Skip to content

Responsive & Modern CSS

Responsive design means one document that adapts to any viewport, input method, and user preference. Modern CSS does most of it with intrinsic sizing and fluid values; media queries are the exception, not the foundation.

px is the only absolute unit worth using. A CSS pixel is a device-independent reference unit — on a 3× phone screen, 1px is three physical pixels. pt, cm, in, mm are defined relative to px (1in = 96px) and belong in print stylesheets only.

Unit Relative to
em the element’s own font-size (for font-size itself: the parent’s)
rem the root (<html>) font-size — 16px by default
ch the advance width of 0 in the current font
ex the x-height of the current font
lh the element’s own line-height

em compounds. Nested elements each multiply:

.a { font-size: 1.5em; } /* 24px if parent is 16px */
.a .a { font-size: 1.5em; }/* 36px — compounds */

That makes em unpredictable for font size and excellent for padding that scales with the element’s own text:

.btn {
font-size: 1rem;
padding: 0.6em 1.2em; /* padding scales with the button's text size */
border-radius: 0.4em;
}
.btn-lg { font-size: 1.25rem; } /* padding grows automatically */

Rule of thumb: rem for font sizes and layout spacing, em for spacing that must track the local font size, px for hairlines and things that should never scale (1px borders).

ch is the right unit for line length:

article { max-inline-size: 65ch; } /* ~65 characters: comfortable measure */
Unit Meaning
vw / vh 1% of viewport width / height
vmin / vmax 1% of the smaller / larger viewport dimension
svh / svw small viewport: as if mobile browser UI is fully shown
lvh / lvw large viewport: as if browser UI is fully hidden
dvh / dvw dynamic: updates as the URL bar collapses and expands

On mobile, 100vh equals the large viewport, so a height: 100vh hero is taller than the visible area and its bottom is hidden behind the browser chrome. Use 100dvh (Chrome 108+, Safari 15.4+, Firefox 101+):

.hero { min-block-size: 100dvh; }

vw ignores the scrollbar width in most browsers, so width: 100vw on a page with a vertical scrollbar causes horizontal overflow. Prefer 100% or the newer 100dvw.

Grid-only, and not a length: a fraction of the leftover space in a grid container. Covered in Grid.

@media (min-width: 48rem) {
.layout { grid-template-columns: 240px 1fr; }
}

All current browsers support comparison operators, which are easier to read and avoid off-by-one overlaps:

@media (width >= 48rem) { }
@media (width < 48rem) { }
@media (40rem <= width < 64rem) { }

The old form is (min-width: …) / (max-width: …). If you mix them, remember max-width: 48rem includes 48rem, so pairing it with min-width: 48rem makes both match at exactly 48rem.

@media (min-width: 48rem) and (orientation: landscape) { }
@media (min-width: 48rem), print { } /* comma = OR */
@media not all and (min-width: 48rem) { }
@media screen and (min-width: 48rem) { } /* media type + feature */

These matter more than breakpoints for real users.

