Mert Özen Further With Every Line
Frontend CSS3: Flexbox, Grid and Modern CSS — #08

Responsive Design: Mobile-First, Media Query Range Syntax, Fluid Typography with clamp() and User Preferences

Mert Özen Nov 14, 2026 11 min 2 views
Responsive Design: Mobile-First, Media Query Range Syntax, Fluid Typography with clamp() and User Preferences

A responsive page is written mobile-first, its breakpoints are chosen from the content in rem, and font sizes go inside clamp() as the sum of a rem and a vw; a heading that uses only vw does not grow at all under zoom. On Bookstore we measure, in Chromium and WebKit, the range syntax, why rem in a media query ignores the page's root font size, zoom, prefers-reduced-motion, prefers-contrast, prefers-color-scheme and hover/pointer.

Version note: This post follows the Media Queries Level 4/5 and CSS Values 4 specifications and the state of Baseline in November 2026; the examples were run in Chrome 158 and WebKit 27.2 via Playwright 1.65, and Firefox support is taken from web-features 3.46 data.

Guess First

A designer working on Bookstore, the online bookstore used as the shared example across this site's series, changed the root font size so that text looks a little bigger: html { font-size: 20px; }. Every 1rem on the page is now 20px. The skeleton of the catalog page switches to two columns with this media query: @media (width >= 48rem). The page is opened on an 800px wide tablet.

How many pixels is 48rem here? If it is 48 × 20 = 960, the skeleton stays in one column on the 800px screen; if it is 48 × 16 = 768, it switches to two columns. Which one is right, and why?

So far in this series we have built the layout. In this post we fit that layout to different screens and different users: mobile-first writing, the range syntax of media queries, font sizes with clamp() that grow with the screen without breaking zoom, the preferences the user chose in their operating system, and the media features that tell a mouse from a finger.

Mobile-First and Content-Based Breakpoints

We already wrote Bookstore's page skeleton mobile-first in the Grid post of this series: the rules outside any media query describe the narrowest screen, the single-column phone; the @media (width >= 48rem) block adds two columns only for wider screens. The opposite also works, writing the desktop first and shrinking it with max-width queries, but it has two costs. The phone browser applies the desktop rules first and then undoes them; every rule you forget to undo shows up as a bug on the phone. And the simple case (one column) is written as an exception to the complex one. With mobile-first, every media query adds something and undoes nothing.

Choose a breakpoint where the content breaks, not by the width of a device. I did not choose 48rem because it is "tablet width" but from this sum: the filter column is 14rem, next to it at least two 14rem cards, 1rem of space between them and 1rem of padding on both sides of the result area. That adds up to 45rem; 48rem leaves a little room. On a narrower screen a two-column skeleton could fit only one card into the result area, and the filters would waste space. Device lists change every year; what the content needs does not.

Media Query Range Syntax

Writing (width >= 48rem) is the Media Queries Level 4 counterpart of the old (min-width: 48rem). Both mean the same thing, but the new one is clearer in two ways. First, you can read whether the boundary value is included: min-width: 48rem includes 48rem too, and you had to memorise that; >= says it outright. Second, a range can be written as a single expression. On the lab page I tried both rules together:

html { font-size: 20px; }   /* the page changes its own rem... */
.layout::after { content: "one column"; }
@media (width >= 48rem) {   /* ...but this 48rem is still 48 × 16px */
    .layout::after { content: "two columns"; }
}
@media (48rem <= width < 64rem) {
    .layout::before { content: "tablet: "; }
}
node scripts/08-responsive.mjs
--- chromium
760px: root font-size 20px, layout "one column"
800px: root font-size 20px, layout "tablet: two columns"
1100px: root font-size 20px, layout "two columns"
 360px zoom 100%: on screen vw-only 14.4px, fluid 28.0px, body 16.0px
 768px zoom 100%: on screen vw-only 30.7px, fluid 34.6px, body 16.0px
1280px zoom 100%: on screen vw-only 51.2px, fluid 40.0px, body 16.0px
1280px zoom 200%: on screen vw-only 51.2px, fluid 64.0px, body 32.0px  growth 1.00x / 1.60x / 2.00x
1280px zoom 300%: on screen vw-only 51.2px, fluid 84.0px, body 48.0px  growth 1.00x / 2.10x / 3.00x
default                           scroll-behavior smooth, --color-border #e5e7eb, hover yes, dark no, filter link height 18px
less motion, more contrast, dark  scroll-behavior auto, --color-border #6b7280, hover yes, dark yes, filter link height 18px
phone (touch)                     scroll-behavior smooth, --color-border #e5e7eb, hover no, dark no, filter link height 44px
chromium 320px cover 288x432
chromium 1280px cover 320x480
webkit 320px cover 288x432
webkit 1280px cover 320x480

