Responsive & Adaptive Design¶
Responsive and adaptive design is how a single PWA codebase fits a 360-pixel phone, a folding phone opened into a tablet, a desktop window the user drags to any size, and a printer. Responsive techniques (fluid grids, intrinsic sizing, container queries) let every component resize continuously; adaptive techniques (window size classes, input-modality and preference media queries, foldable segments) switch between distinct layouts and interaction models when the context changes. An installed PWA needs both more than a website does, because it runs in app windows that users resize freely, on devices that fold, and without the browser UI that would otherwise absorb some of those differences.
Key takeaways
- Design components with container queries (Chrome 105, Firefox 110, Safari 16) and intrinsic layout, and use viewport media queries only for the page-level shell. That split lets the same card work in a phone column, a tablet pane and a narrow desktop window.
- Organize the shell around window size classes (compact below 600 px, medium 600 to 839 px, expanded 840 px and up, following Android's breakpoints) and switch navigation patterns (bottom bar, rail, drawer) at those boundaries, not at device names.
- Installed desktop PWAs can be resized to almost any size, tiled, and moved across monitors with different pixel densities. Test every layout at 320 px wide, at 200% zoom, and while resizing live.
- Foldables are covered by the Viewport Segments API (Chrome 138:
horizontal-viewport-segments,vertical-viewport-segments,env(viewport-segment-*),window.viewport.segments) and the Device Posture API (Chromium 132). Both are Chromium-only. - Adapt to how people interact, not what device they have:
pointer,hover,any-pointerandany-hoverdescribe input;prefers-reduced-motion,prefers-contrast,forced-colorsandprefers-color-schemedescribe needs and preferences. - Don't lock orientation to force a layout. WCAG 2.2 criterion 1.3.4 forbids it unless essential, and Android 16 ignores orientation and resizability restrictions on large screens for apps targeting API level 36.
- Serve resolution-appropriate images with
srcset,sizes(includingsizes="auto"for lazy images) andimage-set(), and provide a print stylesheet: app shells print badly by default.
Responsive versus adaptive in an installed app¶
The two terms describe different mechanisms, and good PWAs use both:
| Responsive | Adaptive | |
|---|---|---|
| Mechanism | Continuous: fluid units, minmax(), clamp(), intrinsic sizing, container query units | Discrete: breakpoints, media and container query conditions, runtime capability checks |
| Scope | Usually one component | Usually the app shell, navigation, interaction model |
| Example | A card grid that fits as many 16rem columns as the container allows | Bottom tab bar on phones, navigation rail on tablets, sidebar on desktop |
| Failure mode | Stretched lines, tiny tap targets on large screens | Awkward in-between sizes, layouts that assume a device |
In a browser tab the viewport rarely changes size except on rotation. In an installed PWA it changes constantly:
- Desktop app windows (Chromium on Windows, macOS, Linux and ChromeOS; Safari web apps on macOS) are ordinary resizable windows. Users snap them to half or a third of the screen, tile them next to other apps and maximize them.
- Android supports split-screen and free-form windows on tablets and foldables, and a folding phone changes from a narrow to a near-square viewport when opened.
- iPadOS Split View and Stage Manager resize Home Screen web apps like any other app.
- Window Controls Overlay removes the title bar and puts your own content in its place, so the usable area changes when the user toggles it (see Window Controls Overlay).
So "the phone layout" and "the desktop layout" are not device properties. They are states your app can move between at any moment, without a reload.
The viewport in app windows¶
The viewport meta tag¶
Every PWA page needs the standard viewport declaration. Two optional keys matter for app-like layouts:
<meta name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content">
viewport-fit=coverlets the layout extend under display cutouts and rounded corners. It is honored by Safari on iOS (11+), Chrome on Android (135+ per MDN) and Firefox for Android. Pair it withenv(safe-area-inset-*).interactive-widgetcontrols what the on-screen keyboard resizes. It's supported by Chrome on Android (108+) and Firefox for Android (133+). Desktop browsers and Safari ignore it. The default isresizes-visual: the keyboard shrinks only the visual viewport, and yourposition: fixedbottom bar stays under the keyboard.resizes-contentshrinks the layout viewport too, so fixed bottom UI anddvh-based layouts move above the keyboard.overlays-contentresizes neither.
Never add user-scalable=no or maximum-scale=1. They block pinch zoom where they're honored and fail WCAG 1.4.4 (Resize Text). See Accessibility.
Viewport units: svh, lvh, dvh¶
On mobile, the classic vh unit equals the largest possible viewport (browser toolbars retracted), so height: 100vh overflows when the toolbar is visible. The three newer unit families, supported in Chrome 108, Firefox 101 and Safari 15.4, fix that:
| Unit | Meaning | Use for |
|---|---|---|
svh / svw / svi / svb | Small viewport: browser UI fully expanded | Heroes that must be fully visible on first load |
lvh / lvw / lvi / lvb | Large viewport: browser UI retracted (same as vh) | Backgrounds that should never show a gap |
dvh / dvw / dvi / dvb | Dynamic: tracks the current size | Full-height app shells |
In a standalone or fullscreen PWA there are no retractable toolbars, so all three are usually equal. They still differ in the browser tab before installation, and on Android when interactive-widget=resizes-content lets the keyboard change the layout viewport. 100dvh is the safe default for an app shell; avoid animating anything on dvh changes, which fire during scrolling in the browser.
Safe areas¶
Notched phones, the iOS home indicator and Android display cutouts overlap the viewport when you use viewport-fit=cover, a fullscreen display mode, or iOS's black-translucent status bar style. The env(safe-area-inset-top|right|bottom|left) variables (Chrome 69, Firefox 65, Safari 11) report the insets:
.app-bar {
/* Keep the bar's own padding and add the inset on top of it. */
padding-block-start: calc(0.5rem + env(safe-area-inset-top, 0px));
padding-inline: max(1rem, env(safe-area-inset-left, 0px)) max(1rem, env(safe-area-inset-right, 0px));
}
.tab-bar {
padding-block-end: max(0.5rem, env(safe-area-inset-bottom, 0px));
}
Status bar colors and theme colors are covered on Splash Screens & Theming and Display Modes.
The on-screen keyboard¶
Besides interactive-widget, Chromium offers the experimental VirtualKeyboard API (Chrome 94+ on devices with a virtual keyboard): set navigator.virtualKeyboard.overlaysContent = true and position UI with env(keyboard-inset-height). On iOS the keyboard never resizes the layout viewport, so use visualViewport resize events to keep a composer bar above it. Chat-style layouts that must work everywhere combine all three.
Fluid layout foundations¶
Before reaching for breakpoints, let layout primitives do the work. These patterns need no media queries at all:
:root {
/* Fluid type: 1rem at 360px wide, 1.25rem at 1280px, clamped at both ends.
rem-based bounds keep user font-size preferences working (WCAG 1.4.4). */
--step-0: clamp(1rem, 0.92rem + 0.35vw, 1.25rem);
--step-2: clamp(1.44rem, 1.2rem + 1.1vw, 2.2rem);
/* Fluid spacing */
--space-s: clamp(0.75rem, 0.6rem + 0.6vw, 1.25rem);
--space-m: clamp(1rem, 0.8rem + 1vw, 2rem);
}
body {
font-size: var(--step-0);
line-height: 1.5;
}
/* Auto-fitting grid: as many columns of at least 16rem as fit, never overflowing. */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(16rem, 100%), 1fr));
gap: var(--space-m);
}
/* Readable measure regardless of window width. */
.prose {
max-inline-size: 70ch;
margin-inline: auto;
text-wrap: pretty; /* Chrome 117, Safari 26; Firefox ignores the value and wraps normally */
}
/* Sidebar that stacks when there's no room, with no breakpoint. */
.with-sidebar {
display: flex;
flex-wrap: wrap;
gap: var(--space-m);
}
.with-sidebar > aside { flex: 1 1 18rem; }
.with-sidebar > main { flex: 999 1 0; min-inline-size: 60%; }
Techniques behind this:
min(16rem, 100%)insideminmax()prevents horizontal overflow at 320 px, which is the width WCAG 1.4.10 (Reflow) requires content to work at without two-dimensional scrolling.- Logical properties (
padding-inline,margin-block-end,inline-size) make the same CSS correct in right-to-left and vertical writing modes. clamp()with aremcomponent, rather than purevw, keeps text zoomable. A purevwfont size doesn't grow with browser zoom at all: zooming shrinks the viewport's width in CSS pixels by the same factor it enlarges each CSS pixel, so the text stays the same physical size and fails WCAG 1.4.4. Theremterm is what zoom and the user's default font size actually scale.
Container queries: components that fit their slot¶
Media queries answer "how big is the window?". Components usually need to know "how big is my slot?": the same product card appears in a full-width list on phones, a 300 px sidebar on desktop, and a two-column grid on tablets. Size container queries answer that. They're supported in Chrome 105, Firefox 110 and Safari 16.
Declaring containers and querying them¶
/* 1. The slot establishes a container. inline-size containment only:
the element's height still depends on its content. */
.card-slot {
container: card / inline-size; /* shorthand for container-name + container-type */
}
/* 2. The component styles itself from the container's size. */
.product-card {
display: grid;
gap: 0.75rem;
grid-template-areas: "media" "body";
}
@container card (inline-size >= 28rem) {
.product-card {
grid-template-columns: 10rem 1fr;
grid-template-areas: "media body";
}
}
@container card (inline-size >= 40rem) {
.product-card {
grid-template-columns: 16rem 1fr auto;
grid-template-areas: "media body actions";
}
.product-card .actions { flex-direction: column; }
}
/* 3. Container query units: 1cqi = 1% of the container's inline size. */
.product-card h3 {
font-size: clamp(1rem, 0.8rem + 2cqi, 1.5rem);
}
Rules that trip people up:
container-type: inline-sizeapplies size containment in the inline axis and layout and style containment. The container's width can't depend on its children, so a container inside a shrink-to-fit parent (a flex item withflex: none, aninline-block, a grid track sizedautoormax-content) collapses to zero width. Give containers a definite or stretch width.container-type: sizealso contains the block axis, so the container needs an explicit height. Use it for full-height panes only.- A container can't query itself. The query element is the nearest ancestor container (optionally filtered by name), so style the children.
- Container query units (
cqw,cqh,cqi,cqb,cqmin,cqmax) resolve against the nearest container of a suitable type, or the small viewport size if there is none. - Name-only queries (
@container card { ... }, without a size condition) arrived later: Chrome 148, Firefox 149 and Safari 26.4.
Style queries and state queries¶
Container style queries on custom properties let a parent pass a "variant" to its descendants without classes:
.panel { --tone: default; }
.panel.is-promo { --tone: promo; }
@container style(--tone: promo) {
.product-card { background: var(--color-promo-surface); }
}
Style queries for custom properties are supported in Chrome 111, Safari 18 and Firefox 151. MDN notes that in Safari the document element can't act as the container. Range syntax inside style queries (style(--columns >= 3)) is Chromium 142 and Firefox 151 only.
Scroll-state queries (container-type: scroll-state with @container scroll-state(stuck: top), snapped, scrollable) let a sticky header restyle itself when stuck. They are Chromium-only (Chrome 133+) and should be treated as an enhancement.
Media queries or container queries?¶
| Decision | Use |
|---|---|
| App shell layout: navigation placement, number of panes | Viewport media queries (window size classes) |
| Component internals: card layout, toolbar overflow, table vs list | Container queries |
| Input modality, user preferences, display mode | Media queries (they describe the environment, not the slot) |
| Foldable hinge handling | Viewport segment media queries |
Window size classes for web apps¶
Android's adaptive guidance defines window size classes: opinionated width and height ranges measured in density-independent pixels (dp), which map 1:1 to CSS pixels at default zoom. They give you a small, shared vocabulary for "how much room does the app have", independent of device type. The current breakpoints from the Android developer documentation are:
| Width class | Range | Typical windows | Suggested navigation |
|---|---|---|---|
| Compact | < 600 | Phone portrait, narrow desktop window | Bottom navigation bar |
| Medium | 600 to 839 | Tablet portrait, unfolded foldable portrait, half-screen desktop | Navigation rail |
| Expanded | 840 to 1199 | Tablet landscape, unfolded foldable landscape, laptop window | Rail or persistent drawer, two panes |
| Large | 1200 to 1599 | Desktop, large tablet landscape | Persistent drawer, two or three panes |
| Extra-large | ≥ 1600 | Large desktop monitors | Drawer, three panes, constrained content width |
| Height class | Range | Why it matters |
|---|---|---|
| Compact | < 480 | Phone landscape: hide the app bar on scroll, avoid bottom bars that eat half the screen |
| Medium | 480 to 899 | Most windows |
| Expanded | ≥ 900 | Tall windows: keep content width limits, consider vertical splits |
Android added the large and extra-large width classes later for desktop and external displays. Everything in CSS pixels here is at 100% zoom. At 200% zoom, a 1280 px wide desktop window is a 640 px medium window, and your layout should treat it as one. That is exactly what media queries in CSS pixels do, and it's why width classes work well for accessibility.
Implementing size classes in CSS and JavaScript¶
CSS has no custom media queries in stable browsers yet, so define the ranges once in CSS and once in JavaScript:
/* Window size classes as range media queries (Chrome 104, Firefox 102, Safari 16.4). */
/* Compact (default): bottom tab bar. */
.app-shell {
display: grid;
grid-template-rows: auto 1fr auto;
grid-template-areas: "header" "main" "nav";
min-block-size: 100dvh;
}
.app-nav { grid-area: nav; }
/* Medium: navigation rail on the inline-start side. */
@media (width >= 600px) {
.app-shell {
grid-template-columns: 5rem 1fr;
grid-template-rows: auto 1fr;
grid-template-areas: "nav header" "nav main";
}
.app-nav { flex-direction: column; }
.app-nav .label { font-size: 0.75rem; }
}
/* Expanded: labelled drawer plus list-detail panes. */
@media (width >= 840px) {
.app-shell { grid-template-columns: 16rem 1fr; }
.app-nav .label { font-size: 1rem; }
.list-detail {
display: grid;
grid-template-columns: minmax(18rem, 2fr) 3fr;
}
}
/* Compact height (phone landscape): reclaim vertical space. */
@media (height < 480px) {
.app-header { position: static; }
}
/**
* Observe the current window size class. Mirrors the CSS breakpoints exactly.
* Uses matchMedia (fires only when a boundary is crossed) instead of a resize
* listener (fires on every pixel).
*/
const WIDTH_CLASSES = [
["extra-large", "(width >= 1600px)"],
["large", "(width >= 1200px)"],
["expanded", "(width >= 840px)"],
["medium", "(width >= 600px)"],
["compact", "all"],
];
const queries = WIDTH_CLASSES.map(([name, q]) => [name, matchMedia(q)]);
/** @returns {"compact"|"medium"|"expanded"|"large"|"extra-large"} */
export function getWidthClass() {
return queries.find(([, mql]) => mql.matches)[0];
}
/**
* @param {(cls: string) => void} callback Called immediately and on every change.
* @returns {() => void} unsubscribe
*/
export function onWidthClassChange(callback) {
// Expose the class to CSS for rules that are awkward to express as media queries.
const publish = (cls) => {
document.documentElement.dataset.widthClass = cls;
callback(cls);
};
let current = getWidthClass();
const handler = () => {
// Several queries flip during one resize; only report real class changes.
const next = getWidthClass();
if (next !== current) {
current = next;
publish(next);
}
};
for (const [, mql] of queries) mql.addEventListener("change", handler);
publish(current);
return () => {
for (const [, mql] of queries) mql.removeEventListener("change", handler);
};
}
Use the JavaScript side for behavior, not styling: whether a list item click navigates to a detail route (compact) or updates the detail pane in place (expanded), whether a filter panel is a modal sheet or an inline sidebar, how many items to prefetch.
Preserving state across class changes¶
When the user widens a window from compact to expanded, a "detail" route shown full-screen should become the right-hand pane of a list-detail layout, keeping the selection, scroll position and any unsaved input. Keep the selected item in the URL (/inbox/42) rather than in component state, and render both panes from the URL in expanded mode and only the most specific one in compact mode. Then a class change is just a re-render. Animating the shift with a same-document view transition is covered on View Transitions.
Resizable desktop windows¶
Installed desktop PWAs run in real OS windows. Design for these realities:
- Any size, any time. Users drag windows to widths far below any desktop breakpoint. Your compact layout is your narrow-desktop layout, so it must work with a mouse and keyboard, not only touch.
- Live resizing. Layout runs on every frame of a drag. Avoid JavaScript layout on
resize. Use CSS,ResizeObserverfor component-level measurement (it batches notifications once per frame), andmatchMedialisteners for breakpoints. - Snap and tiling layouts. Windows snap layouts, macOS tiling and ChromeOS split views produce windows around a half or third of the screen width, which often lands in the medium class on laptops. Test it deliberately.
- Multiple monitors. Moving a window to a monitor with a different scale factor changes
devicePixelRatiomid-session. Canvas-based UI and raster images need to respond (see High-DPI images). - Initial size. The manifest has no member for the default window size or position. On first launch Chromium opens the app window at a size derived from the current screen (capped at 1920 by 1080, per web.dev's Learn PWA) and from then on restores the user's last size and position. Chromium does let an installed desktop PWA call
window.resizeTo(),resizeBy(),moveTo()andmoveBy()on its own window, which ordinary browser tabs can't do (the calls do nothing on mobile). Use that sparingly, for example to restore a size the user chose inside your app, never to override a size the user just dragged to. Your layout has to cope with whatever size the window ends up at anyway. - Title bar area. With Window Controls Overlay enabled, your title bar content shares a strip with the OS window controls, whose width varies by platform. Use container queries on the title bar to drop items as it narrows.
- Zoom. Desktop app windows support page zoom (Ctrl/Cmd + +). Zoom reduces the CSS pixel width of the viewport and should move you down the size classes.
// Measure a resizable pane without resize listeners: ResizeObserver delivers
// at most one notification per frame and reports content-box sizes directly.
const detail = document.querySelector(".detail-pane");
const ro = new ResizeObserver(([entry]) => {
const inline = entry.contentBoxSize[0].inlineSize;
// Toggle a dense toolbar variant when the pane itself (not the window) is narrow.
detail.classList.toggle("is-narrow", inline < 480);
});
ro.observe(detail);
(For pure styling, a container query on .detail-pane does the same job with no JavaScript. Use ResizeObserver when behavior must change, for example virtual list row counts.)
Multi-screen placement with the Window Management API is Chromium-only and covered with other desktop integration on Desktop Platforms.
Foldables and dual-screen devices¶
Foldable phones present three situations to a PWA: folded (a normal phone), fully open (a small tablet, one continuous viewport), and half-open or "book/tabletop" posture where the fold divides the screen into two logical areas. Dual-screen devices have a physical hinge that masks pixels between two displays. Two Chromium APIs expose this.
Chromium-only
The Viewport Segments API shipped in Chrome 138 and the Device Posture API in Chromium 132, on Android (and on any platform that reports segments or postures). Safari and Firefox don't support either, and MDN lists both as experimental. Build the normal responsive layout first and add segment-aware layouts as an enhancement.
The Viewport Segments API¶
A display feature (a fold or hinge) divides the viewport into segments. The API exposes them in CSS and JavaScript:
| Surface | Syntax | Meaning |
|---|---|---|
| Media feature | (horizontal-viewport-segments: 2) | Two segments side by side (vertical fold, device held like a book) |
| Media feature | (vertical-viewport-segments: 2) | Two segments stacked (horizontal fold, "laptop" or tabletop posture) |
| Environment variables | env(viewport-segment-left 0 0), -top, -right, -bottom, -width, -height | Edges and size of a segment. The two indices are the segment's column (x) and row (y), starting at 0 |
| JavaScript | window.viewport.segments | Array of DOMRect in CSS pixels. One rect for an unsegmented viewport, null if the document isn't fully active |
When the device is flat or can't fold, the media features report 1 and segments contains a single rect covering the viewport. Before Chrome 138 an origin trial exposed segments on visualViewport. The shipped API moved it to the new window.viewport object.
A list-detail layout that respects the fold¶
/* Default responsive list-detail (expanded class). */
@media (width >= 840px) {
.list-detail { display: grid; grid-template-columns: minmax(18rem, 2fr) 3fr; }
}
/* Book posture: one pane per segment, with a gap exactly over the hinge. */
@media (horizontal-viewport-segments: 2) {
.list-detail {
display: grid;
grid-template-columns:
env(viewport-segment-width 0 0)
calc(env(viewport-segment-left 1 0) - env(viewport-segment-right 0 0))
env(viewport-segment-width 1 0);
grid-template-areas: "list hinge detail";
}
.list-detail > .list { grid-area: list; }
.list-detail > .detail { grid-area: detail; }
}
/* Tabletop posture: content on top, controls on the bottom half. */
@media (vertical-viewport-segments: 2) {
.player {
display: grid;
grid-template-rows:
env(viewport-segment-height 0 0)
calc(env(viewport-segment-top 0 1) - env(viewport-segment-bottom 0 0))
env(viewport-segment-height 0 1);
}
.player video { grid-row: 1; }
.player .controls { grid-row: 3; }
}
The middle column or row is the hinge (zero width on a seamless fold, a few dozen pixels on a physical hinge). Keep text and controls out of it.
/**
* Report the viewport segments, and re-report when they may have changed.
* The segments array is a snapshot: re-read it after resize, orientation or
* posture changes.
* @param {(segments: DOMRect[]) => void} callback
*/
export function watchSegments(callback) {
if (!("viewport" in window) || !("segments" in window.viewport)) {
callback([new DOMRect(0, 0, innerWidth, innerHeight)]); // unsupported: one segment
return () => {};
}
const report = () => callback(window.viewport.segments ?? []);
const mqls = [
matchMedia("(horizontal-viewport-segments: 2)"),
matchMedia("(vertical-viewport-segments: 2)"),
];
mqls.forEach((m) => m.addEventListener("change", report));
window.addEventListener("resize", report);
navigator.devicePosture?.addEventListener("change", report);
report();
return () => {
mqls.forEach((m) => m.removeEventListener("change", report));
window.removeEventListener("resize", report);
navigator.devicePosture?.removeEventListener("change", report);
};
}
The Device Posture API¶
Posture is coarser than segments: navigator.devicePosture.type is "continuous" (flat, or a device that can't fold) or "folded" (half-open), with a change event, and the matching @media (device-posture: folded) media feature. Use posture for mode decisions (for example, a camera app switching to tabletop controls), and segments for geometry.
Testing foldables¶
Chrome DevTools' device toolbar includes foldable device presets with a fold toggle, which lets you exercise the media features and environment variables on a desktop. MDN notes that DevTools can't emulate every physical segment arrangement, so confirm on real hardware or the Android Emulator's foldable profiles before release.
Orientation¶
Orientation is a special case of size. Prefer size and aspect-ratio queries, which describe what you actually care about:
/* "Wider than tall", including desktop windows and split-screen panes. */
@media (orientation: landscape) { /* ... */ }
/* Often better: act on actual proportions. */
@media (aspect-ratio >= 16/9) and (height < 480px) {
/* Phone landscape: put the media beside the controls. */
.player { grid-template-columns: 1fr 18rem; }
}
Two caveats about orientation: it compares the viewport's width and height, so a portrait tablet with the keyboard open (when interactive-widget=resizes-content) can report landscape; and it says nothing about the device's physical orientation. Use screen.orientation.type for that.
Don't lock orientation to avoid layout work¶
The manifest orientation member and screen.orientation.lock() can request a fixed orientation. Details, platform behavior and code are on Display Modes. For responsive design, three facts matter:
- WCAG 2.2 success criterion 1.3.4 (Orientation, level AA) requires that content isn't restricted to a single orientation unless a specific orientation is essential (a piano app, a check deposit camera). A "portrait-only because we didn't build landscape" app fails.
- Large screens increasingly ignore locks. For apps targeting Android 16 (API level 36), Android ignores orientation, resizability and aspect-ratio restrictions on displays whose smallest width is at least 600 dp, in both full-screen and multi-window modes. A temporary opt-out exists, but it stops applying to apps that target API level 37, and games are exempt. An installed PWA's window belongs to the browser app, so what matters is the browser's target API level, not anything in your manifest. Desktop windows have no orientation lock at all.
screen.orientation.lock()is only honored in limited contexts (typically fullscreen or an installed app on Android). Desktop Chrome always rejects it withNotSupportedError. Firefox implemented the method from version 144; earlier versions always rejected it.
Input modality¶
A 13-inch touchscreen laptop with a trackpad and keyboard, a phone with a Bluetooth mouse, and a tablet with a stylus all break assumptions based on screen size. Media Queries Level 4 describes input capabilities directly:
| Media feature | Values | Describes |
|---|---|---|
pointer | none, coarse, fine | Accuracy of the primary pointing device |
hover | none, hover | Whether the primary input can hover |
any-pointer | none, coarse, fine | Accuracy of any available pointing device (matches all that apply) |
any-hover | none, hover | Whether any available input can hover |
Support is universal in current browsers. MDN notes that some Android devices (certain Samsung models) incorrectly match (hover: hover), so never hide essential functionality behind hover even when the query matches.
/* Baseline: comfortable targets for everyone (WCAG 2.5.8 minimum is 24x24 CSS px). */
.icon-button {
min-inline-size: 2.75rem;
min-block-size: 2.75rem;
}
/* A precise pointer is available: allow denser toolbars. */
@media (pointer: fine) {
.toolbar { gap: 0.25rem; }
.icon-button { min-inline-size: 2rem; min-block-size: 2rem; }
}
/* Hover-reveal affordances only when the primary input can hover,
and never as the only way to reach the action. */
@media (hover: hover) {
.row .row-actions { opacity: 0; transition: opacity 120ms; }
.row:hover .row-actions,
.row:focus-within .row-actions { opacity: 1; }
}
/* Any coarse pointer present (e.g. touchscreen laptop): keep drag handles large. */
@media (any-pointer: coarse) {
.drag-handle { padding: 0.75rem; }
}
In JavaScript, adapt per interaction rather than per device. PointerEvent.pointerType tells you whether this particular gesture came from "mouse", "pen" or "touch":
list.addEventListener("pointerdown", (event) => {
// Long-press menus make sense for touch and pen; mice have a right button.
if (event.pointerType === "mouse") return;
startLongPressTimer(event);
});
list.addEventListener("contextmenu", openContextMenu); // mouse, keyboard menu key, long-press
Keyboard input is the modality most often forgotten in touch-first PWAs. Every adaptive layout must keep a logical focus order when panes rearrange; CSS order and grid placement change the visual order but not the tab order. See Accessibility.
High-DPI and responsive images¶
Displays range from 1x desktop monitors to 3x and higher phones, and a desktop PWA window can move between them. Serve images that match both the layout size and the density.
srcset and sizes¶
<!-- Width descriptors: the browser picks based on layout size x devicePixelRatio. -->
<img
src="/img/shoe-800.avif"
srcset="/img/shoe-400.avif 400w, /img/shoe-800.avif 800w,
/img/shoe-1200.avif 1200w, /img/shoe-1600.avif 1600w"
sizes="(width >= 840px) 40vw, 100vw"
width="1600" height="1200"
alt="Blue trail running shoe, side view"
fetchpriority="high">
<!-- Lazy images can let the browser compute sizes from layout. -->
<img
srcset="/img/thumb-200.avif 200w, /img/thumb-400.avif 400w, /img/thumb-600.avif 600w"
sizes="auto" loading="lazy"
width="600" height="600" alt="">
sizesmust describe the image's rendered width at each breakpoint. It's evaluated against the viewport, not the container, so it duplicates your layout logic. That's the main friction with container-driven components.sizes="auto"solves it for lazy-loaded images: the browser waits for layout and uses the actual rendered width. It's supported in Chrome 126, Firefox 150 and Safari 27, requiresloading="lazy", and older browsers fall back to treatingsizesas100vw, so writesizes="auto, (width >= 840px) 40vw, 100vw"to keep a sensible fallback.- Always set
widthandheightattributes. They give the browser the aspect ratio before the image loads and prevent layout shift (Core Web Vitals).
Art direction with <picture>¶
When the crop, not just the resolution, should change (a wide banner on desktop, a square on phones), use <picture> with media conditions. The browser uses the first matching <source>:
<picture>
<source media="(width >= 840px)" srcset="/img/hero-wide-1600.avif 1600w, /img/hero-wide-2400.avif 2400w"
sizes="100vw" type="image/avif">
<source srcset="/img/hero-square-600.avif 600w, /img/hero-square-1200.avif 1200w"
sizes="100vw" type="image/avif">
<img src="/img/hero-square-1200.jpg" width="1200" height="1200" alt="Runners on a mountain trail at dawn">
</picture>
CSS images: image-set() and resolution queries¶
.app-header {
background-image: image-set(
url("/img/texture.avif") type("image/avif") 1x,
url("/img/[email protected]") type("image/avif") 2x,
url("/img/texture.png") 1x
);
}
/* Hairline borders that stay crisp on high-density screens. */
@media (resolution >= 2dppx) {
.divider { border-block-end-width: 0.5px; }
}
Unprefixed image-set() with type() is supported in Chrome 113, Firefox 89 and Safari 17. Prefer SVG for icons and illustrations, which are density-independent; the app's own manifest icons are covered on Icons & Maskable Icons.
Reacting to density changes at runtime¶
devicePixelRatio changes when a window moves to another monitor and, in Chromium and Firefox, when the user zooms (MDN notes Safari doesn't change it on zoom). Canvas-based views must re-rasterize:
/**
* Keep a <canvas> sharp across DPR changes (monitor moves, zoom).
* @param {HTMLCanvasElement} canvas
* @param {(ctx: CanvasRenderingContext2D) => void} draw
*/
export function crispCanvas(canvas, draw) {
const ctx = canvas.getContext("2d");
function resize() {
const dpr = window.devicePixelRatio || 1;
const { width, height } = canvas.getBoundingClientRect();
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // draw in CSS pixels
draw(ctx);
}
// A resolution media query that matches the *current* DPR stops matching when
// the DPR changes; re-arm it each time with the new value.
function watchDpr() {
const mql = matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
mql.addEventListener("change", () => { resize(); watchDpr(); }, { once: true });
}
new ResizeObserver(resize).observe(canvas);
watchDpr();
}
User preference media queries¶
Preference media features let the operating system's accessibility and display settings flow into your PWA. They're even more important in an installed app, which users expect to follow system settings like native apps do.
| Media feature | Values | Support (stable) |
|---|---|---|
prefers-reduced-motion | no-preference, reduce | All engines (Chrome 74, Firefox 63, Safari 10.1) |
prefers-color-scheme | light, dark | All engines (Chrome 76, Firefox 67, Safari 12.1) |
prefers-contrast | no-preference, more, less, custom | All engines (Chrome 96, Firefox 101, Safari 14.1) |
forced-colors | none, active | All engines (Chrome 89, Firefox 89, Safari 16) |
prefers-reduced-transparency | no-preference, reduce | Chromium 118 only |
inverted-colors | none, inverted | Safari only |
prefers-reduced-data | no-preference, reduce | Behind a flag in Chromium only |
dynamic-range | standard, high | All engines |
update | none, slow, fast | All engines (e-ink and print report slow/none) |
scripting | none, initial-only, enabled | All engines (Chrome 120, Firefox 113, Safari 17) |
Reduced motion¶
/* Opt motion *in*, rather than removing it afterwards. */
@media (prefers-reduced-motion: no-preference) {
.sheet { transition: translate 250ms cubic-bezier(0.2, 0, 0, 1); }
html { scroll-behavior: smooth; }
}
Reduced motion means less movement, not no feedback: replace slides and zooms with short opacity changes. The treatment of view transitions specifically is on View Transitions.
Reduced data¶
prefers-reduced-data isn't shipped anywhere, so the practical signal is Chromium's Save-Data: the Save-Data: on request header and navigator.connection.saveData (Chrome 65+; not in Safari or Firefox). Your service worker sees the header on requests and can pick lighter responses:
self.addEventListener("fetch", (event) => {
const { request } = event;
if (request.destination === "image" && request.headers.get("Save-Data") === "on") {
// Serve a low-quality variant, e.g. /img/x.avif -> /img/x.lq.avif, from cache or network.
const url = new URL(request.url);
url.pathname = url.pathname.replace(/(\.\w+)$/, ".lq$1");
event.respondWith(
(async () => {
const cached = await caches.match(url.href);
if (cached) return cached;
try {
const lq = await fetch(url.href);
// A 404 doesn't reject: fall back to the original image when there's no variant.
if (lq.ok) return lq;
} catch {
// Network error: try the original request below (it may be cached elsewhere).
}
return (await caches.match(request)) ?? fetch(request);
})(),
);
}
});
Handle this conservatively: autoplaying video and large prefetches should be off by default for everyone on metered connections, not only for users who found the setting. Runtime caching strategies are on Caching Strategies.
Contrast and forced colors¶
@media (prefers-contrast: more) {
:root { --border: #000; --text-muted: #1f1f1f; }
.card { border: 2px solid var(--border); box-shadow: none; }
}
/* Windows contrast themes: the browser replaces colors with system colors.
Restore meaning that was carried only by background or box-shadow. */
@media (forced-colors: active) {
.selected { outline: 2px solid Highlight; }
.icon { forced-color-adjust: auto; fill: CanvasText; }
.focus-ring { outline-color: Highlight; }
}
Dark mode¶
A PWA should follow the system color scheme, and the installed app's chrome (title bar, status bar, splash screen) must match the page. The pieces:
<!-- Tell the browser both schemes are supported before CSS loads:
form controls, scrollbars and the canvas background follow the system. -->
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#f8f9fb" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#111418" media="(prefers-color-scheme: dark)">
:root {
color-scheme: light dark;
--surface: light-dark(#f8f9fb, #111418);
--text: light-dark(#1b1c1f, #e3e3e8);
--accent: light-dark(#0b57d0, #a8c7fa);
}
/* A user-selected override (stored in localStorage or a cookie) wins over the system. */
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"] { color-scheme: dark; }
body { background: var(--surface); color: var(--text); }
light-dark() (Chrome 123, Firefox 120, Safari 17.5) picks a value based on the element's used color-scheme, which makes a manual override a one-line change. Remember:
- The manifest's
theme_colorandbackground_colorhave no dark variant in any shipped browser, so the splash screen and initial title bar use the light values until your page'stheme-colormeta takes over. Splash Screens & Theming covers workarounds. - Images need dark versions too:
<picture><source srcset="logo-dark.svg" media="(prefers-color-scheme: dark)">for logos, and reduced brightness for photos is a matter of taste, not a rule. - Verify contrast in both schemes. Muted text colors that pass at 4.5:1 on white often fail on dark gray.
Print¶
App shells print terribly by default: the bottom tab bar lands in the middle of page two, the sidebar takes a third of the width, and 100dvh containers clip content to a single page. Installed apps also hide the browser's Print menu item in some contexts (iOS web apps have no browser menu at all), so add a print action where printing is a real use case (invoices, tickets, recipes).
@media print {
/* Remove app chrome. */
.app-nav, .app-header, .tab-bar, .fab, .toast-region, [data-print="hide"] {
display: none !important;
}
/* Undo app-shell scroll containers so content flows across pages. */
html, body, .app-shell, main {
display: block;
block-size: auto;
min-block-size: 0;
overflow: visible;
}
body { font-size: 11pt; color: #000; background: #fff; }
/* Show link targets for external links. */
a[href^="http"]::after { content: " (" attr(href) ")"; font-size: 9pt; }
/* Keep cards and table rows intact. */
.card, tr, figure { break-inside: avoid; }
h2, h3 { break-after: avoid; }
}
@page {
size: A4;
margin: 18mm 15mm;
}
const button = document.querySelector("#print");
// Hide the button where printing isn't possible (some embedded web views).
button.hidden = typeof window.print !== "function";
button.addEventListener("click", () => window.print());
// Expand collapsed sections for printing, then restore them.
const opened = [];
addEventListener("beforeprint", () => {
for (const d of document.querySelectorAll("details:not([open])")) {
d.open = true;
opened.push(d);
}
});
addEventListener("afterprint", () => {
opened.splice(0).forEach((d) => (d.open = false));
});
Chromium also supports @page margin boxes (@top-center, @bottom-right and so on) for running headers and page numbers since Chrome 131; other engines ignore them. Safari supports @page itself from 18.2. Test printing from the installed app window, not only from a tab, on each platform you ship to.
A complete adaptive shell¶
This example combines the size classes, container queries, input and preference queries into a single shell that works from 320 px to ultrawide, in a tab or installed.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#f8f9fb" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#111418" media="(prefers-color-scheme: dark)">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="stylesheet" href="/css/shell.css">
<script type="module" src="/js/shell.js"></script>
<title>Inbox · Mailbird</title>
</head>
<body>
<div class="app-shell">
<header class="app-header">
<h1 class="app-title">Inbox</h1>
</header>
<nav class="app-nav" aria-label="Main">
<a href="/inbox" aria-current="page"><svg aria-hidden="true"><use href="#i-inbox"/></svg><span class="label">Inbox</span></a>
<a href="/sent"><svg aria-hidden="true"><use href="#i-sent"/></svg><span class="label">Sent</span></a>
<a href="/settings"><svg aria-hidden="true"><use href="#i-gear"/></svg><span class="label">Settings</span></a>
</nav>
<main class="app-main list-detail" id="main">
<section class="list" aria-label="Messages"><!-- message rows --></section>
<section class="detail" aria-label="Message"><!-- selected message --></section>
</main>
</div>
</body>
</html>
:root {
color-scheme: light dark;
--surface: light-dark(#f8f9fb, #111418);
--surface-2: light-dark(#ffffff, #1b1f24);
--text: light-dark(#1b1c1f, #e3e3e8);
--accent: light-dark(#0b57d0, #a8c7fa);
--nav-size: 4.5rem;
}
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; background: var(--surface); color: var(--text); font: 1rem/1.5 system-ui, sans-serif; }
/* ---------- Compact (default) ---------- */
.app-shell {
display: grid;
min-block-size: 100dvh;
grid-template: "header" auto "main" 1fr "nav" auto / 1fr;
}
.app-header {
grid-area: header;
padding: calc(0.75rem + env(safe-area-inset-top, 0px)) max(1rem, env(safe-area-inset-right, 0px))
0.75rem max(1rem, env(safe-area-inset-left, 0px));
}
.app-main { grid-area: main; min-inline-size: 0; }
.app-nav {
grid-area: nav;
position: sticky;
inset-block-end: 0;
display: flex;
justify-content: space-around;
padding-block-end: env(safe-area-inset-bottom, 0px);
background: var(--surface-2);
}
.app-nav a {
display: grid;
place-items: center;
min-inline-size: 3rem;
min-block-size: 3rem;
color: inherit;
text-decoration: none;
}
.app-nav [aria-current="page"] { color: var(--accent); }
.list-detail > .detail { display: none; } /* detail is its own route on compact */
.list-detail:has(> .detail[data-open]) > .list { display: none; }
.list-detail > .detail[data-open] { display: block; }
/* Components adapt to their slot. */
.list, .detail { container-type: inline-size; }
@container (inline-size >= 36rem) {
.message-row { display: grid; grid-template-columns: 12rem 1fr auto; }
}
/* ---------- Medium: rail ---------- */
@media (width >= 600px) {
.app-shell { grid-template: "nav header" auto "nav main" 1fr / var(--nav-size) 1fr; }
.app-nav {
position: sticky;
inset-block-start: 0;
block-size: 100dvh;
flex-direction: column;
justify-content: start;
gap: 0.5rem;
padding-block: env(safe-area-inset-top, 0px) 0;
}
.app-nav .label { font-size: 0.75rem; }
}
/* ---------- Expanded: drawer + two panes ---------- */
@media (width >= 840px) {
.app-shell { --nav-size: 15rem; }
.app-nav a { display: flex; justify-content: start; gap: 0.75rem; padding-inline: 1rem; }
.app-nav .label { font-size: 1rem; }
.list-detail { display: grid; grid-template-columns: minmax(18rem, 2fr) 3fr; }
.list-detail > .list, .list-detail > .detail { display: block; }
}
/* ---------- Foldable, book posture ---------- */
@media (horizontal-viewport-segments: 2) {
.list-detail {
grid-template-columns:
calc(env(viewport-segment-width 0 0) - var(--nav-size))
calc(env(viewport-segment-left 1 0) - env(viewport-segment-right 0 0))
env(viewport-segment-width 1 0);
}
.list-detail > .detail { grid-column: 3; }
}
/* ---------- Input and preferences ---------- */
@media (pointer: fine) { .app-nav a { min-block-size: 2.5rem; } }
@media (prefers-reduced-motion: no-preference) { .app-nav a { transition: color 120ms; } }
@media (forced-colors: active) { .app-nav [aria-current="page"] { outline: 2px solid Highlight; } }
/* ---------- Print ---------- */
@media print {
.app-nav, .app-header { display: none; }
.app-shell, .list-detail { display: block; min-block-size: 0; }
.list-detail > .list { display: none; }
.list-detail > .detail { display: block; }
}
import { onWidthClassChange } from "./window-class.js";
const main = document.querySelector("#main");
onWidthClassChange((cls) => {
// Behavior differs by class: in compact, selecting a message navigates to its
// own route; in expanded and wider, it fills the detail pane in place.
main.dataset.selectionMode = cls === "compact" || cls === "medium" ? "navigate" : "pane";
});
The calc(env(viewport-segment-width 0 0) - var(--nav-size)) term accounts for the navigation rail, which occupies part of the first segment. Without it, the list pane would spill across the hinge.
Browser support¶
Support data as of September 2026. For live data, see MDN: CSS container queries, MDN: Viewport Segments API and caniuse.
| Feature | Chrome / Edge | Safari (macOS, iOS) | Firefox |
|---|---|---|---|
Size container queries, cq* units | ✅ 105 | ✅ 16 | ✅ 110 |
| Style queries (custom properties) | ✅ 111 | ✅ 18 ⚠️ | ✅ 151 |
| Name-only container queries | ✅ 148 | ✅ 26.4 | ✅ 149 |
| Scroll-state container queries | ✅ 133 | ❌ | ❌ |
| Media query range syntax | ✅ 104 | ✅ 16.4 | ✅ 102 |
svh / lvh / dvh units | ✅ 108 | ✅ 15.4 | ✅ 101 |
interactive-widget viewport key | ⚠️ Android 108 | ❌ | ⚠️ Android 133 |
| VirtualKeyboard API | 🧪 94 | ❌ | ❌ |
| Viewport Segments API | ✅ 138 ⚠️ | ❌ | ❌ |
| Device Posture API | ✅ 132 ⚠️ | ❌ | ❌ |
pointer, hover, any-pointer, any-hover | ✅ | ✅ | ✅ |
sizes="auto" | ✅ 126 | ✅ 27 | ✅ 150 |
image-set() with type() (unprefixed) | ✅ 113 | ✅ 17 | ✅ 89 |
light-dark() | ✅ 123 | ✅ 17.5 | ✅ 120 |
prefers-reduced-data | 🧪 flag | ❌ | ❌ |
@page margin boxes | ✅ 131 | ❌ | ❌ |
Notes:
- Safari's style queries can't use the document element as a container.
- The Viewport Segments and Device Posture APIs are marked experimental by MDN. Only foldable and dual-screen hardware reports more than one segment or a
foldedposture. interactive-widgetis honored only by Chrome on Android and Firefox for Android; desktop browsers ignore it.
Common pitfalls¶
- Designing for devices instead of windows. "Tablet layout" at 768 px breaks in a half-screen laptop window and an unfolded phone. Use size classes and container queries.
100vhapp shells. On mobile browsers the bottom of the shell hides behind the toolbar. Use100dvh(with a100vhfallback declared first for old browsers).- Containers with shrink-to-fit widths.
container-type: inline-sizeon an element whose width depends on its content collapses it. Give containers stretch or definite widths. - Visual order diverging from DOM order. Moving the navigation from bottom to side with
orderor grid areas is fine; reordering content panes so that visual and focus order disagree fails WCAG 2.4.3. - Hover-only actions. Hidden row actions revealed on
:hoverare unreachable on touch and keyboard unless you also use:focus-withinand a visible alternative. - Locking orientation. It fails WCAG 1.3.4, is ignored on large Android screens and desktop windows, and makes foldables awkward.
- Ignoring zoom. At 200% zoom a desktop window drops two size classes. If the compact layout only works with touch, keyboard and mouse users are stuck.
- Treating
devicePixelRatioas constant. It changes when windows move between monitors. Re-rasterize canvases and don't cache DPR-specific image URLs at startup. - Forgetting print. Scroll containers with
overflow: autoand fixed heights truncate printed pages to one screen.
Debugging¶
- Chrome DevTools device toolbar (Ctrl/Cmd + Shift + M) emulates viewport sizes, DPR, touch, and foldable devices with a fold toggle. Emulating inside the installed app window works too: open DevTools from the app menu or with Ctrl/Cmd + Shift + I.
- Rendering drawer → Emulate CSS media feature toggles
prefers-color-scheme,prefers-reduced-motion,prefers-contrast,forced-colors,prefers-reduced-transparencyand print media without changing OS settings. - Elements panel marks container elements with a
containerbadge; clicking it highlights the container and its queried descendants. The Styles pane shows which@containerrule applies. - Firefox's Responsive Design Mode and Safari's Responsive Design Mode (Develop menu) cover the same basics; test iPadOS Split View and Stage Manager on real hardware or the Simulator.
- Resize live. Drag the installed app window slowly from its narrowest to widest size and watch for layout jumps, overflow and focus loss at each boundary.
For the full toolset, see Browser DevTools.
Further reading¶
On this site
- Display Modes
- Window Controls Overlay
- App-Like UX Patterns
- Splash Screens & Theming
- View Transitions
- Accessibility
- Desktop Platforms
- Core Web Vitals
External references
- CSS Containment Module Level 3 (container queries)
- Media Queries Level 5
- MDN: Viewport Segments API
- Chrome for Developers: Support foldable devices with the Viewport Segments API
- W3C: Device Posture API
- Android Developers: Use window size classes
- Android Developers: Behavior changes for apps targeting Android 16
- MDN: Responsive images
- WCAG 2.2: Understanding 1.3.4 Orientation