Published: October 23, 2025
One of the goals when writing CSS is to build component parts that will adapt well to different (and unexpected) contexts. Ideally, a component can be placed inside any "container" element without it feeling broken or out of place. How can you accomplish this in a complex layout like a store where the primary component—the "product"—has to fit into a variety of list layouts, including the sidebar?
Responsive typography with cqi
units
The first step is to define some basic sizing variables that could be reused across the project—starting with whitespace sizes. But before creating any custom properties, the browser provides some useful named values as CSS units:
1em
: the current font size.1rem
: the font size on the:root (html)
element.1lh
/1rlh
: the current and root line heights.1vw
: the viewport width.1vi
: the viewport "inline" size (for English, this is the same asvw
).1cqi
: the inline size of the nearest "container" (defaulting to the viewport).
You can think of these units as common variables provided by the browser, with a shorthand syntax for multiplication. If you wanted half of a --line-height
custom property in CSS, you would need to write the entire calculation calc(0.5 * var(--line-height))
, but with the lh
unit, you can ask for 0.5lh
instead.
Custom properties like --brand-color
and --button-background
have different meanings and serve a different purpose, even when they result in the same deepPink
color (another browser-provided variable). Similarly, 1em
might sometimes be equal to 16px
, but that's not a stable relationship. Like any other variable, units should be used to express a relationship, rather than an expected value.
Both 1em
and 1lh
are font-relative units that could be used for spacing, but only one of them has a reliable relationship to the current line height. If this page involved a lot of elements with prose in them—paragraphs and lists, for example—the lh
unit would work well for spacing between them:
p, ul, ol {
margin-block: 1lh;
}
That will maintain a consistent baseline rhythm, without any extra work. But this page has almost no prose. Instead of spacing within a flow of text, this layout requires spacing to push text away from the edge of a card, and spacing between columns and rows in a grid or stacked layout. In this case there are several things to consider:
- Multiples (or fractions) of
1lh
may still be useful for maintaining vertical rhythm across the page. - The
cqi
unit would account for the amount of available space in a given context.
By combining these two units in a round()
function, the --gap
variable is based primarily on the container size, but rounded up to a multiple of quarter-lines:
html {
--gap: round(up, 2cqi, 0.25lh);
}
If the line-height
is 20px
, then the --
gap will be multiples of 5px
—but the exact multiple will depend on available space. If either the line-height
or available space change, the --gap
variable will adapt to its new context. To make the --gap
consistent across the entire design, you could replace the container-relative cqi
units with viewport-relative vi
units.
The same approach is useful when establishing "fluid" font sizes that respond to available space. This --body-text
variable is based on the user-provided font preference, with some range to adapt based on the container. In this case, clamp()
ensures the font will be at least as large as the user's preference, can grow some as the container size increases, but will stop growing at some point:
html {
--body-text: clamp(1rem, 0.875rem + 0.5cqi, 1.25rem);
}
- The range is clamped between
1rem
and1.25rem
, to stay near the user-selected font size. - The
cqi
value determines how fast the font will grow or shrink in relation to available space, which is added to arem
value to offset that growth based on the user-selected size.
Keeping the central rem
value near 1
and the added cqi
value low ensures that users still have significant control when zooming in or out. You can adjust those two values, and then use browser zoom to see how they interact. The closer you get to 100cqi
, and the farther you fall below 1rem
, the less influence user font preferences will have—and the less font sizes will respond to zooming in or out.
The --item-title
variable is slightly larger and more responsive, and the --list-title
size responds to the viewport size (using vi
) rather than the immediate container size. That way item headings respond to their context, but the main list headings all match in size no matter where they show up.
Defining containers to measure in context
At this point, container query units have been used to create adaptive typography on a web page, but new containers haven't been defined.
By default, 1cqi
(1/100
container query inline size) is the same as 1svi
(1/100
small viewport inline size) because the "small" viewport acts as the initial container for any web page. In order to take full advantage of the cqi
unit, you need to define additional "containers" within the page. The primary layout containers on this page are the product-list
and shopping-cart
—so they are set to expose their inline-size
.
product-list,
shopping-cart {
container-type: inline-size;
}
Container query units—including cqi
—aren't able to measure the element that they are used on. If you set the width
of shopping-cart
to 25cqi
, it would be a paradox to determine the container's width based on its own width! Instead, the result will be based on the next ancestor container in the tree hierarchy that contains shopping-cart
.
The product-detail
cards are part of a product-list
grid that can be changed by the user. The list
layout option displays each card at full width. In that case, referring to the parent container size is useful, but both the small-grid
and large-grid
options cause the card to grow and shrink depending on how many columns fit into the container. Even when the container is quite large, the cards can remain tightly packed into smaller grid cells.
There's currently no way to declare those grid cells as "containers" directly. Instead, an extra element is needed to measure within each cell. That's why each product-detail
instance has an <article>
element nested directly inside. product-detail > article
is the card to be styled, while product-detail
itself is used only as a container to measure. That allows the cqi
-based text and spacing calculations previously defined to be recalculated for the space available to each card.
Explicit container queries
Container units are powerful, but sometimes it's useful to make more dramatic changes in a component layout when the available size crosses a threshold. These are often called breakpoints—since the fix is applied at the point when a given layout begins to break. You may already be familiar with using @media
to add breakpoints based on the viewport size:
main {
display: grid;
grid-template:
'controls' auto
'cart' 1fr
'list' auto
/ minmax(min-content, 1fr);
@media (width > 30em) {
grid-template:
'controls controls' auto
'list cart' 1fr
/ 2fr 1fr
;
}
}
Here, shopping-cart
appears above the main product-list
on small screens—but at a certain point, there's more horizontal space, and a sidebar makes more sense. Since that shift depends on the overall viewport, a media query is used to handle the change. However, the product-list
and product-detail
components might appear in different contexts, somewhat independent of the viewport size. When the viewport grows wider than the sidebar breakpoint, the shopping-cart
component suddenly becomes smaller, providing less space for products inside. This is where container queries become necessary. The syntax is nearly identical to a media query, but uses @container
rather than @media
at the start of the rule:
article {
display: grid;
grid-template:
'image' auto 'title' auto
'summary' auto
'button' auto / auto
;
@container (inline-size > 40ch) {
--image-ratio: 1;
grid-template:
'image title' auto
'image summary' 1fr
'image button' auto
/ minmax(50px, min(20%, 500px)) 1fr
;
}
@container (inline-size > 50ch) {
grid-template:
'image title title' auto
'image summary button' 1fr
/ minmax(50px, min(20%, 500px)) 1fr fit-content(20%);
}
}
The initial goal of this post is that components should be able to respond to any context. To make that work, each component defines its own internal behavior, without explicit knowledge of the surrounding components. Container queries help us accomplish that. The product-list
and product-detail
components don't need to be aware of why they have more or less space in a given context, they only need to know how much space is currently available.
Transition grid templates and visibility
With a layout that's changing often based on container queries, how do we smooth out all of the transitions? When using grids for layout, it's possible to animate the size of a column or row, as well as the gap between columns and rows. In this case, the cart area and gap are expanded from 0
width when the cart is opened. In order to animate grid templates like this, two things are required:
- The initial and end states must have the same number of tracks (columns or rows).
- The animated tracks must use comparable units.
While it would be possible to change from a one-column grid to a two-column grid when the sidebar is hidden, the empty sidebar column is instead resized to 0
, and the column-gap is also set to 0
. When the cart is open in the sidebar, the 0
-width column transitions to calc(15em + 1cqi)
. Since the calculation results in a normal length value, the transition can be animated from length 0
. The gap also animates from one length to another—from 0
to var(--gap)
—which was defined earlier:
main {
transition: grid-template 250ms, gap 250ms;
}
Non-Baseline animation enhancements
The shopping-cart
and product-detail
components are also animated when hidden or shown, and this is done using two recent features that are not yet Baseline, but work as progressive enhancements for browsers that do have support:
- Applying
interpolate-size: allow-keywords
allows transitioning element dimensions from0
toauto
. This is used for transitioning the products to and from ablock-size
of0
when they are added or removed from the cart. Since theinterpolate-size
property inherits, that only needs to be defined once on the<html>
element, and it is available to every other element on the page. This is only supported in Chrome-based browsers (Chrome, Edge, and others), but can be used as a progressive enhancement. The fallback works as expected, just without the animated transition.
- "Discrete" properties like
display
can now be transitioned as well, even though there are no intermediate values betweengrid
andnone
. Instead the transition is applied at the start or end of the duration provided. To achieve that,allow-discrete
is added to thetransition-behavior
property. Whiletransition-behavior
is otherwise Baseline, animating thedisplay
property is not yet supported in Firefox. But again, this works well as a progressive enhancement.
Browser Support
-
Querying media versus containers
There are many situations where container queries and units can be used to replace media queries and viewport units, and that's great when it helps you express the intent of a design more clearly. But one is not meant to replace the other, and there's not a better query or unit that will work in every situation. CSS works best when the relationships established in code match the goals and purpose of the design. When you want text and spacing that is relative to immediate context, container queries provide that functionality, but when you want consistent sizing relative to the overall viewport, media queries and viewport units are still an excellent option. Most websites will likely involve a mix of both.