The first three lines answer the guess. Even though the page's root font size is 20px, the skeleton switched to two columns at 800px and not at 760px. So 48rem is 768px here. The specification defines this explicitly: relative units inside a media query (em, rem) are not computed from the page's styles but from the initial value, that is, the default font size set by the browser or the user. That makes sense, because the result of a media query can change the page's styles; if the styles could change the media query in turn, you would get a loop. In practice it means this: if the user sets the default font size to 20px in the browser settings, the 48rem breakpoint moves to 960px, and on screens where two columns of large text would not fit, the page stays in one column. A pixel-based breakpoint does not react to that setting, so write your breakpoints in rem or em.

The "tablet: " prefix appeared only at 800px: (48rem <= width < 64rem) describes the range from 768 to 1024 pixels in one expression. In the old syntax that was something like (min-width: 48rem) and (max-width: 63.99rem), and those .99 boundaries led to bugs where, right at the boundary, neither rule or both rules applied. The range syntax has been Baseline since March 2023 and widely available since September 2025.

Fluid Typography with clamp() and Zoom

To make headings grow gradually as the screen gets wider, you can use one formula instead of writing a separate font size at every breakpoint. clamp(MIN, PREFERRED, MAX) computes the middle value and keeps it between the two limits. The lab page has two headings, one using only a viewport unit and one using clamp():

.vw-only { font-size: 4vw; }
.fluid { font-size: clamp(1.75rem, 1.2rem + 2vw, 2.5rem); }

Look at the second block of the output. At 360px, 4vw gives 14.4px, so the heading is smaller than the body text (16px). At 1280px it reaches 51.2px and keeps growing with the screen. The clamp() heading is at its lower limit (28px) at 360px, 34.6px at 768px (1.2rem + 2vw = 19.2 + 15.36), and at its upper limit (40px) at 1280px. The real difference shows up with zoom.

Browser zoom makes the CSS pixel bigger: at 200% zoom a 1280 pixel screen looks 640 CSS pixels wide to the page, and every CSS pixel covers two screen pixels. In the script I simulated zoom exactly that way:

// zoom: the layout viewport shrinks by the zoom factor, every CSS px covers zoom × device px
const { browser, page } = await open('lab/08-fluid.html', { engine, width: Math.round(width / zoom), deviceScaleFactor: zoom });

The result: at 200% zoom the body text went from 16 to 32 pixels on screen, twice as large, as expected. The 4vw heading stayed at 51.2px; zoom had no effect on it at all, because as the viewport narrows, the vw value shrinks by exactly the same factor. However much a user with low vision zooms in, the heading does not grow. W3C's technique F94 describes exactly this situation: using viewport units as the main way to set font size is likely to fail WCAG 1.4.4, which requires that text can be resized up to 200%.

The clamp() heading reached 64px at 200% (1.6 times) and 84px at 300% (2.1 times). It grows, because the 1.2rem in the formula grows with zoom and the limits are in rem too. But not twice as much: the vw part of the formula shrinks under zoom. The rule that follows is simple: the bigger the vw share in a fluid formula, the less effect zoom has. Always write the preferred value as the sum of a rem and a vw, keep the vw share small, and measure that the largest size is reachable with zoom. The script in this post is enough for that. The accessibility details belong to the upcoming Accessibility (a11y) series.

In Bookstore I gave the same formula to every h1:

/* fluid heading: grows with the viewport between 28px and 40px; the rem part keeps it zoomable */
h1 {
    font-size: clamp(1.75rem, 1.2rem + 2vw, 2.5rem);
}

User Preferences: prefers-color-scheme, prefers-reduced-motion, prefers-contrast

Some media features do not read the screen but the preferences the user chose in their operating system or browser. For Bookstore I collected these in a separate file and made it the last import of the components layer in main.css, so that it beats the component defaults in the same layer by source order:

/* user preferences: these rules come last in the components layer, so they win over the component defaults */

/* smooth scrolling only for users who did not ask for less motion */
@media (prefers-reduced-motion: no-preference) {
    html {
        scroll-behavior: smooth;
    }
}

/* darker borders and secondary text for users who asked for more contrast */
@media (prefers-contrast: more) {
    :root {
        --color-muted: #374151;
        --color-border: #6b7280;
    }
}

/* a hover effect only where the main pointer can actually hover */
@media (hover: hover) {
    .book-card:hover {
        box-shadow: 0 0 0 2px var(--color-muted);
    }
}

/* bigger tap targets when the main pointer is a finger */
@media (pointer: coarse) {
    .filters a {
        display: block;
        min-block-size: 2.75rem;
        align-content: center;
    }
}

For the motion preference I checked no-preference, not reduce. The difference is small but it matters: instead of making smooth scrolling the default and "turning it off for people who asked for less motion", I turn it on only for people with no objection. In an environment that does not support the media query, the rule never applies and the page stays still, so it fails on the right side. Reducing animation instead of switching it off is the topic of the animation post of this series.