/* Colour scheme */
@media (prefers-color-scheme: dark) {
:root { --bg: #0b0b0d; --fg: #e8e8ea; }
}
/* Motion sensitivity — respect it, always */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
@media (prefers-contrast: more) { :root { --fg: #000; } }
@media (prefers-reduced-transparency: reduce) { .glass { backdrop-filter: none; } }

Even simpler for colour scheme — tell the browser which schemes you support and it will style form controls, scrollbars and the default background correctly:

:root { color-scheme: light dark; }
@media (hover: hover) and (pointer: fine) {
.card:hover { transform: translateY(-2px); } /* mouse only */
}
@media (pointer: coarse) {
.btn { min-block-size: 44px; } /* bigger touch targets */
}

(hover: hover) is how you avoid sticky hover states on touch devices, where a tap leaves the hover style applied.

@media print { nav, .no-print { display: none; } }
@media (display-mode: standalone) { } /* installed PWA */
@media (scripting: none) { .js-only { display: none; } }

Write the narrow-screen layout as the base, then add complexity at wider breakpoints with min-width.

/* base: single column, applies everywhere */
.layout { display: grid; gap: 1.5rem; }
@media (width >= 48rem) {
.layout { grid-template-columns: 240px 1fr; }
}
@media (width >= 72rem) {
.layout { grid-template-columns: 240px 1fr 280px; }
}

Why this direction and not max-width:

  • The base styles are the simplest ones, and small devices are the ones with the least capacity.
  • Each query only adds, so you never write “undo” rules.
  • Overrides accumulate in one direction, which is far easier to reason about than a stack of max-width blocks that cancel each other out.

Pick breakpoints where your layout breaks, not at device names. Resize the window until something looks wrong; that is a breakpoint.

A media query asks about the viewport. A container query asks about the size of an element’s own container — so a component can be responsive to the space it is actually given, wherever it is used.

.card-wrap {
container-type: inline-size; /* it can now be queried on its inline size */
container-name: card; /* optional name */
}
/* shorthand for both: */
.card-wrap { container: card / inline-size; }
@container card (width >= 30rem) {
.card { display: grid; grid-template-columns: 12rem 1fr; gap: 1rem; }
}
/* unnamed: queries the nearest ancestor container */
@container (width >= 30rem) { }

Supported in Chrome 105+, Safari 16+, Firefox 110+.

Key rules:

  • You query an ancestor, never the element itself. Wrap the component.
  • container-type: inline-size allows querying width only, and the container’s block size becomes dependent on content as usual. container-type: size allows both axes but requires the container to have a definite block size, which usually means you must set a height.
  • container-type also creates a containment context, which means the container establishes a new stacking and layout context — it behaves like contain: layout style inline-size.

Inside a container query (and inside a container generally) you can size against the container:

Unit 1% of the container’s
cqw / cqh width / height
cqi / cqb inline size / block size
cqmin / cqmax smaller / larger of cqi and cqb
.card h2 { font-size: clamp(1rem, 5cqi, 2rem); }

@container style(--variant: compact) queries the value of a custom property on the container. Custom-property style queries are supported in Chrome 111+; queries on arbitrary properties are not broadly available. Treat style queries as a progressive enhancement.

These are math functions that work anywhere a length is allowed.

width: min(100%, 60rem); /* the smaller — an implicit max-width */
width: max(50%, 20rem); /* the larger — an implicit min-width */
font-size: clamp(1rem, 2.5vw + 0.5rem, 2rem);

clamp(MIN, PREFERRED, MAX) is exactly max(MIN, min(PREFERRED, MAX)).

Fluid type without a single media query:

:root {
--step-0: clamp(1rem, 0.95rem + 0.25vw, 1.125rem);
--step-1: clamp(1.25rem, 1.1rem + 0.75vw, 1.5rem);
--step-2: clamp(1.5rem, 1.2rem + 1.5vw, 2.25rem);
--step-3: clamp(2rem, 1.4rem + 3vw, 3.5rem);
}
h1 { font-size: var(--step-3); }
p { font-size: var(--step-0); }

Fluid gutters and a content column, in three lines:

.container {
inline-size: min(100% - 2rem, 72rem);
margin-inline: auto;
}

Related math: calc() mixes units (calc(100% - 2rem)), and round(), mod(), rem() exist in newer browsers.

Give the browser several files and tell it how wide the image will be rendered; it picks the best one for the device pixel ratio and viewport.

<img
src="photo-800.jpg"
srcset="photo-400.jpg 400w, photo-800.jpg 800w, photo-1600.jpg 1600w"
sizes="(width >= 48rem) 50vw, 100vw"
width="1600"
height="900"
alt="A harbour at dusk"
loading="lazy"
decoding="async"
/>
  • w descriptors state each file’s intrinsic pixel width.
  • sizes describes the rendered width at each breakpoint. Without it the browser assumes 100vw and over-downloads.
  • width and height attributes let the browser reserve space, preventing layout shift. CSS aspect-ratio does the same job.
  • loading="lazy" for below-the-fold images only; never on the LCP image. Use fetchpriority="high" on that one instead.

Use <picture> for art direction (a different crop) or format negotiation:

<picture>
<source srcset="hero.avif" type="image/avif" />
<source srcset="hero.webp" type="image/webp" />
<source media="(width < 40rem)" srcset="hero-square.jpg" />
<img src="hero.jpg" alt="" width="1600" height="900" />
</picture>

The browser takes the first <source> whose type and media both match. The <img> is the fallback and carries the alt.

img, video, svg { max-inline-size: 100%; block-size: auto; display: block; }
.thumb {
aspect-ratio: 16 / 9;
object-fit: cover; /* fill the box, crop the overflow */
object-position: 50% 30%;
}

object-fit values: fill (default, distorts), contain (letterbox), cover (crop), none, scale-down.

Physical properties (left, top, margin-left) assume a left-to-right, top-to-bottom writing mode. Logical properties are expressed relative to the flow, so the same CSS works in Arabic, Hebrew, or vertical Japanese with no overrides.

Physical Logical
width / height inline-size / block-size
margin-left / margin-right margin-inline-start / margin-inline-end
margin-top / margin-bottom margin-block-start / margin-block-end
padding-left + padding-right padding-inline
padding-top + padding-bottom padding-block
border-left border-inline-start
top/right/bottom/left inset-block-start / inset-inline-end / …
text-align: left text-align: start
max-width max-inline-size
.card {
padding-inline: 1.5rem;
padding-block: 1rem;
border-inline-start: 3px solid var(--accent);
margin-inline: auto;
max-inline-size: 60ch;
text-align: start;
}

margin-inline: auto is the modern “centre a block” and reads better than margin: 0 auto.

The one-value and multi-value forms exist for inset too:

.overlay { inset: 0; } /* all four */
.badge { inset-block-start: 8px; inset-inline-end: 8px; }

Flexbox and Grid are already logical — flex-direction: row, justify-content: flex-start, and grid’s inline/block axes all follow the writing mode automatically.

  • rem for type and layout, em for element-local spacing, ch for measure, px for hairlines.
  • Use dvh/svh instead of vh for full-height sections on mobile.
  • Write breakpoints in rem with range syntax, mobile-first with min-width.
  • Always honour prefers-reduced-motion; declare color-scheme.
  • Container queries make components responsive to their slot; query an ancestor with container-type: inline-size.
  • clamp() with a rem term removes most typography breakpoints.
  • srcset/sizes for resolution switching, <picture> for art direction or formats; always set width/height.
  • Prefer logical properties — shorter, and internationalisation comes free.