Skip to content

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-pointer and any-hover describe input; prefers-reduced-motion, prefers-contrast, forced-colors and prefers-color-scheme describe 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 (including sizes="auto" for lazy images) and image-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:

index.html (head)
<meta name="viewport"
      content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content">
  • viewport-fit=cover lets 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 with env(safe-area-inset-*).
  • interactive-widget controls 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 is resizes-visual: the keyboard shrinks only the visual viewport, and your position: fixed bottom bar stays under the keyboard. resizes-content shrinks the layout viewport too, so fixed bottom UI and dvh-based layouts move above the keyboard. overlays-content resizes 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:

shell.css
.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:

fluid.css
: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%) inside minmax() 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 a rem component, rather than pure vw, keeps text zoomable. A pure vw font 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. The rem term 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

product-card.css
/* 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-size applies 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 with flex: none, an inline-block, a grid track sized auto or max-content) collapses to zero width. Give containers a definite or stretch width.
  • container-type: size also 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:

theme-slots.css
.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:

shell.css
/* 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; }
}
src/window-class.js
/**
 * 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, ResizeObserver for component-level measurement (it batches notifications once per frame), and matchMedia listeners 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 devicePixelRatio mid-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() and moveBy() 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.
src/pane-resize.js
// 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

foldable.css
/* 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.

src/segments.js
/**
 * 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:

orientation.css
/* "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 with NotSupportedError. 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.

input.css
/* 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":

src/context-menu.js
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

product.html
<!-- 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="">
  • sizes must 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, requires loading="lazy", and older browsers fall back to treating sizes as 100vw, so write sizes="auto, (width >= 840px) 40vw, 100vw" to keep a sensible fallback.
  • Always set width and height attributes. 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>:

hero.html
<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

brand.css
.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:

src/crisp-canvas.js
/**
 * 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

motion.css
/* 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:

sw.js (excerpt)
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

contrast.css
@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:

index.html (head)
<!-- 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)">
colors.css
: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_color and background_color have no dark variant in any shipped browser, so the splash screen and initial title bar use the light values until your page's theme-color meta 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).

print.css
@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;
}
src/print-button.js
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.

index.html
<!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>
css/shell.css
: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; }
}
js/shell.js
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 folded posture.
  • interactive-widget is 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.
  • 100vh app shells. On mobile browsers the bottom of the shell hides behind the toolbar. Use 100dvh (with a 100vh fallback declared first for old browsers).
  • Containers with shrink-to-fit widths. container-type: inline-size on 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 order or 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 :hover are unreachable on touch and keyboard unless you also use :focus-within and 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 devicePixelRatio as 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: auto and 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-transparency and print media without changing OS settings.
  • Elements panel marks container elements with a container badge; clicking it highlights the container and its queried descendants. The Styles pane shows which @container rule 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

External references