Design Engineering
Design & Architecture

Responsive Design

Strategy, patterns, and testing for interfaces that adapt across devices — breakpoints, fluid typography, layout patterns, touch, images, and responsive testing.

Responsive design is not about making a page "fit on mobile." It is about ensuring the interface remains usable, readable, and intentional at every width the user may encounter — from a 320 px phone to a 2560 px monitor. The strategy and patterns documented here should be defined before building components, not retrofitted after.

Responsive Strategy

Mobile-first vs. desktop-first

ApproachWhen to useHow it works
Mobile-firstContent-heavy apps, consumer-facing sites, greenfield projectsBase styles target the narrowest viewport. min-width media queries add complexity at larger widths.
Desktop-firstDashboards, admin panels, internal tools with known large-screen audienceBase styles target wide viewport. max-width media queries simplify for smaller screens.

In a design-engineering context, mobile-first is the recommended default. It forces you to prioritise content and functionality, keeps the CSS simpler (no need to reset desktop styles on small screens), and aligns with how browsers render progressively.

Content-driven breakpoints

Do not use device-specific breakpoints (iPhone, iPad, etc.). Define breakpoints around your content — the page layout should break when content becomes too cramped or too stretched, not when it crosses an arbitrary pixel boundary.

/* Good — content-driven */
--breakpoint-sm: 640px;   /* Single-column → two-column */
--breakpoint-md: 768px;   /* Sidebar collapses to sheet */
--breakpoint-lg: 1024px;  /* Full sidebar layout visible */
--breakpoint-xl: 1280px;  /* Additional utility panels */

/* Avoid — device-driven */
--breakpoint-iphone: 390px;
--breakpoint-ipad: 834px;

Container queries vs. media queries

Media queries respond to the viewport. Container queries respond to the parent element's size. Use each where it fits best.

FeatureMedia queryContainer query
Responds toViewport widthParent container width
Best forPage layout, sidebar visibility, global navigationReusable components that appear in multiple contexts (cards, sidebars, modals)
ExampleHide sidebar below 768pxSwitch a card from horizontal to vertical layout when placed in a narrow column
/* Container query — component adapts to its parent */
@container (max-width: 400px) {
  .profile-card {
    flex-direction: column;
  }
}

Fluid Typography

Type scaling with clamp()

Use clamp() to create typography that scales fluidly between a minimum and maximum size without breakpoints.

/* fluid-type: min 1rem, scales with viewport, max 1.5rem */
--text-base: clamp(1rem, 0.5rem + 1vw, 1.5rem);
--text-lg: clamp(1.125rem, 0.75rem + 1.25vw, 1.75rem);
--text-xl: clamp(1.25rem, 1rem + 1.5vw, 2rem);
--text-2xl: clamp(1.5rem, 1.25rem + 2vw, 2.5rem);

Readable line length

Target 60–80 characters per line for body text. On very wide screens, constrain the content area rather than allowing lines to stretch.

.prose {
  /* Constrain content width for readability */
  max-width: 65ch;
}

Layout Patterns

Common adaptive patterns that appear across most projects. Choose the right one for each section of the page.

PatternBehaviourWhen to use
Sidebar collapseNavigation sidebar becomes a sheet, drawer, or bottom navigation below the breakpointDocumentation, dashboard, settings pages
Card stackMulti-column grid collapses to single or two-column below the breakpointContent listings, feature grids, gallery pages
Priority+ navigationPrimary nav items visible; overflow items hidden behind a "More" menuGlobal navigation with many links
Responsive tableHorizontal scroll or priority columns that hide lower-priority columns at narrow widthsData tables, pricing tables, comparison views
Bottom navigationPrimary navigation moves to a persistent bottom bar on mobileConsumer apps, mobile-first interfaces
Sidebar collapse pattern
function DocsLayout({ sidebar, main }: LayoutProps) {
  return (
    <div className="lg:grid lg:grid-cols-[250px_1fr]">
      {/* Sidebar — always rendered, overlaid on mobile */}
      <aside className="hidden lg:block border-r">
        {sidebar}
      </aside>
      {/* On mobile: sidebar opened via sheet/drawer */}
      <main className="min-w-0 p-4 lg:p-8">
        {main}
      </main>
    </div>
  );
}

Responsive table strategies

Tables are notoriously difficult on small screens. Choose the right strategy per use case.

StrategyHow it worksBest for
Horizontal scrollTable scrolls within a container; first column stays pinnedDense data tables where all columns matter
Priority columnsHide columns by priority — reduce to essential fields, show "expand row" for detailLong tables where users scan specific fields
Reflow to cardsEach row becomes a labelled card at narrow widthsSimple data that is readable as key-value pairs
Truncate + modalTruncate long values with a "show more" that opens a detail viewTables with variable-length content

Modern viewport units

Traditional vh includes browser chrome (address bar) and can cause layout jumps on mobile. Modern viewport units give you precise control:

UnitWhat it measuresWhen to use
100dvhDynamic viewport height — changes as browser chrome appears/disappearsFull-screen hero sections, modals that should fill available space
100svhSmall viewport height — includes browser chromeContent that should never be hidden behind the address bar
100lvhLarge viewport height — excludes all browser chromeBackgrounds, decorative elements that can safely overflow
dvw / svw / lvwSame categories for viewport widthRarely needed — prefer 100% for width
/* Hero that respects the mobile address bar — no content hidden behind chrome */
.hero {
  min-height: 100dvh;
}

