We all know how to do responsive design, right? We use media queries. Well no, we use container queries now, don’t we? Sometimes we get inventive with flexbox or autoflowing grids. If we’re feeling really adventurous we can reach for fluid typography.
I’m a bit uncomfortable that responsive design is often pushed into discreet chunks, like “layout A up to this size, then layout B until there’s enough space for layout C.” It’s OK, it works and fits into a workflow where screens are designed as static layouts in PhotoFigVa (caveat, I made that up). But the process feels like a compromise to me. I’ve long believed that responsive design should be almost invisible to the user. When they visit my site on a mobile device while waiting in line for K-Pop tickets, they shouldn’t notice that it’s different from just an hour ago, sitting at the huge curved gaming monitor they persuaded their boss they needed.
Consider this simple hero banner and its mobile equivalent. Sorry for the unsophisticated design. The image is AI generated, but It’s the only thing about this article that is.
The meerkat and the text are all positioned and sized differently. The traditional way to pull this off is to have two layouts, selected by a media, sorry, container query. There might be some flexibility in each layout, perhaps centering the content, and a little fluid typography on the font-size
, but we’re going to choose a point at which we flip the layout in and out of the stacked version. As a result, there are likely to be widths near the breakpoint where the layout looks either a little empty or a little congested.
Is there another way?
It turns out there is. We can apply the concept of fluid typography to almost anything. This way we can have a layout that fluidly changes with the size of its parent container. Few users will ever see the transition, but they will all appreciate the results. Honestly, they will.
Let’s get this styled up
For the first step, let’s style the layouts individually, a little like we would when using width queries and a breakpoint. In fact, let’s use a container query and a breakpoint together so that we can easily see what properties need to change.
This is the markup for our hero, and it won’t change:
This is the relevant CSS for the wide version:
#hero {
container-type: inline-size;
max-width: 1200px;
min-width: 360px;
.details {
position: absolute;
z-index: 2;
top: 220px;
left: 565px;
h1 { font-size: 5rem; }
p { font-size: 2.5rem; }
}
&::before {
content: '';
position: absolute;
z-index: 1;
top: 0; left: 0; right: 0; bottom: 0;
background-image: url(../meerkat.jpg);
background-origin: content-box;
background-repeat: no-repeat;
background-position-x: 0;
background-position-y: 0;
background-size: auto 589px;
}
}
I’ve attached the background image to a ::before
pseudo-element so I can use container queries on it (because containers cannot query themselves). We’ll keep this later on so that we can use inline container query (cqi
) units. For now, here’s the container query that just shows the values we’re going to make fluid:
@container (max-width: 800px) {
#hero {
.details {
top: 50px;
left: 20px;
h1 { font-size: 3.5rem; }
p { font-size: 2rem; }
}
&::before {
background-position-x: -310px;
background-position-y: -25px;
background-size: auto 710px;
}
}
}
You can see the code running in a live demo — it’s entirely static to show the limitations of a typical approach.
Let’s get fluid
Now we can take those start and end points for the size and position of both the text and background and make them fluid. The text size uses fluid typography in a way you are already familiar with. Here’s the result — I’ll explain the expressions once you’ve looked at the code.
First the changes to the position and size of the text:
/* Line changes
* -12,27 +12,32
*/
.details {
/* ... lines 14-16 unchanged */
/* Evaluates to 50px for a 360px wide container, and 220px for 1200px */
top: clamp(50px, 20.238cqi - 22.857px, 220px);
/* Evaluates to 20px for a 360px wide container, and 565px for 1200px */
left: clamp(20px, 64.881cqi - 213.571px, 565px);
/* ... lines 20-25 unchanged */
h1 {
/* Evaluates to 3.5rem for a 360px wide container, and 5rem for 1200px */
font-size: clamp(3.5rem, 2.857rem + 2.857cqi, 5rem);
/* ... font-weight unchanged */
}
p {
/* Evaluates to 2rem for a 360px wide container, and 2.5rem for 1200px */
font-size: clamp(2rem, 1.786rem + 0.952cqi, 2.5rem);
}
}
And here’s the background position and size for the meerkat image:
/* Line changes
* -50,3 +55,8
*/
/* Evaluates to -310px for a 360px wide container, and 0px for 1200px */
background-position-x: clamp(-310px, 36.905cqi - 442.857px, 0px);
/* Evaluates to -25px for a 360px wide container, and 0px for 1200px */
background-position-y: clamp(-25px, 2.976cqi);
/* Evaluates to 710px for a 360px wide container, and 589px for 1200px */
background-size: auto clamp(589px, 761.857px - 14.405cqi, 710px);
Now we can drop the container query entirely.
Let’s explain those clamp()
expressions. We’ll start with the expression for the top
property.
/* Evaluates to 50px for a 360px wide container, and 220px for 1200px */
top: clamp(50px, 20.238cqi - 22.857px, 220px);
You’ll have noticed there’s a comment there. These expressions are a good example of how magic numbers are a bad thing. But we can’t avoid them here, as they are the result of solving some simultaneous equations — which CSS cannot do!
The upper and lower bounds passed to clamp()
are clear enough, but the expression in the middle comes from these simultaneous equations:
f + 12v = 220
f + 3.6v = 50
…where f
is the number of fixed-size length units (i.e., px
) and v
is the variable-sized unit (cqi
). In the first equation, we are saying that we want the expression to evaluate to 220px
when 1cqi
is equal to 12px
. In the second equation, we’re saying we want 50px
when 1cqi
is 3.6px
, which solves to:
f = -22.857
v = 20.238
…and this tidies up to 20.238cqi – 22.857px
in a calc()
-friendly expression.
When the fixed unit is different, we must change the size of the variable units accordingly. So for the element’s
font-size
we have;
/* Evaluates to 2rem for a 360px wide container, and 2.5rem for 1200px */
font-size: clamp(2rem, 1.786rem + 0.952cqi, 2.5rem);
This is solving these equations because, at a container width of 1200px
, 1cqi
is the same as 0.75rem
(my rems are relative to the default UA stylesheet, 16px
), and at 360px
wide, 1cqi
is 0.225rem
.
f + 0.75v = 2.5
f + 0.225v = 2
This is important to note: The equations are different depending on what unit you are targeting.
Honestly, this is boring math to do every time, so I made a calculator you can use. Not only does it solve the equations for you (to three decimal places to keep your CSS clean) it also provides that helpful comment to use alongside the expression so that you can see where they came from and avoid magic numbers. Feel free to use it. Yes, there are many similar calculators out there, but they concentrate on typography, and so (rightly) fixate on rem
units. You could probably port the JavaScript if you’re using a CSS preprocessor.
The clamp()
function isn’t strictly necessary at this point. In each case, the bounds of clamp()
are set to the values of when the container is either 360px
or 1200px
wide. Since the container itself is constrained to those limits — by setting min-width
and max-width
values — the clamp()
expression should never invoke either bound. However, I prefer to keep clamp()
there in case we ever change our minds (which we are about to do) because implicit bounds like these are difficult to spot and maintain.
Avoiding injury
We could consider our work finished, but we aren’t. The layout still doesn’t quite work. The text passes right over the top of the meerkat’s head. While I have been assured this causes the meerkat no harm, I don’t like the look of it. So, let’s make some changes to make the text avoid hitting the meerkat.
The first is simple. We’ll move the meerkat to the left more quickly so that it gets out of the way. This is done most easily by changing the lower end of the interpolation to a wider container. We’ll set it so that the meerkat is fully left by 450px
rather than down to 360px
. There’s no reason the start and end points for all of our fluid expressions need to align with the same widths, so we can keep the other expressions fluid down to 360px
.
Using my trusty calculator, all we need to do is change the clamp()
expressions for the background-position
properties:
/* Line changes
* -55,5 +55,5
*/
/* Evaluates to -310px for a 450px wide container, and 0px for 1200px */
background-position-x: clamp(-310px, 41.333cqi - 496px, 0px);
/* Evaluates to -25px for a 450px wide container, and 0px for 1200px */
background-position-y: clamp(-25px, 3.333cqi - 40px, 0px);
This improves things, but not totally. I don’t want to move it any quicker, so next we’ll look at the path the text takes. At the moment it moves in a straight line, like this:
But can we bend it? Yes, we can.
A Bend in the path
One way we can do this is by defining two different interpolations for the top
coordinate that places the line at different angles and then choosing the smallest one. This way, it allows the steeper line to “win” at larger container widths, and the shallower line becomes the value that wins when the container is narrower than about 780px
. The result is a line with a bend that misses the meerkat.
All we’re changing is the top value, but we must calculate two intermediate values first:
/* Line changes
* -18,2 +18,9 @@
*/
/* Evaluates to 220px for a 1200px wide container, and -50px for 360px */
--top-a: calc(32.143cqi - 165.714px);
/* Evaluates to 120px for a 1200px wide container, and 50px for 360px */
--top-b: calc(20px + 8.333cqi);
/* By taking the max, --topA is used at lower widths, with --topB taking over when wider.
We only need to apply clamp when the value is actually used */
top: clamp(50px, max(var(--top-a), var(--top-b)), 220px);
For these values, rather than calculating them formally using a carefully chosen midpoint, I experimented with the endpoints until I got the result I wanted. Experimentation is just as valid as calculation as a way of getting the result you need. In this case, I started with duplicates of the interpolation in custom variables. I could have split the path into explicit sections using a container query, but that doesn’t reduce the math overhead, and using the min()
function is cleaner to my eye. Besides, this article isn’t strictly about container queries, is it?
Now the text moves along this path. Open up the live demo to see it in action.
CSS can’t do everything
As a final note on the calculations, it’s worth pointing out that there are restrictions as far as what we can and can’t do. The first, which we have already mitigated a little, is that these interpolations are linear. This means that easing in or out, or other complex behavior, is not possible.
Another major restriction is that CSS can only generate length values this way, so there is no way in pure CSS to apply, for example, opacity or a rotation angle that is fluid based on the container or viewport size. Preprocessors can’t help us here either because the limitation is on the way calc()
works in the browser.
Both of these restrictions can be lifted if you’re prepared to rely on a little JavaScript. A few lines to observe the width of the container and set a CSS custom property that is unitless is all that’s needed. I’m going to use that to make the text follow a quadratic Bezier curve, like this:
There’s too much code to list here, and too much math to explain the Bezier curve, but go take a look at it in action in this live demo.
We wouldn’t even need JavaScript if expressions like calc(1vw / 1px)
didn’t fail in CSS. There is no reason for them to fail since they represent a ratio between two lengths. Just as there are 2.54cm
in 1in
, there are 8px
in 1vw
when the viewport is 800px
wide, so calc(1vw / 1px)
should evaluate to a unitless 8
value.
They do fail though, so all we can do is state our case and move on.
Fluid everything doesn’t solve all layouts
There will always be some layouts that need size queries, of course; some designs will simply need to snap changes at fixed breakpoints. There is no reason to avoid that if it’s right. There is also no reason to avoid mixing the two, for example, by fluidly sizing and positioning the background while using a query to snap between grid definitions for the text placement. My meerkat example is deliberately contrived to be simple for the sake of demonstration.
One thing I’ll add is that I’m rather excited by the possibility of using the new Anchor Positioning API for fluid positioning. There’s the possibility of using anchor positioning to define how two elements might flow around the screen together, but that’s for another time.