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
| Approach | When to use | How it works |
|---|---|---|
| Mobile-first | Content-heavy apps, consumer-facing sites, greenfield projects | Base styles target the narrowest viewport. min-width media queries add complexity at larger widths. |
| Desktop-first | Dashboards, admin panels, internal tools with known large-screen audience | Base 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.
| Feature | Media query | Container query |
|---|---|---|
| Responds to | Viewport width | Parent container width |
| Best for | Page layout, sidebar visibility, global navigation | Reusable components that appear in multiple contexts (cards, sidebars, modals) |
| Example | Hide sidebar below 768px | Switch 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.
| Pattern | Behaviour | When to use |
|---|---|---|
| Sidebar collapse | Navigation sidebar becomes a sheet, drawer, or bottom navigation below the breakpoint | Documentation, dashboard, settings pages |
| Card stack | Multi-column grid collapses to single or two-column below the breakpoint | Content listings, feature grids, gallery pages |
| Priority+ navigation | Primary nav items visible; overflow items hidden behind a "More" menu | Global navigation with many links |
| Responsive table | Horizontal scroll or priority columns that hide lower-priority columns at narrow widths | Data tables, pricing tables, comparison views |
| Bottom navigation | Primary navigation moves to a persistent bottom bar on mobile | Consumer apps, mobile-first interfaces |
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.
| Strategy | How it works | Best for |
|---|---|---|
| Horizontal scroll | Table scrolls within a container; first column stays pinned | Dense data tables where all columns matter |
| Priority columns | Hide columns by priority — reduce to essential fields, show "expand row" for detail | Long tables where users scan specific fields |
| Reflow to cards | Each row becomes a labelled card at narrow widths | Simple data that is readable as key-value pairs |
| Truncate + modal | Truncate long values with a "show more" that opens a detail view | Tables 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:
| Unit | What it measures | When to use |
|---|---|---|
100dvh | Dynamic viewport height — changes as browser chrome appears/disappears | Full-screen hero sections, modals that should fill available space |
100svh | Small viewport height — includes browser chrome | Content that should never be hidden behind the address bar |
100lvh | Large viewport height — excludes all browser chrome | Backgrounds, decorative elements that can safely overflow |
dvw / svw / lvw | Same categories for viewport width | Rarely 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
| Scenario | Use |
|---|---|
| Page layout, sidebar visibility, navigation changes | Media query (responds to viewport) |
| Component that appears in both a main column and a sidebar | Container query (responds to parent) |
| Grid item that should reflow at different column counts | Container query |
| Global typography scale | Media query or clamp() |
| A hero section that fills the screen | Viewport 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
| Pattern | Mobile behaviour | Desktop behaviour |
|---|---|---|
| Hamburger menu | Hidden behind icon, opens full-screen overlay or sheet | Visible horizontal nav |
| Bottom tab bar | Persistent bottom bar with 3–5 icons | Sidebar or top nav |
| Priority+ | Show most important items; overflow into "More" dropdown | All items visible |
| Segmented control | Horizontal scroll within a container | Full-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 method | What it catches | Tool / approach |
|---|---|---|
| Browser DevTools responsive mode | Layout breaks, overflow, overlapping content at various widths | Chrome/Firefox responsive mode — test at every breakpoint |
| Real device testing | Touch target sizes, gesture conflicts, font rendering | Physical device or device emulation in browser |
| Playwright viewport testing | Layout differences across exact dimensions | page.setViewportSize({ width: 375, height: 812 }) |
| Visual regression | Unexpected layout changes between revisions | Playwright snapshot or Percy/Chromatic |
| Content overflow | Text truncation, unstyled scrollbars, data overflow in tables | Data seeding with long content strings |
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.
Layout Patterns & Scale
Common page layouts with specific dimensions, Tailwind configuration patterns, and how to customise spacing, sidebar widths, and container sizes.
Design System
Establish the visual language of the application through design tokens, typography scales, and spacing systems before components are built.