1. Presentation
  2. CSS
  3. Responsive
  4. Media Contexts and Responsive Design

Presentation · CSS 07

Media Contexts and Responsive Design

One HTML document reaches phones and wall-sized monitors, mice and fingers, light rooms and dark ones, readers who want motion and readers who are made ill by it, and paper. CSS does not pick one of those contexts to design for. It describes a presentation that adapts to all of them.

This page is the working form of the attitude the rest of the section keeps returning to: write a sound baseline, then let the page respond to the space it has, the preferences its reader has set, and the features the browser supports. The order of the sections is the order to apply them in: fluid first, then breakpoints, then container queries, then preferences, capabilities and feature queries on top, with defensive habits throughout.

1. The spectrum of contexts

CSS Basics introduced media types: a style sheet for screen, another for print. That was the first idea of context, and it was far too coarse. A "screen" can be 20 characters wide or 300. It can be touched or pointed at. It can belong to someone who has asked their operating system for dark colors, less motion or more contrast. These dimensions vary independently, and a single style sheet has to serve every combination of them.

DimensionRangeWhere CSS sees it
Viewport sizeA narrow phone to a wide desktop monitor, in both dimensions@media (width …), viewport units
Container sizeDifferent for each component, even on one page@container, container units
InputTouch, mouse, pen, keyboard, voicepointer, hover, any-pointer, any-hover
Color schemeLight or darkprefers-color-scheme, color-scheme, light-dark()
MotionFull or reducedprefers-reduced-motion
ContrastStandard, more, less, or colors forced by the systemprefers-contrast, forced-colors
Text size and zoomThe reader's default font size, and zoom up to 500 percentRelative units: rem, em, ch
MediumScreen or paper@media print, @page
Feature supportThe browser version the reader happens to run@supports, cascade fallbacks

The table is not a list of special cases. Every visitor sits at some point in all of these dimensions at once, and the author cannot know which point. Someone on a small touch screen in landscape, with reduced motion and a large default font, is not exotic. They are using the site right now.

Responsive is more than width

The phrase "responsive web design" (Ethan Marcotte, 2010) came to mean adjusting a layout to the screen's width, and media queries made that easy. Width is the least interesting part of the problem. A narrow viewport does not tell you the device is slow, the connection is metered, or the reader is on a phone; a wide one does not tell you the reader is using a mouse. Rearranging boxes for a narrow screen does nothing about a page that downloads three megabytes of images, or an interface whose hover menus cannot be opened by a finger. Treat width as one input among many, and remember that CSS controls presentation, not payload.

Three strategies, one order

  • Fluid: the design scales continuously with the space available. No thresholds; sizes change smoothly. The tools are relative units, clamp(), and grids that fit as many columns as there is room for.
  • Responsive: the design changes at chosen thresholds. The tools are @media and @container size queries. Use it for structural changes that cannot be expressed as scaling: a sidebar that appears, a navigation bar that collapses.
  • Adaptive: the design responds to preferences and capabilities, not to size. The tools are the prefers-* queries, forced-colors, pointer, hover and @supports.

A good component uses all three, in that order. Start fluid. Add a breakpoint where fluid scaling stops working. Layer adaptive rules on top to respect what the reader and the device have told you.

2. Fluid design first

Responsive design built only from breakpoints has a known failure: the page is designed at the breakpoints and nowhere else. It looks right at 640 pixels and at 1024, and between them it is whatever stretched or squeezed version of the nearest design falls out of the rules. With the range of real viewport sizes (viewports.fyi charts it), most visitors land in between.

Fluid design removes the gap by defining values for every width at once. Normal flow is already fluid, as Basics showed: block boxes fill the inline space and text wraps. Percentages, fr tracks and ch-based measures keep that property. The piece flow lacks is a way to scale sizes, such as type and spacing, smoothly between a sensible minimum and maximum. That piece is clamp().

clamp() as a function of the viewport

clamp(MIN, PREFERRED, MAX) uses the preferred value unless it falls outside the bounds. When the preferred value mixes a fixed part with a viewport-relative part, the result is a piecewise-linear function of the viewport width: flat at the minimum, a straight rising line, flat at the maximum. Three values describe the whole curve.

