The following is an experimental technique. Please carefully consider the cons listed at the end before using it in your project.
Good news! Container queries have been proposed to the CSSWG by Miriam Suzanne, and are being prototyped in Chromium.
I've previously written about CSS layout that comes close to container queries. I've also written about fluid typography with viewport units.
But this article is about fluid typography relative to the parent container.
The Problem
Existing CSS-only solutions (like my article linked previously) rely on viewport units in order to trigger a change that will scale typography.
Why? Because there isn't a CSS-only way to get the width of the parent, and any media query solutions rely on the viewport as well.
Here's a comparison of these two options:
/* Fluid typography */
h2 {
/* clamp: min | preferred | max */
font-size: clamp(1.5rem, 4vw, 2rem);
}
/* Media query */
h2 {
font-size: 1.5rem;
}
@media (min-width: 60rem) {
h2 {
font-size: 2rem;
}
}
What we really want is to have a smaller font-size when a container is narrow, which may or may not mean the viewport is narrow.
Solution
Now, we could devise an entirely JavaScript solution, and that might be your first impulse!
However, using vanilla CSS, we can get most of the way there thanks to clamp
and calc
. The missing ingredient towards a solution is obtaining the container width, and that's where we need a little help from JavaScript.
But let's start at the top and work out what our calculation even needs to look like.
We're going to consider this solution within the context of a card component, and specifically the card headline which is set as an h3
.
First, we need to come up with minimum and maximum font-size
values for our h3
, which we'll create as CSS custom properties:
.card h3 {
--max: 2rem; /* 32px */
--min: 1.25rem; /* 20px */
}
But we also need to have a target "midsize" value, and in this case we'll select 28px
.
Let's set our font-size
to this midsize value as a starting point:
.card h3 {
font-size: 28px;
}
This midsize value is the first half of how we'll work out the calculation needed to fluidly resize the typography relative to the container.
Ultimately, the calculation we need is to take a value that represents the width of a single character and determine what that size is relative to the total container width. So now we need to work out a starting container size to arrive at a value to represent that character width.
In our cards context, let's assume that usually, we have three cards in a row when space allows, which produces fairly narrow cards. But maybe we don't always have enough content for three cards, so if there are two cards then they would each share half of the width of the row. And we're going to say that the 2-up cards represent a "midsize" card width.
Given an additional layout constraint which is the max-width we're allowing for the row of cards - 120ch
- in this exact example that gives us a computed midsize card outer width of 576px
.
We also have 1rem
of padding applied around the text content of the card, and we want to subtract that to get the width relative to the content area only. So this gives us a width of 544px
.
Now, we're going to allow a bit of wiggle room in the numbers here and say that the font-size value is equal to the width of one character as well (which is typically not really true for non-monospace font families). But! It will make our calculation easier, and close enough :)
So, to get our value that represents a single character width, we divide our midsize font-size - 28
- by our midsize container width - 544
:
// Rounded down for a nice whole number
28/544 = 0.05;
Which allows us to prepare our formula:
0.05 * [card-width]
But... how do we get both the formula and the dynamic card-width
value into our CSS?
The answer to this is a combination of clamp
, calc
and CSS custom properties.
Let's go ahead and update our rule and then discuss:
.card h3 {
--max: 2rem; // 32px
--min: 1.25rem; // 20px
font-size:
clamp(
var(--min),
calc(0.05px * var(--card-width)),
var(--max)
);
}
We dropped clamp()
into our initial examples, but if you haven't used it, the way to read it is that it accepts a minimum, a preferred, and a max value. It's a very cool CSS function with many applications beyond fluid font-sizing.
So, we dropped in the min
and max
CSS custom properties that we already had set up. And we've added a new property of card-width
, and for now we're defaulting it to the midsize card value that we found.
But in the middle, we include calc()
which is a CSS function that allows us to perform math calculations. And, it's fortunately friendly to dynamic values! Eventually, it will act as guardrails to prevent the computed middle value from shrinking below the minimum, or growing above the maximum.
We've provided calc()
our value of 0.05
, and then multiply it by the card-width
custom property. And while the addition of the px
to this value probably looks funny, it's because the card-width
is going to be unitless, and we need the final computed value to have a unit to work.
But wait! Where are we setting the card-width
custom property?
For our cards, we're using CSS grid and a particular method that will automagically resize the grid columns containing the cards. Using this, all cards will resize at the same rate and always be equal width as each other.
So for this example, we're going to set the card-width
custom property at the level of the .card-wrapper
class which is the containing element for the cards. This way, we can update it once, and the value will cascade to the cards. Otherwise, we would have to individually update the value per card h3 if we scoped it more specifically.
Here's the extra rule:
.card-wrapper {
--card-width: 544;
}
For now, we're defaulting this to the size we found to be our midsize card width. You'll also notice it's unitless, which is for simplicity when we update it with JavaScript in a moment.
The result of our CSS so far is still static and will be 28(ish)px
.
It's time to add in the JavaScript to help us compute the actual card width.
We'll create a tiny function that we'll eventually call on load and also upon window resize.
To start off, we'll create some constants. Since in this exact context all cards will be equal width, we only need to examine the first one we find.
const updateCardFontSize = () => {
const card = document.querySelector(".card");
const cardWrapper = document.querySelector(".card-wrapper");
const cardWidth = card.clientWidth;
};
Finally, we add one last line that will use the cardWidth
computed value, which is assumed to be pixels, as the value of the --card-width
CSS custom property. This is possible by using style.setProperty()
which intakes the full custom property name, and then the desired value:
const updateCardFontSize = () => {
// ...constants
cardWrapper.style.setProperty("--card-width", cardWidth);
};
Now all that's left is to initiate this function on load and on window resize (the full demo includes a debounce
function as well).
updateCardFontSize();
window.addEventListener("resize", updateCardFontSize);
Demo
This CodePen demonstrates all of these pieces together, and you are encouraged to open the full pen to resize and get the full effect! You can also add and remove cards to see how that alters the allowed font-size
. For example, the computed size won't reach the max
value unless there's only one card and the viewport is larger than ~670px
.
Pros and Cons
Reminder: this technique is experimental!
I hope you've learned some useful things about clamp
, calc
, and CSS custom properties, but please consider the following before using this technique in your projects.
If you do decide to use it, consider limiting it to a single section or component, and not all typography across your application.
Evaluate if you really need this technique, or if viewport sizing via clamp
or a series of media queries is best suited for your goals.
Pros
-
font-size
is relative to the container, rather than the viewport. - Possibly reduce reliance on text truncation methods due to the technique keeping a similar number of characters per line across container sizes
Cons
- Potential of a brief flash at the undesired
font-size
, both annoying to users and possibly impacting Cumulative Layout Shift (CLS) performance scores - Difficult to scale across multiple contexts and typography elements
- Potential to hit performance issues if trying to attach to too many elements
-
font-size
"ramps" need to be carefully constructed to retain visual hierarchy throughout resizing - Possibility of not allowing zooming to increase
font-size
as required by WCAG accessibility guidelines (also a pitfall of fluid typography based on viewport units)
What do you think? Have a suggestion for how this could be improved? Spot an error in my math? Drop a comment! If you enjoy CSS-focused front-end tips and tutorials, follow @5t3ph on Twitter!
Top comments (2)
This is pretty cool! I definitely appreciate the extensive "cons" list. Thank you for sharing this!
BTW, I'd like to point out not a mistake, but a minor simplification. We can drop the
calc()
wrapper in aclamp()
value, so we'd end up with:๐คฏ Welllll dang! I totally missed that for
clamp()
, that's super awesome! Thanks for mentioning it!