Overview
Modern CSS handles layout, theming, and responsive behavior natively. Most of what teams reach for libraries to do is now in the platform. This page covers the primitives worth knowing before you write a single class.
Grid for 2D, Flex for 1D
Pick the layout primitive by the dimension count, not by familiarity.
- Grid: the page has rows and columns that line up. Use for page shells, dashboards, card layouts, complex form rows.
- Flex: a single row or column with items that grow or shrink. Use for nav bars, button rows, side-by-side input groups.
.shell {
display: grid;
grid-template-columns: 240px 1fr;
grid-template-rows: auto 1fr auto;
min-block-size: 100dvh;
}
.toolbar {
display: flex;
gap: 0.5rem;
align-items: center;
}Nesting Grid inside Flex inside Grid is normal. Mix freely.
Define tokens as custom properties
Custom properties are the design system substrate. Define them once on :root and read them everywhere.
:root {
--color-ink: oklch(0.2 0 0);
--color-surface: white;
--space-1: 0.25rem;
--space-2: 0.5rem;
--radius-card: 0.75rem;
--shadow-card: 0 1px 2px rgb(0 0 0 / 0.08);
}Tokens cascade and respond to media queries, which makes theming a one-line change. If you ship Tailwind v4, define tokens with @theme and they become utilities for free; see tailwind.
Order the cascade with @layer
Use @layer to give intentional precedence to broad categories of CSS. Layers declared later win, regardless of selector specificity within them.
@layer reset, base, components, utilities;
@layer reset { /* normalize */ }
@layer base { :root { --color-ink: oklch(0.2 0 0); } }
@layer components { .card { /* ... */ } }
@layer utilities { .text-center { text-align: center; } }Third-party CSS (a vendor widget, a CMS body) goes in its own layer below yours. You then override it without !important.
Use :has() for parent and sibling state
:has() makes the parent selector real. Style a container based on what is inside it.
/* A form row that has an error message inside it. */
.form-row:has(.error) > label { color: var(--color-danger); }
/* A card that contains an image gets tighter padding. */
.card:has(> img) { padding-block-start: 0; }
/* Hide the sidebar when a modal is open anywhere on the page. */
body:has(dialog[open]) .sidebar { visibility: hidden; }Treat :has() as production-ready in evergreen browsers. It deletes a lot of JavaScript that existed only to add and remove classes.
Query the container, not the viewport
Container queries let a component restyle itself based on its parent’s size, not the window. This is what media queries should have been for components.
.card {
container-type: inline-size;
}
.card .title {
font-size: 1rem;
}
@container (min-width: 32rem) {
.card .title { font-size: 1.25rem; }
}The same .card renders correctly in a 240px sidebar and an 800px main column without conditional class strings.
Use logical properties for layout
Logical properties (margin-inline, padding-block, inset-inline-start) describe the writing flow, not the screen. They flip automatically in RTL languages and vertical writing modes.
margin-inlineinstead ofmargin-leftandmargin-right.padding-blockinstead ofpadding-topandpadding-bottom.inset-inline-startinstead ofleft.border-inline-endinstead ofborder-right.
Even on LTR-only sites, the names read better at the call site and survive a future RTL rollout.
Fluid type with clamp(), system fonts as default
Use clamp(min, preferred, max) for type that scales with the viewport without breakpoints.
h1 {
font-size: clamp(1.75rem, 1.25rem + 2vw, 3rem);
line-height: 1.1;
}Default to the system font stack until you have a reason to load a webfont. System fonts render instantly, match the OS, and ship zero bytes.
:root {
font-family: system-ui, -apple-system, "Segoe UI", Roboto, Inter, sans-serif;
}Add a webfont when brand identity requires it; subset, preload, and use font-display: swap.