h1 { font-size: 1.5rem; font-size: clamp(1.25rem, 1rem + 1.25vw, 2rem); }
clamp(1.25rem, 1rem + 1.25vw, 2rem) as a function of viewport widthA graph of font size against viewport width. The font size is flat at the 1.25rem minimum up to a viewport of 20rem, which is 320 pixels. It then rises in a straight line, following 1rem plus 1.25vw, until it reaches the 2rem maximum at a viewport of 80rem, which is 1280 pixels, and stays flat after that. A dashed line continues the preferred value beyond both ends to show what it would be without the clamp.font-sizeviewport width1rem1.25rem2rempreferred, unclamped20rem (320px)80rem (1280px)MIN holdsMAX holds1rem + 1.25vwPREFERRED wins
A fluid value is flat below the minimum, linear in between, and flat above the maximum.

The arithmetic is a line through two points. At a 20rem viewport (320 pixels at the default font size), 1.25vw is 0.25rem, so the preferred value is 1.25rem. At 80rem it is 1rem + 1rem = 2rem. Choose the two sizes you want at two widths, solve for the slope (the vw term) and the intercept (the rem term), and the clamp bounds the ends.

Keep a rem term in the preferred value. A size written purely in vw ignores the reader's default font size and barely changes when they zoom, which fails the WCAG requirement that text can be resized to 200 percent. The rem part keeps the reader in charge. The Fonts section measures this zoom trap and builds fluid type around it in Size, spacing and measure.

Let a tool do the algebra

Solving for slopes by hand for every heading level and spacing step gets old quickly. Utopia (utopia.fyi) generates a whole fluid type scale and space scale from a minimum and maximum viewport, a base size at each end and a ratio. It outputs custom properties, which drop straight into a token layer:

@layer base { :root { --step-0: clamp(1rem, 0.93rem + 0.36vw, 1.25rem); --step-1: clamp(1.2rem, 1.07rem + 0.63vw, 1.56rem); --step-2: clamp(1.44rem, 1.23rem + 1.04vw, 1.95rem); --space-m: clamp(1.5rem, 1.36rem + 0.71vw, 1.88rem); } body { font-size: var(--step-0); } h2 { font-size: var(--step-2); } article > * + * { margin-block-start: var(--space-m); } }

Intrinsic grids

Column counts can be fluid too. A grid that asks for as many columns as fit, each at least a minimum width, needs no breakpoints at all. The min() inside keeps a single column from overflowing a container narrower than the minimum:

ul:where([role="list"]) { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(16rem, 100%), 1fr)); gap: var(--space-m); padding: 0; }

Type, spacing, measure and column counts are usually fully handled by fluid rules. What fluid cannot do is change the structure of the page. That is what breakpoints are for, and fluid design does not abolish breakpoints. It saves them for the changes that need them.

3. Media queries and breakpoints

@media is one of three conditional at-rules, with @container and @supports. All three share a shape: a condition in the prelude, and a block of rules that apply only while the condition is true. They are the closest thing CSS has to an if statement, and the only place it can make rules conditional. Unlike a script that tests the screen once, they are live: when the window is resized, the device rotates, or the reader switches to dark mode, the browser re-evaluates them and restyles the page.

The parts of a media query

A query is an optional media type, then media features in parentheses, joined with and, or and not. A comma-separated list of queries matches if any one of them does.

@media print { … } @media (width >= 40rem) { … } @media screen and (width >= 40rem) and (orientation: landscape) { … } @media (prefers-reduced-motion: reduce), (update: slow) { … } @media not (hover: hover) { … }

The same queries work outside CSS. A link element's media attribute takes one, as does the media attribute on a source inside picture, and JavaScript can test or listen to one with window.matchMedia("(width >= 40rem)"). One vocabulary covers markup, style and script.

FamilyFeaturesNotes
Viewportwidth, height, aspect-ratio, orientationThe layout viewport, not the physical device
Displayresolution, color-gamut, dynamic-range, updateupdate: slow describes e-ink and similar screens
Interactionpointer, hover, any-pointer, any-hoverCovered in section 5
Preferenceprefers-color-scheme, prefers-reduced-motion, prefers-contrast, forced-colorsCovered in section 5
Deprecateddevice-width, device-height, device-aspect-ratioMeasured the screen rather than the viewport; use width and friends