For the contrast preference I changed only the values of two custom properties and touched none of the components. That is the most concrete benefit of defining design tokens as custom properties, and we will go through it in the theming post of this series. prefers-color-scheme works with the same mechanism and we will build the dark theme in that post; here I only checked that the browser reports the preference correctly. Playwright can simulate all three preferences when it opens the page (reducedMotion, contrast, colorScheme). In the "less motion, more contrast, dark" line of the output, scroll-behavior went back to auto, the border colour became #6b7280, and prefers-color-scheme: dark matched.

Mouse or Finger? hover and pointer

Screen width does not say how the device is used: a 1024px tablet can be used with a finger, a 390px window with a mouse. The hover and pointer media features ask about the input device directly. hover: hover says that the primary input device can rest over an element (mouse, touchpad); pointer: coarse says that the primary input device has low precision (a finger). any-hover and any-pointer look not only at the primary device but at any connected one; on a laptop with a touch screen and a mouse, pointer: fine and any-pointer: coarse can both be true at the same time.

In the "phone (touch)" line of the output I ran Playwright as a touch phone (isMobile and hasTouch). hover: hover did not match, so the card's shadow will never be applied; that is right, because on a phone holding a finger on a card is mostly the same as tapping it. The filter links went from an 18px inline height to 44px blocks. For that I used align-content: center on a block container, which we saw in the previous post: the text sits in the middle of the 44px. The reasoning behind touch target sizes and their WCAG counterparts is again a topic for the Accessibility (a11y) series.

Do not tie any function to hover: an interface that opens a menu only on hover never opens it on a touch screen. Hover should only be an extra visual hint; the same function has to work with a click and with the keyboard.

Responsive Images on the CSS Side: aspect-ratio and object-fit

Which resolution of an image gets downloaded (srcset, sizes, picture) is HTML's job, and it was covered in post 8 of the HTML5 and Semantic HTML series. CSS's job is to fit the incoming image's box onto the screen. The cover on the book detail page has done this since the box model post of this series: its width is min(100%, 20rem), its ratio aspect-ratio: 2 / 3, and the content fills the box with object-fit: cover. The last lines of the output: on a 320px phone the cover is 288 × 432px (the width left after the page's padding on both sides), at 1280px it is 320 × 480px. In both cases the ratio is 2:3, and the page does not shift while the image loads, because the box height is known before the image arrives.

If a cover from the publisher is not 2:3, object-fit: cover crops the image to fill the box, while contain shows the whole image and leaves space at the edges. object-position chooses the area that is kept; since the title is usually at the top of a cover, object-position: top often works better than the centre.

In Production

Everything in this post is Baseline, and most of it has been widely available for years: clamp() since 2020, hover/pointer since 2018, prefers-color-scheme and prefers-reduced-motion since 2020, prefers-contrast since 2022, the range syntax since 2023. You do not need fallbacks.

When you test responsive behaviour, narrowing the window is not enough. The script in this post opens the same page in four different ways: different widths, zoom, user preferences and a touch device. Putting these into a CI step catches far more bugs than settling for "it looks fine on the desktop". It does not replace testing on real devices, but it checks the things that break most often on every commit.

There is one more trap with viewport units: because the address bar of mobile browsers hides and reappears as you scroll, 100vh is taller than the visible area on most phones. The svh, lvh and dvh units we covered in the box model post of this series exist for that; Bookstore's page skeleton uses min-block-size: 100svh.

Common Pitfalls

First, setting font size with vw alone. As we measured, it does not grow at all with zoom, and on a phone it can end up smaller than the body text.

Second, writing breakpoints in px. They do not react to the user's default font size setting; use rem or em, and remember that these values are computed from the initial value, not from the page's root font size.

Third, guessing touch devices from screen width. The assumption "narrower than 768px means touch" is wrong both on large tablets and in narrow desktop windows; ask pointer and hover.

Fourth, letting max-width and min-width queries overlap at their boundaries. (max-width: 768px) and (min-width: 768px) both match at exactly 768px; with < and >= in the range syntax, that ambiguity goes away.

A Small Experiment

★☆☆ Change the formula of .fluid in lab/08-fluid.html to clamp(1.75rem, 1.5rem + 1vw, 2.5rem). Guess whether the growth at 200% zoom goes up or down, then check with node scripts/08-responsive.mjs.

★★☆ Add the case where the user sets the default font size to 20px to the script (in Chromium, open a CDP session and use Page.setFontSizes) and find the width at which catalog.html switches to two columns.

★★★ Add a rule for forced-colors: active to preferences.css so that card borders do not disappear in Windows high contrast themes. Verify with Playwright's forcedColors option that the card's border colour turns into a system colour.

In the next post we take responsive design down from the page to the component: container queries. The same book card will look different in a narrow column and in a wide area; but we will also measure why subgrid, which we set up in the previous post, and a container query do not work together on the same element.