CSS, layout & accessibility

Responsive and adaptive design

Mobile-first with fluid type and spacing, container queries over media queries for components, viewport units and safe areas, touch targets and pointer queries, user preference queries for motion, contrast and colour scheme, images that do not shift layout, and print.

Responsive design stopped being about three breakpoints a long time ago. It is about adapting to the container, the device's capabilities and the user's preferences at once, while never shifting layout. Interviewers test it with concrete scenarios: a card that must work in a sidebar and a hero, a dashboard on a phone, a user who has reduced motion switched on.

Know

  • Mobile-first and fluid: write the small layout first and add complexity with min-width queries; use clamp(min, preferred, max) with viewport or container units for type and spacing so values scale continuously (font-size: clamp(1rem, 0.9rem + 0.5vw, 1.25rem)); min()/max() for constrained widths (width: min(100%, 72ch)).
  • Container queries for components, media queries for the page: a component should not know whether it is in a sidebar; @container sizes it by its parent, cqi units scale inside it. Media queries remain for page-level layout and for user preferences.
  • Viewport units that work on phones: dvh (dynamic, accounts for the URL bar), svh/lvh; 100vh overflows on mobile Safari. env(safe-area-inset-*) with viewport-fit=cover keeps content out of notches and home indicators.
  • Input and pointer: @media (hover: hover) before adding hover-only affordances, (pointer: coarse) for larger targets; WCAG 2.2 requires targets of at least 24×24 CSS pixels (2.5.8, AA) with 44×44 as the comfortable mobile norm; touch-action to control gestures; no hover-only disclosure of essential content.
  • Preference queries: prefers-reduced-motion: reduce (disable or shorten animations; in Vue use VueUse usePreferredReducedMotion, in Nuxt experimental.viewTransition: true already respects it), prefers-color-scheme with color-scheme and light-dark(), prefers-contrast: more, forced-colors: active (Windows High Contrast; use currentColor and system colours), prefers-reduced-data.
  • No layout shift: give every image and embed width/height or aspect-ratio, reserve space for late content (skeletons of the final size), load fonts with font-display and metric-compatible fallbacks (@nuxt/fonts), avoid injecting banners above content; CLS is a Core Web Vital.
  • Responsive images: srcset + sizes so the browser picks a resolution for the layout width, <picture> for art direction and format fallbacks, loading="lazy" below the fold, decoding="async", fetchpriority="high" for the LCP image; @nuxt/image generates all of it from sizes="100vw md:50vw".
  • Typography and readability: measure of 45–75 characters (max-inline-size: 65ch), text-wrap: balance for headings and pretty for body, relative units (rem) so user zoom and font settings work, line-height unitless.
  • Foldables and large screens: @media (min-width) still, plus the Viewport Segments API for dual screens where relevant; wide screens need a max content width, not a stretched layout.
  • Print: @media print to hide chrome, expand links (a[href]::after { content: " (" attr(href) ")" }), avoid page breaks inside cards (break-inside: avoid); often forgotten and cheap.
  • Testing: DevTools device mode with throttling, real devices for touch, prefers-* emulation in the Rendering panel, Playwright emulateMedia({ reducedMotion: 'reduce', colorScheme: 'dark' }) and viewport matrices in CI.

How it works

A card that adapts to its container, the user's preferences and the device, with no layout shift:

app/assets/css/adaptive.css
:root {
  color-scheme: light dark;
  --step-0: clamp(1rem, 0.95rem + 0.25vw, 1.125rem);
  --step-2: clamp(1.5rem, 1.2rem + 1.5vw, 2.25rem);
  --gutter: clamp(1rem, 4vw, 2rem);
}

.page {
  min-block-size: 100dvh;                                   /* not 100vh */
  padding: var(--gutter) max(var(--gutter), env(safe-area-inset-right)) var(--gutter) max(var(--gutter), env(safe-area-inset-left));
  max-inline-size: 80rem;
  margin-inline: auto;
}

.card {
  container-type: inline-size;
  display: grid;
  gap: 1rem;
  img { inline-size: 100%; aspect-ratio: 16 / 9; object-fit: cover; }  /* space reserved: no CLS */
  h2 { font-size: var(--step-2); text-wrap: balance; }
  p { max-inline-size: 65ch; text-wrap: pretty; }
  @container (min-width: 36rem) { grid-template-columns: 2fr 3fr; align-items: start; }
}

/* affordances only where they make sense */
@media (hover: hover) and (pointer: fine) {
  .card a:hover { text-decoration-thickness: 2px; }
}
@media (pointer: coarse) {
  .card button { min-block-size: 44px; min-inline-size: 44px; }
}

/* preferences */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; }
}
@media (prefers-contrast: more) { .card { border: 2px solid currentColor; } }
@media (forced-colors: active) { .card { border: 1px solid CanvasText; } }

@media print {
  nav, .card button { display: none; }
  .card { break-inside: avoid; }
  a[href^="http"]::after { content: " (" attr(href) ")"; }
}

Responsive images the Nuxt way, and motion that respects the user in Vue:

app/components/Article/Hero.vue
<script setup lang="ts">
const reduced = usePreferredReducedMotion() // 'reduce' | 'no-preference'
</script>

<template>
  <NuxtPicture
    :src="image"
    sizes="100vw md:80vw xl:1200px"
    format="avif,webp"
    width="1600"
    height="900"
    :img-attrs="{ fetchpriority: 'high', decoding: 'async' }"
  />
  <Transition :name="reduced === 'reduce' ? undefined : 'fade-up'">
    <h1 v-if="ready">{{ title }}</h1>
  </Transition>
</template>
Gotcha· 100vh on phones

min-height: 100vh is taller than the visible area while the mobile browser's toolbar is shown, so the bottom of the page is hidden until the user scrolls. 100dvh tracks the visible viewport; svh is the safe minimum when you must avoid resizing during scroll.

Gotcha· Hiding content behind hover

Menus that open only on hover exclude touch and keyboard users and fail WCAG. Open on click or focus, add @media (hover: hover) for the hover enhancement, and make the trigger a real <button> with aria-expanded.

Exercise

Exercise
  • Rebuild a card with @container and drop it into a 20rem sidebar and a 60rem main column; no media queries allowed.
  • Turn on reduced motion in your OS and navigate your app; list every animation that still plays, then fix them with one media query.
  • Measure CLS on a page with Lighthouse, add aspect-ratio and dimensions to every image and embed, re-measure.

Be able to say

Be able to say· How do you approach responsive design in 2026?

"Mobile-first and fluid: clamp() for type and spacing so values scale continuously, min() and max() for constrained widths, and container queries so a component adapts to the space it is given rather than to the viewport, which media queries never captured. Then I adapt to the device and the person: dvh and safe-area insets on phones, pointer and hover queries before adding hover-only affordances, 24-pixel minimum targets per WCAG 2.2 and 44 on touch, and preference queries for reduced motion, colour scheme, contrast and forced colours. And I keep the layout stable: dimensions or aspect ratios on every image, metric-compatible font fallbacks, reserved space for late content, because CLS is a Core Web Vital and a shifting layout is the most visible defect a user meets."

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.