Of the media types, only all, screen and print remain useful, and a query without a type means all. The only keyword seen in older code (only screen and …) existed to hide a query from browsers too old to understand media features. Those browsers are gone, and so is the reason for the keyword.

Range syntax

Older queries wrote ranges as min-width and max-width. Both are inclusive, so two adjacent breakpoints overlap at the exact boundary, and authors patched the overlap with fractional pixel values:

@media only screen and (min-width: 768px) and (max-width: 1023.98px) { .sidebar { float: left; width: 30%; } }

Media Queries Level 4 added real comparison operators. They read as the condition they express, they make the boundary explicit, and they are Baseline widely available:

@media (width >= 40rem) { … } @media (width < 40rem) { … } @media (40rem <= width < 64rem) { … }

Breakpoints in rem, not px

A breakpoint written in pixels is a guess about screens. A breakpoint written in rem or em is a statement about content: "when there is room for about 40 characters of body text beside the sidebar". In a media query, both units are measured against the browser's initial font size, which the reader controls. A reader who sets a larger default font gets a larger breakpoint along with the larger text, so the page switches to its narrow layout sooner, exactly when the bigger text needs it. A pixel breakpoint would keep the two-column layout and squeeze the enlarged text into it.

Mobile first, and where to break

Write the baseline for the narrowest, most constrained case, then add rules for wider spaces with width >= queries. Each query adds capability rather than undoing it, and the narrow layout, which is usually close to plain normal flow, is also what a browser shows when an enhancement fails.

@layer layout { body { display: grid; gap: var(--space-m); @media (width >= 48rem) { grid-template-columns: minmax(0, 1fr) 18rem; > :is(header, footer) { grid-column: 1 / -1; } } } }

Choose breakpoints where this design breaks, not where some device's screen happens to end. Widen the window slowly and watch: the moment lines get too long, a row of links wraps awkwardly or a sidebar gets cramped is the breakpoint. Device widths change every year; the width at which your content needs a new arrangement does not. Two or three page-level breakpoints are usually plenty. A component that needs four is asking to be fluid instead, or to use a container query.

4. Container queries

A media query answers one question for the whole page: how wide is the viewport? That is the right question for page structure, such as whether a sidebar exists. It is the wrong question for a component. A card can appear in a wide main column and in a narrow sidebar on the same page, at the same viewport width. The viewport cannot tell the two apart; the card's container can.

A viewport query versus a container queryOne viewport holds a wide main column and a narrow aside. The same card component sits in each. In the wide main column the card lays out in two columns, an image beside its text. In the narrow aside the same card stacks, image above text. A media query sees only the single viewport width; a container query lets each card respond to the width of its own container.viewport · @media sees one width for the whole pagemain · container, wideimagecard: two columnsaside · narrowimagesame card: stacked@media asks how wide the page is: one answer for every component.@container asks how wide my box is: each card gets its own answer.
Same markup, same CSS, same viewport: the card follows its container.

Two steps

First, make an ancestor a size container with container-type. The value inline-size lets descendants query its inline size, and applies containment so the browser can size the container without looking at its contents (that is what keeps the query from depending on its own result). Then write @container rules, which match against the nearest ancestor container.

@layer layout { :is(main, aside) { container-type: inline-size; } article { display: grid; gap: 0.5rem 1rem; h2 { font-size: clamp(1rem, 0.75rem + 2.5cqi, 1.5rem); } @container (inline-size >= 24rem) { grid-template-columns: 9rem 1fr; img { grid-row: span 3; } } } }

The stacked card is the baseline; the two-column card is the enhancement for containers with room. The heading uses cqi, one percent of the container's inline size, so the type is fluid relative to the card's space rather than the screen's. The six container units (cqw, cqh, cqi, cqb, cqmin, cqmax) mirror the viewport units. Size container queries and container units are Baseline widely available.

Naming containers, and what to avoid

When containers nest, container-name (or the container shorthand, container: card / inline-size) lets a query target a specific one: @container card (inline-size >= 30rem). Avoid container-type: size unless you really need to query height: it contains both dimensions, so the element no longer grows to fit its content and needs an explicit block size.

