Layout Patterns
10 minLayout Patterns
The most reliable indicator of an experienced frontend developer is how they handle layout. Beginners reach for JavaScript measurement—getBoundingClientRect, offsetHeight, IntersectionObserver for positioning—before asking whether CSS could do the job. Experienced developers start with CSS flex and grid and reach for JavaScript only when the browser genuinely can't solve the problem.
The principle: never measure layout in JavaScript if CSS can do the job. Layout reads in JavaScript force the browser to flush pending style changes, which triggers expensive reflows. CSS flex and grid respond to viewport and content changes for free—properties that JavaScript-based layout has to recompute on every resize, mutation, or font swap.
What flex and grid solve for free
These are real layout problems developers routinely solve with JavaScript that CSS handles natively:
- Equal-width columns:
display: grid; grid-template-columns: repeat(3, 1fr); - Aspect-ratio cards:
aspect-ratio: 4 / 3; - Responsive wrap:
flex-wrap: wrap;withmin-contentconstraints. - Sidebar + main:
grid-template-columns: 240px 1fr; - Centred elements:
place-items: center;on the parent. - Sticky elements:
position: sticky; top: 0;without JS scroll listeners. - Equal heights across cards:
display: gridwithalign-items: stretch.
The last one is especially common. Reading every card's height in JavaScript and applying the max as a fixed height is a pattern that display: grid eliminates with zero JavaScript.
When JavaScript is appropriate
Some UI does need measurement:
- Anchor positioning for tooltips, popovers, and dropdowns—until CSS Anchor Positioning ships broadly. Use a library (
floating-ui, Radix, Aria) rather than writing it yourself. - Virtualised lists past 50 items—rendering thousands of rows requires JavaScript windowing.
- Drag-and-drop reordering—needs hit-testing during the drag.
In all three cases, batch reads and writes carefully. Layout reads (offsetTop, getBoundingClientRect) followed by writes (style changes) followed by reads cause layout thrashing—the browser does the work multiple times per frame.
overscroll-behavior in modals
Modal sheets, drawers, and infinite-scroll lists need one often-forgotten CSS rule: overscroll-behavior: contain. Without it, scrolling past the bottom of a modal scrolls the body underneath—and on iOS, sometimes triggers pull-to-refresh.
.modal {
overscroll-behavior: contain;
}
This single property prevents scroll chaining from the modal to the page. It's the difference between a modal that feels self-contained and one that feels like it's fighting the browser.
Pair it with 100dvh instead of 100vh for full-height layouts on mobile. 100vh includes the browser chrome on mobile and overflows; 100dvh (dynamic viewport height) accounts for the URL bar and toolbar.
Empty, loading, and error states as first-class
Every component that fetches or displays user data has four states: populated, empty, loading, and error. Most components ship with the populated state polished and the other three as afterthoughts.
Empty
The component has nothing to show. Two cases need different treatment:
- First-time empty: No invoices yet. Create your first invoice to get paid. Plus a primary CTA. This is a recruitment opportunity—tell the user what the surface is for and let them take the first step.
- Filter empty: No results match "Q4 2025". Plus a Clear filters button.
A blank space with the word "Empty" is not an empty state.
Loading
Show a skeleton or spinner—but not immediately. If the operation finishes within ~200ms, the user perceives it as instant. Show nothing for the first 150–300ms, then commit to a loading state only if the response is genuinely slow. Once the spinner appears, keep it visible for at least 300ms to avoid a flash-on-flash-off flicker.
For lists, prefer skeletons (grey shapes matching the eventual layout) over spinners. The user can see what's coming. For atomic operations (form submit, button click), prefer a spinner inside the action.
Error
The error state should tell the user what failed, what to do about it, and provide an action—a retry button, a support link, or both. Preserve the user's work wherever possible. If a form submission fails, keep the typed values; don't reset the form.
A red banner with the word "Error" is not an error state.
Long content safety
Layouts break when content is longer than the designer pictured. A 70-character email address. A product name in German. A URL pasted into a card. Three CSS rules fix most of the damage:
.cell {
min-width: 0; /* allow flex/grid children to shrink */
overflow-wrap: anywhere; /* break long unbroken strings */
word-break: break-word; /* fallback for older browsers */
}
min-width: 0 is the most counter-intuitive. By default, flex and grid children have min-width: auto, which means they refuse to shrink below their content width. A 60-character email in a flex item makes the entire row blow out.
Common mistakes
- JS-measured equal heights. Replace with
display: gridandalign-items: stretch. window.addEventListener('resize', ...)for responsive layout. Use CSS media queries; let the browser batch the changes.- Forgetting
overscroll-behavioron modals. The user scrolls to the bottom of a modal and the page behind starts scrolling. - No empty state at all. The component renders an empty list and the user sees whitespace with no explanation.
- Loading spinner for a 50ms request. Flicker. Use a show-delay.
- Using
100vhon mobile. Content overflows behind the browser toolbar. Use100dvh.