/* Sidebar that handles both desktop and mobile */
.sidebar {
  height: 100dvh;
  height: 100svh; /* fallback for browsers without dvh support */
  overflow-y: auto;
}

100dvh is the safe default for most full-height layouts. It adapts to the browser chrome state automatically. Always provide a fallback with 100vh or 100svh for older browsers.

Container query patterns

Container queries let components adapt to their parent rather than the viewport. This is essential for reusable components that appear in different contexts — a card in a three-column grid should behave differently than the same card in a sidebar.

/* Define a containment context on the parent */
.card-grid {
  container-type: inline-size;
  container-name: card-grid;
}

/* Component adapts based on available width */
@container card-grid (min-width: 500px) {
  .project-card {
    display: grid;
    grid-template-columns: 200px 1fr;
    gap: 1.5rem;
  }
  .project-card__image {
    aspect-ratio: 4/3;
  }
}

@container card-grid (max-width: 499px) {
  .project-card {
    display: flex;
    flex-direction: column;
  }
  .project-card__image {
    aspect-ratio: 16/9;
  }
}

Container queries replace the awkward pattern of passing responsive props down through components (<Card layout="horizontal" /> becomes unnecessary — the card reads its own container width and adapts). This is one of the highest-leverage CSS features for design system components.

When to use which query type

ScenarioUse
Page layout, sidebar visibility, navigation changesMedia query (responds to viewport)
Component that appears in both a main column and a sidebarContainer query (responds to parent)
Grid item that should reflow at different column countsContainer query
Global typography scaleMedia query or clamp()
A hero section that fills the screenViewport units (dvh)

Touch & Input

Touch target sizing

The minimum touch target for interactive elements is 44×44 pixels (WCAG 2.2 requirement). This applies to all interactive elements on touch-enabled devices.

/* Ensure touch targets meet minimum size */
.mobile-nav-item {
  min-height: 44px;
  min-width: 44px;
  display: flex;
  align-items: center;
  /* Padding inside the target still counts toward 44px */
  padding: 12px;
}

Hover vs. tap states

On touch devices, hover states are problematic — a hover effect may become sticky after tapping. Use the pointer media query to determine input type.

/* Hover effects only for devices with a fine pointer (mouse) */
@media (hover: hover) and (pointer: fine) {
  .card:hover {
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  }
}

/* Tap feedback for all devices (including touch) */
.card:active {
  transform: scale(0.98);
}

Responsive navigation patterns

PatternMobile behaviourDesktop behaviour
Hamburger menuHidden behind icon, opens full-screen overlay or sheetVisible horizontal nav
Bottom tab barPersistent bottom bar with 3–5 iconsSidebar or top nav
Priority+Show most important items; overflow into "More" dropdownAll items visible
Segmented controlHorizontal scroll within a containerFull-width visible segments

Responsive Images

srcset and sizes

Serve appropriately sized images for each viewport. The browser selects the best source based on viewport width and pixel density.

<Image
  src="/hero.jpg"
  srcSet="/hero-400.jpg 400w, /hero-800.jpg 800w, /hero-1200.jpg 1200w"
  sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
  alt="Hero image"
/>

With Next.js Image component, sizes is the critical prop — it tells the browser how wide the image will be at each breakpoint, which determines which source it downloads.

Art direction with <picture>

Use <picture> when different viewports need different image crops or aspect ratios, not just different sizes.

<picture>
  <source media="(max-width: 640px)" srcSet="/hero-mobile.jpg" />
  <source media="(max-width: 1024px)" srcSet="/hero-tablet.jpg" />
  <img src="/hero-desktop.jpg" alt="Hero image" />
</picture>

Testing

Test methodWhat it catchesTool / approach
Browser DevTools responsive modeLayout breaks, overflow, overlapping content at various widthsChrome/Firefox responsive mode — test at every breakpoint
Real device testingTouch target sizes, gesture conflicts, font renderingPhysical device or device emulation in browser
Playwright viewport testingLayout differences across exact dimensionspage.setViewportSize({ width: 375, height: 812 })
Visual regressionUnexpected layout changes between revisionsPlaywright snapshot or Percy/Chromatic
Content overflowText truncation, unstyled scrollbars, data overflow in tablesData seeding with long content strings
Playwright responsive test
test('page renders at mobile viewport', async ({ page }) => {
  await page.setViewportSize({ width: 375, height: 812 });
  await page.goto('/docs');
  // Sidebar should be hidden
  await expect(page.locator('aside')).toBeHidden();
  // Main content should be full width
  await expect(page.locator('main')).toBeVisible();
});

test('page renders at desktop viewport', async ({ page }) => {
  await page.setViewportSize({ width: 1440, height: 900 });
  await page.goto('/docs');
  // Sidebar should be visible
  await expect(page.locator('aside')).toBeVisible();
});

Responsive testing is not a one-time task. Every component addition or layout change can introduce regressions. Automated viewport tests in CI catch these before deployment. See Testing Strategy for Playwright setup.

On this page