Size is not the only thing a container can be asked. Style queries such as @container style(--theme: dark) test a custom property's value on the container. Check their Baseline status before relying on them; at the time of writing they were not yet available in every major engine, so treat them as an enhancement.

ConcernToolWhy
Page structure: is there a sidebar?@media widthThe answer depends on the page
Component layout: how does this card render?@containerThe component may sit anywhere
Type and spacing scaleclamp()Continuous change, no threshold
Color scheme, motion, contrastPreference @mediaNot about size at all
Touch versus mousepointer, hoverA capability, not a size

A component that queries its container is portable: drop it anywhere and its layout follows. A component that queries the viewport is quietly tied to the one page layout it was designed inside.

5. Preference and capability queries

Size queries react to space. Preference queries react to the person: settings they chose in their operating system or browser, often for accessibility reasons. Ignoring them is not neutral. It overrides a choice the reader already made. This is where a style sheet does its part in the negotiation Basics described, in which the user, not the author, has the last word.

Color scheme, with light-dark()

The color-scheme property tells the browser which schemes a page supports, so its own parts (form controls, scrollbars, the default canvas) match. With light dark, the page follows the reader's prefers-color-scheme setting. The light-dark() function then picks one of two colors according to the scheme in use, so each token is defined once, with both values side by side:

