Building Responsive Layouts: Mobile-First, Flexbox, Grid, and Fluid Sizing
Learn to build React components that adapt cleanly across screen sizes using mobile-first CSS, breakpoints, Flexbox and Grid layout, fluid sizing units, responsive typography, and touch-friendly mobile navigation.
Responsive design in a React app is mostly a CSS problem wearing a component-architecture hat: the same JSX tree needs to render sensibly on a 360px phone and a 2560px monitor, and React's job is to give you the structural hooks (conditional rendering, layout components, CSS custom properties) that make that adaptation maintainable instead of a pile of one-off media queries.
Mobile-first thinking and breakpoints
Mobile-first means you write your base (unprefixed) CSS for the smallest, most constrained screen first, then layer on min-width media queries to progressively enhance the layout as more space becomes available. This is the opposite of writing a desktop layout and then squeezing it down with max-width overrides, and it tends to produce simpler, more robust CSS because each breakpoint only adds capability rather than fighting a bigger layout into submission.
Breakpoints are the viewport widths at which your layout changes shape — commonly something like 480px (large phones), 768px (tablets), 1024px (small laptops), and 1280px+ (desktops), though the 'right' values depend entirely on where your own content actually starts looking cramped or sparse, not on matching a device catalog. In a React project these breakpoints are usually centralized as CSS custom properties or a shared TypeScript constants object so every component media query and every JS-based check (like a useMediaQuery hook) references the same numbers.
It's worth being explicit that breakpoints should be driven by content, not by specific devices. Chasing 'iPhone width' or 'iPad width' as breakpoints creates layouts that only work for the devices you tested, while breakpoints chosen at the point where your own grid, text line-length, or navigation starts to break adapt correctly to the huge variety of real screen sizes users actually have.
Flexbox and Grid for adaptive layout
Flexbox excels at one-dimensional layout — a row of nav links, a card's icon-plus-text alignment, a toolbar that needs items to wrap onto a second line on narrow screens. flex-wrap: wrap combined with a min-width on flex children is often all it takes to make a row of cards reflow into fewer columns as the viewport shrinks, with zero JavaScript involved.
CSS Grid excels at two-dimensional layout — a page shell with header/sidebar/content/footer regions, or a card gallery that needs both rows and columns to line up. The repeat(auto-fit, minmax(240px, 1fr)) pattern is a cornerstone of responsive Grid: it tells the browser to fit as many 240px-minimum columns as will comfortably fit the row and stretch them evenly, which reflows a grid from four columns to two to one purely through CSS, with no media query at all.
In React, these layout primitives usually live in a small set of reusable layout components (Stack, Grid, Cluster) rather than being re-implemented inline in every feature component. That keeps the responsive rules in one place, so a change to the breakpoint or gap size for 'card grids' propagates everywhere automatically instead of needing to be hunted down across dozens of files.
Fluid sizing and responsive typography
Fluid sizing uses CSS functions like clamp(min, preferred, max) to let a value scale smoothly with the viewport instead of jumping between fixed values at breakpoints. clamp(1rem, 0.5rem + 2vw, 2rem) gives you a font size that grows with the viewport width but never shrinks below 1rem or grows past 2rem, which produces a much smoother reading experience than a handful of discrete font-size media queries.
Responsive typography specifically benefits from clamp() because line length (characters per line) is what actually affects readability, and a fluid font size keeps line length in a comfortable range across a continuous range of viewport widths rather than only at the handful of widths you happened to write breakpoints for. Pairing a fluid font-size with a max-width on the text container (often in ch units, which are based on character width) is a reliable combination.
Fluid sizing isn't limited to text — spacing (padding, gap, margin) and even component dimensions can use clamp() so an entire layout breathes proportionally with the viewport instead of having spacing that feels cramped on mobile and oddly small on a wide desktop screen.
Touch targets and mobile navigation
Touch targets — buttons, links, form controls — need to be large enough to tap accurately with a finger, which is far less precise than a mouse cursor. The widely cited guideline is a minimum of 44x44 CSS pixels (from Apple's Human Interface Guidelines; Android's Material guidelines use 48x48dp), and this applies to the tappable hit area, not just the visible icon — a small icon can still have a large invisible padding that extends its tap target.
Mobile navigation typically can't just be a horizontal row of links once space runs out — the common pattern is collapsing the full navigation into a hamburger-triggered off-canvas menu or bottom tab bar below a breakpoint, while showing the full horizontal nav above it. In React this is usually implemented with a boolean open/closed state, conditional class names or conditional rendering for the menu panel, and careful attention to keyboard/focus management and an aria-expanded attribute on the toggle button for accessibility.
A responsive component in React often needs to change more than just its CSS — a data table might need to become a stacked card list on mobile, which is a structural (JSX) change, not just a stylistic one. This is where component-level responsive design goes beyond CSS media queries and starts to involve conditional rendering driven by a matchMedia-backed hook, covered in the Browser APIs module.
Container queries: responding to a parent, not the viewport
Media queries respond to the viewport's size, which is a problem for reusable components: a card component might live in a wide main content area on one page and a narrow sidebar on another, and its own internal layout often needs to differ based on the space it actually has, not the window's total width. Container queries solve exactly this by letting a component respond to the size of its nearest ancestor with container-type: inline-size, using @container instead of @media.
As of the mid-2020s, container queries have solid support in all major evergreen browsers, so they're a practical tool rather than a future one — but it's still worth being aware that older browsers or constrained environments (some embedded webviews, very old browser versions) may not support them, so a graceful base layout that doesn't depend on the container query firing is good practice.
In a component library, container queries let a single Card component be written once and correctly adapt whether it's rendered at 300px or 900px wide, which is a meaningfully different (and often better) unit of reusability than a component whose internal layout only ever considers window width.
• container-type: inline-size on the parent opts that element into being a query container. • @container (min-width: 400px) { ... } inside the component's CSS then targets the container's width, not the viewport's. • container-name lets you scope a container query to a specific named ancestor when there are nested containers.
A responsive card grid: mobile-first CSS with clamp() and auto-fit Grid
auto-fit + minmax lets the grid reflow from many columns to one with zero media queries. clamp() on gap and font-size scales those values smoothly instead of jumping at breakpoints. The nav-toggle button is only hidden (display: none) once the viewport is wide enough to show the full nav — mobile-first base styles show it by default.
.card-grid {
display: grid;
gap: clamp(0.75rem, 1vw + 0.5rem, 1.5rem);
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
.card-grid h2 {
font-size: clamp(1.125rem, 0.9rem + 0.6vw, 1.5rem);
}
.nav-toggle {
display: block;
min-width: 44px;
min-height: 44px;
}
@media (min-width: 768px) {
.nav-toggle {
display: none;
}
.primary-nav {
display: flex;
gap: 1.5rem;
}
}Code Examples
The toggle button (a 44px+ touch target) controls a boolean open state; the primary-nav element gets an 'open' class on mobile that CSS uses to slide it into view, while a min-width media query overrides that CSS entirely on desktop to show the nav as a plain horizontal row and hides the toggle button. Expected result: On a narrow viewport, tapping the hamburger button reveals the nav links as a stacked panel; at 768px and above, the toggle disappears and the links show inline automatically via CSS.
Some responsive changes go beyond CSS — here the description is dropped entirely on small screens rather than merely restyled, because showing it would make the row too tall to scan. The useMediaQuery hook (built in the Browser APIs lesson) drives this structural decision from React state.
const isCompact = useMediaQuery("(max-width: 640px)");
import { useState } from "react";
function ResponsiveNav({ links }: { links: { href: string; label: string }[] }) {
const [open, setOpen] = useState(false);
return (
<header className="site-header">
<button
className="nav-toggle"
aria-expanded={open}
aria-controls="primary-nav"
onClick={() => setOpen((o) => !o)}
>
<span className="sr-only">Menu</span>
☰
</button>
<nav id="primary-nav" className={open ? "primary-nav open" : "primary-nav"}>
<ul>
{links.map((link) => (
<li key={link.href}>
<a href={link.href} onClick={() => setOpen(false)}>
{link.label}
</a>
</li>
))}
</ul>
</nav>
</header>
);
}import { useMediaQuery } from "./useMediaQuery";
interface Product {
id: string;
name: string;
price: number;
description: string;
}
function ProductRow({ product }: { product: Product }) {
const isCompact = useMediaQuery("(max-width: 640px)");
if (isCompact) {
return (
<article className="product-card compact">
<h3>{product.name}</h3>
<p className="price">${product.price.toFixed(2)}</p>
</article>
);
}
return (
<article className="product-card">
<h3>{product.name}</h3>
<p className="description">{product.description}</p>
<p className="price">${product.price.toFixed(2)}</p>
</article>
);
}Mobile-first breakpoint layering
Mobile-first CSS stacks min-width media queries as progressive enhancements rather than max-width overrides fighting a desktop-first base.
base styles (0px+)
| apply to every screen, written first
v
@media (min-width: 480px) -- add: two-column card grid
v
@media (min-width: 768px) -- add: full horizontal nav, hide toggle
v
@media (min-width: 1024px) -- add: sidebar layout, wider max-width
each layer only ADDS or OVERRIDES on top of the one before itImportant Rules
Write base (unprefixed) styles for the smallest screen first, then add min-width media queries to enhance the layout — never the reverse.
Choose breakpoints where your own content breaks, not to match specific device widths.
Give every interactive element a minimum 44x44px (iOS) / 48x48dp (Android) tap target, even if the visible icon is smaller.
Prefer clamp() for font-size, spacing, and other values that should scale smoothly, and reserve discrete breakpoints for structural layout changes.
Use container queries (@container) instead of viewport media queries when a component's layout should depend on the space its parent gives it, not the window size.
Where this shows up
Every production React app with real traffic serves a wide spread of screen sizes — a component library built mobile-first with fluid typography, Flexbox/Grid reflow, and touch-safe targets needs far fewer one-off breakpoint overrides than one retrofitted from a desktop-only design, and container queries increasingly let shared components (cards, widgets) be written once and correctly adapt to sidebars, modals, and full-width layouts without any JavaScript involved..card { font-size: 24px; } @media (max-width: 768px) {.card { font-size: 16px; } } @media (max-width: 480px) {.card { font-size: 14px; } }.card { font-size: 16px; } @media (min-width: 768px) {.card { font-size: 24px; } }
Writing the desktop layout first and shrinking it with max-width queries (desktop-first) tends to produce more overrides and fights against itself as more breakpoints are added. Mobile-first with min-width queries only adds rules going up, which stays simpler as the number of breakpoints grows.
<button style={{ width: 20, height: 20 }}>✕</button> <button style={{ width: 20, height: 20, padding: 12, minWidth: 44, minHeight: 44 }}>✕</button>
A visually small icon button with no extra padding creates a tap target well under the 44px minimum, making it easy to mis-tap on a touchscreen — the fix is to keep the visual icon small while padding out the actual clickable area.
[multiple-choice] What does 'mobile-first' mean in the context of writing responsive CSS? A. Writing the base, unprefixed styles for the smallest screen and layering min-width media queries on top B. Writing the desktop layout first and using max-width media queries to shrink it down C. Only supporting mobile devices and ignoring desktop entirely D. Using JavaScript to detect the device type and serve entirely different HTML
A. Writing the base, unprefixed styles for the smallest screen and layering min-width media queries on top — Mobile-first means the unprefixed base styles target the smallest/most constrained screen, and min-width media queries progressively add capability as more space becomes available.
[multiple-choice] Which CSS Grid pattern lets a card grid reflow its column count automatically as the viewport shrinks, without any media query? A. grid-template-columns: repeat(3, 1fr) B. grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)) C. display: flex; flex-direction: column D. grid-template-columns: 1fr 1fr 1fr with !important
B. grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)) — repeat(auto-fit, minmax(240px, 1fr)) tells the browser to fit as many 240px-minimum columns as the row's width allows and stretch them evenly, reflowing the column count purely from available space.
[true-false] Container queries (@container) respond to the size of the browser viewport, just like @media. A. True B. False
B. False — Container queries respond to the size of a component's nearest ancestor opted in with container-type, not the viewport — this is what lets the same component adapt correctly whether it's placed in a wide main area or a narrow sidebar.
Convert a desktop-first three-column CSS Grid card layout (grid-template-columns: repeat(3, 1fr), with max-width: 640px overrides collapsing it to one column) into a mobile-first version using repeat(auto-fit, minmax(240px, 1fr)) that needs no media queries at all..card-grid { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); } auto-fit combined with a minmax() lower bound lets the browser compute the column count itself based on available width.