@layer base { :root { color-scheme: light dark; --text: light-dark(#1f2430, #e8ebf2); --surface: light-dark(#ffffff, #14181f); --accent: light-dark(#a3420f, #f0a270); } body { color: var(--text); background: var(--surface); } }

light-dark() is Baseline newly available (since 2024), so a careful page gives older browsers a light baseline and wraps the light-dark() tokens in @supports, as the Basics demo does. The longer route works everywhere: define light tokens, then override them inside @media (prefers-color-scheme: dark). Either way the components never mention a scheme; they use tokens, and only the tokens change. A <meta name="color-scheme" content="light dark"> in the head tells the browser before any CSS arrives, which avoids a white flash on a dark page. The Color section's Light and dark page designs both palettes and adds a three-state toggle.

A site that wants a manual theme switch, the modern successor to alternate style sheets, can let a control set color-scheme itself. The tokens follow, with no script needed:

@layer base { :root:has(input[name="scheme"][value="dark"]:checked) { color-scheme: dark; } :root:has(input[name="scheme"][value="light"]:checked) { color-scheme: light; } }

Reduced motion

Large movement, parallax and zooming transitions can cause dizziness and nausea for people with vestibular disorders, who turn on "reduce motion" in their system settings. Treat motion as an enhancement: the baseline has none, and it is added only for readers who have not asked to avoid it.

@media (prefers-reduced-motion: no-preference) { @layer base { html { scroll-behavior: smooth; } details[open] > summary + * { animation: reveal 0.3s ease-out; } } } @keyframes reveal { from { opacity: 0; translate: 0 -0.5rem; } }

Reduced does not have to mean none. A gentle fade can replace a slide, and feedback such as a focus ring should never be removed. Animation itself belongs to the Animation topic (coming).

Contrast and forced colors

prefers-contrast: more matches readers who have asked for stronger contrast. Respond by strengthening what is already there: darker text tokens, solid borders where there were faint ones, no text over images. Color for people shows the tokens that change.

forced-colors: active is different in kind. In a forced-colors mode, such as Windows contrast themes, the system replaces the page's colors with a small palette the reader chose, and drops box shadows. The author's job is not to fight it but to make sure nothing important was carried only by color, background or shadow. A focus indicator drawn as a box-shadow disappears; one drawn with outline survives. System color keywords such as CanvasText and ButtonText name the palette's colors where a border has to come back.

@media (prefers-contrast: more) { @layer base { :root { --text: light-dark(#000000, #ffffff); } :is(input, select, textarea) { border: 1px solid currentColor; } } } @media (forced-colors: active) { @layer base { :focus-visible { outline: 0.2em solid Highlight; } [aria-current="page"] { border-block-end: 1px solid CanvasText; } } }

Further preference features exist, such as prefers-reduced-transparency and prefers-reduced-data, but they are not Baseline. Use them only as optional refinements.

Capability: pointer and hover

The interaction features describe input, not screen size. pointer is fine for a mouse or trackpad, coarse for a finger, none without one. hover is hover when the primary input can hover, none when it cannot. Both describe the primary input; any-pointer and any-hover match if any attached input qualifies, which matters for a laptop with a touch screen.

@layer components { nav a { padding-block: 0.25rem; @media (pointer: coarse) { display: inline-block; min-block-size: 2.75rem; padding-block: 0.75rem; } } @media (hover: hover) { figure img:hover { scale: 1.02; } } }

Use the primary-input features for the main experience and the any- features for optional extras, and never make hover the only way to reach something. A menu that opens on :hover should also open on :focus-within or a click, because keyboards and fingers cannot hover.

6. @supports and progressive enhancement

Progressive enhancement is the discipline of building up from a baseline that works everywhere. Semantic HTML works with no CSS. CSS makes it look designed without any script. Script adds interaction on top. And newer CSS features are added behind detection, so their absence costs the reader an enhancement rather than a working page. Every layer can fail: a script is blocked, a style sheet times out, a browser is a few versions old. The page that degrades gracefully is the one built from the bottom up.

Two kinds of feature detection

The first is free. Because the parser drops declarations it does not understand (see forgiving parsing), a fallback followed by a modern value is already feature detection:

body > header { min-block-size: 100vh; min-block-size: 100dvh; background: #b65a25; background: oklch(60% 0.15 45); }

The second is @supports, for when several rules only make sense together, or when the baseline must change rather than simply be overridden. The condition is tested by parsing; nothing is applied until the block matches.

@supports (display: grid) and (gap: 1rem) { … } @supports not (container-type: inline-size) { … } @supports selector(:has(+ *)) { … } @supports (color: light-dark(#000, #fff)) { … }

The last form tests a function through a property that accepts it: if the browser does not know light-dark(), the declaration is invalid and the condition is false. The selector() form tests whether a selector parses, which is how :has() was used safely before it reached every browser.

@layer components { form { display: grid; gap: 0.75rem; } @supports selector(:has(:user-invalid)) { form:has(:user-invalid) button[type="submit"] { opacity: 0.6; } } }

Conditions compose

Conditional at-rules nest inside rules and inside each other, and nested conditions combine as and. The transition below applies only when the card's container is wide and the reader has not asked for reduced motion:

article { padding: 1rem; @container (inline-size >= 36rem) { padding: 2rem; @media (prefers-reduced-motion: no-preference) { transition: padding 0.2s; } } }

Deciding with Baseline

StatusMeaningApproach
Widely availableIn every major engine for at least 30 monthsUse it directly
Newly availableIn the current version of every major engineUse it if its absence is harmless; otherwise give a fallback or gate it with @supports
Limited availabilityMissing from at least one major engineEnhancement only, behind @supports, with a tested baseline

Status changes month by month, so check MDN or webstatus.dev at the time you ship, not the time you learned the feature.

7. Print

Print is the oldest media context and still a real one: receipts, boarding passes, recipes, articles saved as PDF, readers who think better with paper. Basics showed the minimal @media print block. A careful print style sheet does more.

@media print { @layer print { :is(nav, footer, form, video, [aria-hidden="true"]) { display: none; } body { font: 11pt/1.4 Georgia, serif; color: black; background: none; } main { max-inline-size: none; } :is(h2, h3) { break-after: avoid; } :is(figure, table, pre) { break-inside: avoid; } article + article { break-before: page; } a[href^="http"]::after { content: " (" attr(href) ")"; font-size: 0.85em; } } } @page { margin: 2cm; }
  • Remove what paper cannot use. Navigation, forms, video and buttons are dead on a page. Hide them.
  • Absolute units are fine here. On screen, pt and cm are approximations; on paper they are real. Print is the one place they are the natural choice.
  • Control page breaks. The old page-break-before and page-break-after properties are now the general break-before, break-after and break-inside, which also apply to columns. Keep a heading with the text after it and a figure in one piece.
  • Mind the paper. US Letter (8.5 by 11 inches) and A4 (210 by 297 millimeters) differ in shape. Margins set with @page and a fluid content width suit both; a layout tuned to one sheet will clip on the other.
  • Expose links. A link on paper is just underlined text. Printing the URL after it keeps the reference.
  • Save ink. Browsers drop background colors and images by default when printing, so never carry meaning with a background alone.

8. Defensive design

Defensive CSS assumes things will go wrong and makes sure the page survives them: content longer than the design expected, images wider than their column, a font that never arrives, a custom property nobody set, a component whose script has not run. None of these habits is impressive alone. Together they are the difference between a page that holds up for every visitor and one that only works in the screenshot.

@layer base { :is(img, svg, video) { max-inline-size: 100%; block-size: auto; } :is(h1, h2, h3, p, li, td) { overflow-wrap: break-word; } pre { overflow-x: auto; } main { inline-size: min(100% - 2rem, 70rem); margin-inline: auto; } button { background: var(--button-bg, var(--accent, ButtonFace)); } :not(:defined) { opacity: 0; } }
  • Flexible media. An image or video wider than its column pushes the page sideways. max-inline-size: 100% with block-size: auto lets it shrink and keep its proportions. Set width and height attributes in the HTML too, so the browser reserves the space before the file arrives.
  • Long words. A long URL, an email address or a German compound noun does not wrap by default and overflows a narrow column. overflow-wrap: break-word breaks it only when nothing else fits. Code blocks scroll inside themselves instead.
  • min() for widths. min(100% - 2rem, 70rem) is a maximum width and a gutter in one value: never wider than 70rem, never wider than the space minus a margin. The same trick inside minmax() kept the intrinsic grid from overflowing in section 2.
  • Gaps, not margins, between items. gap on a flex or grid container puts space only between items, so there is no stray margin on the last one to cancel when items wrap or reorder.
  • Fallbacks in var(). var(--button-bg, …) keeps a usable value if a token is missing or misspelled.
  • Undefined custom elements. Before a script registers a custom element, the browser renders it as an unknown inline element. :not(:defined) can hide or reserve space for it until then, so the unstyled flash never shows. Use it only where the element's contents are not needed without script.

Test like a reader

The reader can override almost anything, and the test plan should assume they will. Zoom to 200 percent and check that nothing is cut off. Narrow the window to 320 CSS pixels, which is what a 1280-pixel window looks like at 400 percent zoom, and check that the content reflows into one column without scrolling sideways in two directions; WCAG's reflow criterion asks for exactly that. Set the browser's default font size larger, switch the system to dark mode, reduced motion and high contrast, and open the print preview. Open reader mode, which throws your CSS away entirely, to see whether the HTML alone carries the structure. Each check takes seconds, and each one is a real person's everyday setup. The Accessibility quality, and CSS for people next in this section, take these checks further.

9. Summary

ConceptKey points
ContextsViewport, container, input, color scheme, motion, contrast, text size, medium and feature support vary independently. Width is the least of them. Start with the viewport meta tag.
Fluid firstclamp(MIN, PREFERRED, MAX) is a line with a floor and a ceiling. Keep a rem term so zoom works. Let Utopia generate the scales; use auto-fit and minmax(min(…, 100%), 1fr) for grids.
Media queriesConditional, live, and shared with HTML and JavaScript. Use range syntax, (width >= 40rem). Write breakpoints in rem or em, mobile first, where the design breaks rather than where devices end.
Container queriescontainer-type: inline-size on an ancestor, @container on the component, cqi for fluid sizes within it. Page structure uses @media; components use @container.
Preferencescolor-scheme with light-dark() tokens; motion only under prefers-reduced-motion: no-preference; strengthen under prefers-contrast: more; never carry meaning by color or shadow alone in forced-colors.
Capabilitiespointer and hover describe the primary input; the any- forms describe every input. Hover is never the only way in.
@supportsCascade fallbacks for single values; @supports for grouped enhancements and selectors. Conditions nest and combine as and. Decide with Baseline status at ship time.
PrintHide what paper cannot use, set @page margins, control break-*, expose link URLs, and use real units such as pt and cm.
Defensive designFlexible media, overflow-wrap, min() widths, gap, var() fallbacks, :not(:defined). Test at 200 percent zoom, at 320 CSS pixels, and with every preference switched on.