HTML & CSS NotesRohit’s interview study guide
Chapter 08

Responsive Design

Making one page work on every screen: mobile-first CSS, the viewport meta tag, media queries and their range syntax, fluid layouts and type without breakpoints, container queries, responsive images, and the viewport units that behave on phones.

8 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Responsive design and mobile-first

Added

Responsive design means one page, one URL and one set of HTML that adapts its layout to the space it's given, instead of a separate m. site for phones. The term comes from Ethan Marcotte's 2010 article, which named three ingredients that are still the core of it: fluid layouts (sizes in relative units, not fixed pixels), flexible media (images that never overflow their container), and media queries (CSS that applies only when a condition, such as a minimum width, is true). Modern CSS adds a fourth: layouts that adapt on their own (flex-wrap, auto-fit grids, clamp()) and container queries that respond to a component's own width rather than the whole screen.

Mobile-first is the usual way to write it. The base CSS, outside any media query, is the small-screen layout: usually a single column, which is also what plain block layout gives you for free. Then min-width media queries add layout as space grows. The alternative, desktop-first, writes the wide layout as the base and uses max-width queries to undo it on small screens.

Mobile-first wins for three reasons: the small screen (often the slowest device on the worst network) gets the simplest CSS with nothing to override; each query only adds, so rules don't fight each other; and it forces you to decide what matters most when space is short. A useful mental model: the base styles are the content in its simplest arrangement, and every breakpoint is an enhancement for when there's room. Put breakpoints where the content starts to look wrong (lines too long, cards too wide), not at the widths of particular phones.

A mobile-first page: one column, then a sidebar and two columns from 600px
CSS
/* Base: small screens. Plain block flow, cards in one column. */
.page { display: grid; gap: 12px; }
.cards { display: grid; gap: 8px; }
.side, .card { padding: 8px; }
.side { background: #e0e7ff; color: #312e81; }
.card { height: 40px; background: #dcfce7; color: #14532d; }

/* Enhancement: from 600px there is room for a sidebar and two columns. */
@media (min-width: 600px) {
  .page { grid-template-columns: 180px 1fr; }
  .cards { grid-template-columns: 1fr 1fr; }
}
HTML
<div class="page">
  <nav class="side">Filters</nav>
  <div class="cards">
    <div class="card">Card 1</div><div class="card">Card 2</div>
    <div class="card">Card 3</div><div class="card">Card 4</div>
  </div>
</div>
Resultlive, in your browser
What’s happening
  1. Read the base CSS as "the phone layout". .page and .cards are grids with no columns, so each is a single column of full-width items: at a 360px viewport the sidebar and every card are 336px wide (360 minus the body's 12px margins), stacked.
  2. Nothing in the base CSS has to be undone later: there are no widths, floats or columns to reset. That's the point of mobile-first.
  3. @media (min-width: 600px) adds two column definitions. At 800px it matches: the page gets a 180px sidebar column plus 1fr (776 − 180 − 12 = 584px), and the cards split that into two columns of (584 − 8) / 2 = 288px, measured.
  4. The test runs the same demo at 360px and 800px. On this page the Result frame is as wide as the text column, and media queries inside the frame see the frame's width: narrow your browser window and the live result switches layouts at 600px.
  5. The desktop-first version of the same page would put the two-column rules in the base and add @media (max-width: 599px) { .page, .cards { grid-template-columns: none; } } to take them away, which is more CSS and a second place to keep in sync. The next example shows the two side by side.
AdvancedDesktop-first, for comparison

You'll meet desktop-first in older codebases, and an interviewer may ask you to convert one into the other. It produces the same pixels; the difference is which rules have to override which.

The same layout written desktop-first
CSS
/* Base: the wide layout. */
.page { display: grid; gap: 12px; grid-template-columns: 180px 1fr; }
.cards { display: grid; gap: 8px; grid-template-columns: 1fr 1fr; }
.side { padding: 8px; background: #fee2e2; color: #7f1d1d; }
.card { padding: 8px; height: 40px; background: #fef3c7; color: #78350f; }

/* Small screens: undo what the base did. */
@media (max-width: 599px) {
  .page, .cards { grid-template-columns: none; }
}
HTML
<div class="page">
  <nav class="side">Filters</nav>
  <div class="cards">
    <div class="card">Card 1</div><div class="card">Card 2</div>
    <div class="card">Card 3</div><div class="card">Card 4</div>
  </div>
</div>
Resultlive, in your browser
What’s happening
  1. The base CSS is now the wide layout, so a phone downloads and applies it first.
  2. Below 600px, @media (max-width: 599px) resets both grids with grid-template-columns: none, which gives back the single column: 336px at 360px, the same as the mobile-first version.
  3. At 800px the query doesn't match and the base layout shows: 180px sidebar, 288px cards, again identical.
  4. Every property set in the base and changed on mobile needs an "undo" in the query. On a real stylesheet that's dozens of resets (floats, widths, margins), and forgetting one is a classic phone-only bug. Note 599px rather than 600px: with old-style min-/max- queries you must avoid both matching at exactly 600px, which Media queries shows measured, and the range syntax solves.
Exactly on the breakpoint

With the mobile-first CSS above (@media (min-width: 600px)), which layout does a viewport of exactly 600px get? And one of 599px?

Show answer

600px gets the two-column layout; 599px gets one column. min-width is inclusive.

The breakpoint, measured
CSS
.cards { display: grid; gap: 8px; }
@media (min-width: 600px) { .cards { grid-template-columns: 1fr 1fr; } }
.cards > div { height: 30px; background: #dcfce7; }
HTML
<div class="cards"><div></div><div></div></div>
Resultlive, in your browser
What’s happening
  1. min-width: 600px means "the viewport is at least 600px wide", so it's true at 600px itself: the two cards share a row.
  2. At 599px it's false and the cards stack.
  3. In the range syntax this is written (width >= 600px), which reads the way it behaves.

#The viewport meta tag

Added

Early smartphones had to show a web built for desktops. Their trick: lay the page out in a virtual window about 980px wide (the layout viewport), then zoom out until it fits the screen. A desktop site looked like a tiny, complete desktop site, and you pinched to zoom. That default still applies to any page that doesn't opt out, which is why a page with perfect media queries can look tiny and unresponsive on a phone: at 980px, your max-width: 600px query never matches.

<meta name="viewport" content="width=device-width, initial-scale=1"> opts out. It tells the browser to make the layout viewport as wide as the device's screen in CSS pixels (360 or 390 or 430 on today's phones) and not to zoom. Without it, responsive CSS doesn't work on phones at all. With it, 1px in CSS is one CSS pixel on the device, and media queries see the real width.

This is the one topic in this chapter that the Result frames can't show live: desktop Chrome ignores the viewport meta tag, and the demo frames are desktop pages. The numbers below were measured separately in Chrome 155's mobile emulation (through the DevTools protocol, with a 360px-wide, 3x phone), not by this page's automated demo tests.

HTML
<head>
  <meta charset="utf-8">
  <!-- The standard line: lay out at the device's width, start unzoomed. -->
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Shop</title>
</head>
What’s happening
  1. Without the tag, Chrome's mobile emulation (360px screen) reported a layout viewport of innerWidth = 980, a visual-viewport scale of 0.367 (360 / 980), (width: 980px) matching and (max-width: 400px) not matching. The page is laid out for a 980px desktop and shrunk: 16px text appears about 5.9px tall.
  2. With the tag, the same emulated phone reported innerWidth = 360, scale 1, and (max-width: 400px) matching. Media queries now see the phone's real width.
  3. width=device-width sets the layout viewport to the screen's width in CSS pixels. initial-scale=1 sets the starting zoom to 100%.
  4. The tag goes in the <head> of every page (see The document skeleton: doctype, head and meta). Frameworks' starter templates include it, which is why people forget it exists until a hand-written page looks tiny on a phone.
AdvancedA simulation of what the phone does

To make the effect visible here, the demo below simulates a phone without the tag with a CSS transform: it lays out a page at 980px and scales it by 360 / 980, which is what the browser does. It's a picture of the behaviour, not the behaviour itself.

Simulated: a 980px layout shrunk onto a 360px screen, and the same page at 360px
CSS
.phone { width: 360px; height: 120px; overflow: hidden; outline: 2px solid #334155; margin-bottom: 12px; }
.no-meta { width: 980px; transform: scale(calc(360 / 980)); transform-origin: 0 0; }
.page { box-sizing: border-box; padding: 8px; background: #f8fafc; }
.page p { margin: 0; font-size: 16px; line-height: 20px; }
.page .bar { height: 16px; background: #93c5fd; margin-top: 4px; }
HTML
<div class="phone"><div class="page no-meta"><p>Without the tag: laid out at 980px, zoomed out.</p><div class="bar"></div></div></div>
<div class="phone"><div class="page"><p>With the tag: laid out at the phone's 360px.</p><div class="bar"></div></div></div>
Resultlive, in your browser
What’s happening
  1. .no-meta is laid out at 980px, like a phone's default layout viewport, then transform: scale(calc(360 / 980)) shrinks it to the 360px "screen": measured 360px wide.
  2. Everything shrinks by the same factor, so the 16px bar is 5.9px tall: that's how 16px body text looks on a phone visiting a page without the tag.
  3. The second phone shows the same page laid out at 360px, the width the viewport tag gives you: the bar is a true 16px.
AdvancedThe other values, and the one to avoid
content valueEffect
width=device-widthLayout viewport = the device's width in CSS pixels. Always use it.
initial-scale=1Start at 100% zoom.
maximum-scale=1, user-scalable=noStop the user zooming. Don't: it blocks people with low vision, fails WCAG 1.4.4 Resize Text, and MDN notes iOS 10+ ignores it by default anyway.
viewport-fit=coverLet the page extend under a notch or rounded corners; pair it with padding: env(safe-area-inset-left) and friends so content isn't hidden.
interactive-widget=resizes-contentMake the on-screen keyboard shrink the layout viewport (and with it vh, dvh and fixed elements), as Android Chrome did before version 108. The default, resizes-visual, only shrinks the visual viewport. Chrome on Android supports it; Safari on iOS does not, per caniuse in October 2026. See Viewport units on phones: vh vs svh, lvh and dvh.

#Media queries

Added

A media query is a condition about the device or the viewport; the CSS inside @media (…) { } applies only while it's true, and the browser re-checks it whenever things change (the window is resized, the phone rotates, the user switches to dark mode). The most used feature is width, and modern CSS has a range syntax for it that reads like maths: (width >= 600px), (400px <= width < 900px). It replaces min-width/max-width, and width < 600px excludes 600 cleanly where max-width couldn't. The range syntax is Baseline 2023 (Safari 16.4 was last, in March 2023) and widely available since September 2025, per the web-features data in October 2026.

Other features describe the device and the user's settings rather than the size: orientation, hover and pointer (is there a mouse, or only touch?), prefers-color-scheme (light or dark), prefers-reduced-motion, and more. Queries combine with and, , (a comma means "or"), not, and in Media Queries Level 4 a real or keyword.

The mental model: a media query is an if around a block of CSS, re-evaluated live, and inside the block the normal cascade still applies. A matching query doesn't add specificity; a rule inside @media competes with rules outside it by specificity and source order like any other.

Three ranges, written with the range syntax
CSS
.box { padding: 10px; color: #fff; font-weight: 600; }
.box::after { content: "small (width < 600px)"; }
.box { background: #2563eb; }

@media (600px <= width < 900px) {
  .box { background: #16a34a; }
  .box::after { content: "medium (600px to 899px)"; }
}
@media (width >= 900px) {
  .box { background: #b45309; }
  .box::after { content: "large (900px and up)"; }
}
HTML
<div class="box"></div>
Resultlive, in your browser
What’s happening
  1. The base rules (blue, "small") apply everywhere; the queries only override them, mobile-first.
  2. At 360px neither query is true, so the box is blue: computed rgb(37, 99, 235) and the ::after text "small".
  3. At 700px, (600px <= width < 900px) is true. Read it as "600 is at most the width, and the width is under 900". The box turns green (rgb(22, 163, 74)).
  4. At 1000px, (width >= 900px) matches and the box is amber. The medium query is false there because width < 900px fails, so the two ranges can't both match.
  5. The old equivalent of the medium range is (min-width: 600px) and (max-width: 899.98px); the test checks with matchMedia that it matches at exactly the same widths (only at 700 of the three). The .98 is the old workaround for the boundary problem in the next example.
AdvancedThe boundary bug that the range syntax fixes

min-width and max-width are both inclusive. A stylesheet with @media (max-width: 600px) for phones and @media (min-width: 600px) for tablets has a 1px-wide band, exactly 600px, where both match and their rules mix.

max-width: 600px and min-width: 600px at 599, 600 and 601px
CSS
.box { height: 40px; background: #e2e8f0; }
@media (max-width: 600px) { .old { border-left: 12px solid #dc2626; } }
@media (min-width: 600px) { .old { background: #bbf7d0; } }

@media (width < 600px) { .new { border-left: 12px solid #dc2626; } }
@media (width >= 600px) { .new { background: #bbf7d0; } }
HTML
<div class="box old">old syntax</div>
<div class="box new">range syntax</div>
Resultlive, in your browser
What’s happening
  1. The "phone" query adds a red left border; the "tablet" query adds a green background. On any one viewport you'd expect one or the other.
  2. At 599px only max-width: 600px matches (red border). At 601px only min-width: 600px matches (green). At exactly 600px, both are true, and the old box gets the red border and the green background: the test confirms both is true only at 600.
  3. With the range syntax, width < 600px and width >= 600px partition the widths: at 600 only the second is true. The test checks that exactly one matches at all three widths.
  4. The old fix was max-width: 599.98px. Widths can be fractional (browser zoom, high-density screens), so 599px would leave a gap between 599 and 600 where neither matches; .98 keeps the gap tiny. The range syntax makes the whole workaround unnecessary.
AdvancedUnits inside media queries: em means 16px

Relative units in a media query are relative to the initial font size, the browser's default (16px unless the user changed it), not the html element's font size. That surprises people who set html { font-size: 62.5% } and expect 40em to mean 400px.

html is 10px, but (min-width: 40em) is still 640px
CSS
html { font-size: 10px; }
.box { padding: 8px; background: #e2e8f0; }
@media (min-width: 40em) { .box { background: #c7d2fe; } }
HTML
<div class="box">Is the viewport at least 40em?</div>
Resultlive, in your browser
What’s happening
  1. html { font-size: 10px } makes 1rem and 1em (on the root) equal 10px for every property, as the test reads back.
  2. If the query used that size, 40em would be 400px and the box would be purple at both 500px and 700px.
  3. It's purple only at 700px. In a media query, em and rem use the initial value of font-size, so 40em is 40 × 16 = 640px.
  4. That's why some teams write breakpoints in em: if a reader raises their browser's default font size to 20px, an em breakpoint moves to 40 × 20 = 800px, so the layout switches earlier as text grows. That depends on the browser setting, which the test can't change, so only the 16px case is measured here.
AdvancedDevice and preference features
orientation, hover and pointer, colour scheme, reduced motion
CSS
.panel { display: grid; gap: 6px; }
.row { padding: 6px 8px; background: #f1f5f9; }

.orient::after { content: "landscape"; }
@media (orientation: portrait) { .orient::after { content: "portrait"; } }

/* Show the delete button only on hover, but only where hovering is possible. */
.card .actions { opacity: 1; }
@media (hover: hover) and (pointer: fine) {
  .card .actions { opacity: 0; }
  .card:hover .actions, .card:focus-within .actions { opacity: 1; }
}

.theme { background: #ffffff; color: #1f2937; }
@media (prefers-color-scheme: dark) { .theme { background: #1f2937; color: #f9fafb; } }

.spinner { width: 16px; height: 16px; border: 3px solid #93c5fd; border-top-color: #1d4ed8; border-radius: 50%; animation: spin 1s linear infinite; }
@keyframes spin { to { rotate: 1turn; } }
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
HTML
<div class="panel">
  <div class="row orient">This frame is </div>
  <div class="row card">A card <button class="actions">Delete</button></div>
  <div class="row theme">Follows your colour-scheme setting</div>
  <div class="row"><div class="spinner"></div></div>
</div>
Resultlive, in your browser
What’s happening
  1. orientation compares the viewport's width and height, not the device's: the test frame is 600px tall, so it's portrait at 360px wide and landscape at 800px. A desktop browser window that's taller than it is wide is "portrait" too. On this page the frame is only as tall as its content, so it reads "landscape".
  2. hover and pointer describe the primary input. Headless Chrome reports a mouse: (hover: hover) and (pointer: fine) are true, (pointer: coarse) false. A phone reports hover: none and pointer: coarse, so there the delete button is always visible; on a mouse device it's hidden (opacity: 0, measured) until the :hover or :focus-within rule shows it. The test can't hover, so it checks that the :hover rule exists and sets opacity: 1; hover the card in the live result to see it. For laptops with touch screens, any-hover and any-pointer ask about any input instead of the primary one.
  3. prefers-color-scheme reflects the user's (or the operating system's) light or dark setting. The test can't choose it, so it checks that the colours follow whatever Chrome reports. The live frame follows your browser; see Theming and dark mode with custom properties for doing it properly with variables.
  4. prefers-reduced-motion: reduce is the user asking for less motion (an operating-system accessibility setting). The spinner's animation-name is spin unless that's on, then none. See Respecting prefers-reduced-motion.
  5. The logic: a comma is "or" between whole queries, not negates a whole query (old syntax needs a media type: not all and (…)), and Level 4 adds or and not (…) inside a condition. All of these are checked with matchMedia, the JavaScript side of media queries, which also has a change event for reacting in code.
What colour at 600px?

With this CSS, what background does .box have at exactly 600px, and at 900px?

CSS
.box { background: blue; }
@media (min-width: 600px) { .box { background: green; } }
@media (max-width: 900px) { .box { background: red; } }
Show answer

Red at 600px, and red at 900px too.

The quiz, measured
CSS
.box { height: 30px; background: blue; }
@media (min-width: 600px) { .box { background: green; } }
@media (max-width: 900px) { .box { background: red; } }
HTML
<div class="box"></div>
Resultlive, in your browser
What’s happening
  1. At 600px both queries match (min-width and max-width are inclusive). Both rules have the same specificity (.box), so the later one in the source wins: red.
  2. At 900px, max-width: 900px still matches (inclusive), so again both match and red wins.
  3. Only from 901px is the second query false, and the box is green. Media queries don't add specificity, so when they overlap, order decides. Writing ranges that don't overlap (width < 900px) avoids the question entirely.

#Fluid layouts without media queries

Added

Media queries switch layouts at fixed points. A lot of responsive behaviour doesn't need switching at all: it needs layouts that adapt continuously to the space they're in. This is sometimes called intrinsic design: you give the browser rules ("at least 160px", "never wider than 600px", "wrap when you run out of room") and let it work out the layout for whatever width it gets.

The tools are ones you already know: flex-wrap with a flex-basis (items wrap when they no longer fit), auto-fill/auto-fit grids (repeat(auto-fill, minmax(…)), see repeat(), minmax() and auto-fill vs auto-fit), and the math functions min(), max() and clamp(). The big advantage over media queries is that these respond to the container, not the viewport: the same component works in a sidebar and in the main column without knowing where it is.

A sidebar layout that wraps by itself
CSS
.layout { display: flex; flex-wrap: wrap; gap: 10px; }
.side { flex: 1 1 160px; background: #e0e7ff; color: #312e81; }
.main { flex: 999 1 280px; min-width: 0; background: #dcfce7; color: #14532d; }
.side, .main { box-sizing: border-box; height: 60px; padding: 0 8px; }
HTML
<div class="layout">
  <aside class="side">Sidebar: 160px or more</aside>
  <main class="main">Main: 280px or more, takes the rest</main>
</div>
Resultlive, in your browser
What’s happening
  1. Both items ask for a minimum (flex-basis) of 160px and 280px. Side by side they need 160 + 10 + 280 = 450px.
  2. At 360px the container is only 336px, so they can't share a line: flex-wrap puts main on a second line, and each item grows to fill its own line: both measured 336px. That's the stacked mobile layout, with no media query.
  3. At 800px there's 776px, so both fit with 776 − 450 = 326px to spare. The spare space is shared by flex-grow, which is 1 for the sidebar and 999 for main: the sidebar gets 326 / 1000 ≈ 0.3px (measured 160.3px), main gets the rest (605.7px). Effectively the sidebar stays at its basis and main fills the row.
  4. box-sizing: border-box makes each flex-basis include the item's padding, so the arithmetic above is the whole story. The switch happens at whatever width the container hits 450px, whether it's the whole page or a panel inside it. min-width: 0 on main lets its content shrink and wrap instead of forcing overflow (see Why flex items overflow: min-width auto).
Advancedmin() and max() for widths and spacing

Two one-liners replace a lot of breakpoints. width: min(100%, 600px) is "as wide as the container, but never more than 600px": the same as width: 100%; max-width: 600px, in one declaration. padding: max(12px, 4vw) is "4% of the viewport width, but at least 12px": generous on big screens, never cramped on small ones.

A centred column with fluid padding
CSS
.wrap {
  box-sizing: border-box;
  width: min(100%, 500px);
  margin-inline: auto;
  padding: 10px max(12px, 4vw);
  background: #fef3c7;
}
.wrap p { margin: 0; height: 30px; background: #fde68a; }
HTML
<div class="wrap"><p>Content</p></div>
Resultlive, in your browser
What’s happening
  1. width: min(100%, 500px) picks the smaller value. At 360px, 100% is 336px, smaller than 500px, so the column fills the width. At 800px, 100% is 776px, so 500px wins and margin-inline: auto centres it (138px each side).
  2. padding-inline: max(12px, 4vw) picks the larger. At 360px, 4vw is 14.4px, just above the floor; at 800px it's 32px. Below 300px wide, 4vw would drop under 12px and the floor would take over.
  3. box-sizing: border-box keeps the padding inside the min() width, so the column never exceeds 100% (see box-sizing: content-box vs border-box).
  4. The computed width is still min(100%, 500px): the comparison is resolved at layout time, against whatever the container is, which is why it keeps working in any context. More in Math functions: calc(), min(), max() and clamp().
AdvancedThe switcher: all in a row, or all stacked

A cleverer pattern (popularised by the Every Layout book) switches a group from a row to a column at a container width you choose, with no media query. Each item gets flex-basis: calc((400px - 100%) * 999): when the container is wider than 400px, the result is hugely negative and clamps to 0, so the items share the row equally; when it's narrower, it's hugely positive, so each item takes a whole line.

Three items: row above 400px, stack below
CSS
.switcher { display: flex; flex-wrap: wrap; gap: 10px; }
.switcher > * { flex-grow: 1; flex-basis: calc((400px - 100%) * 999); height: 40px; background: #ede9fe; color: #4c1d95; padding: 0 8px; }
HTML
<div class="switcher"><div>One</div><div>Two</div><div>Three</div></div>
Resultlive, in your browser
What’s happening
  1. In flex-basis, 100% is the container's width. At 360px that's 336px, so the basis is (400 − 336) × 999 = 63,936px: far wider than the line, so every item wraps onto its own line and shrinks back to 336px.
  2. At 800px the container is 776px, so the basis is (400 − 776) × 999, a large negative number. A negative flex-basis is clamped to 0, so all three items start at zero and flex-grow: 1 shares the line: (776 − 20) / 3 = 252px each.
  3. There's no in-between state with two items on one line and one on the next, which is the usual flex-wrap problem this trick avoids. The threshold (400px) is a container width, so the same component switches in different places on different pages.
What does min() give?

A card has width: min(90%, 400px). Its container is 300px wide on a phone and 1000px on a desktop. How wide is the card in each?

Show answer

270px on the phone and 400px on the desktop.

What’s happening
  1. min() returns the smaller of its arguments, resolved against the container at layout time.
  2. Phone: 90% of 300px is 270px, smaller than 400px → 270px.
  3. Desktop: 90% of 1000px is 900px, larger than 400px → 400px. The centred-column demo above measures the same mechanism with min(100%, 500px).

#Fluid typography with clamp()

Added

Fixed font sizes force a choice: big headings that wrap badly on phones, or small ones that look timid on desktops, or a stack of media queries stepping the size up. Fluid typography scales the size smoothly with the viewport, between a minimum and a maximum, with one declaration: font-size: clamp(MIN, PREFERRED, MAX).

clamp(a, b, c) is max(a, min(b, c)): use b, but never below a or above c. The preferred value is usually a rem plus a vw, like 0.5rem + 2vw, which describes a straight line: the font size grows by 2% of the viewport width, starting from 0.5rem. The minimum and maximum flatten the line at both ends. So the mental model is a graph: flat at the minimum on small screens, a ramp in the middle, flat at the maximum on big screens.

clamp(1rem, 0.5rem + 2vw, 2rem) at six widths
CSS
.title { font-size: clamp(1rem, 0.5rem + 2vw, 2rem); line-height: 1.2; margin: 0; }
HTML
<h2 class="title">Fluid heading</h2>
Resultlive, in your browser
What’s happening
  1. 1rem is 16px (the root font size), so the limits are 16px and 32px, and the preferred value is 8px + 2vw.
  2. At 360px the preferred value is 8 + 7.2 = 15.2px, below the minimum, so clamp() returns 16px. At 400px it's exactly 8 + 8 = 16px: the ramp starts here.
  3. On the ramp: at 800px, 8 + 16 = 24px; at 1000px, 8 + 20 = 28px. Measured exactly, as computed font-size.
  4. At 1200px it's 8 + 24 = 32px, the maximum: the ramp ends. At 1400px the preferred value would be 36px, so it's clamped to 32px.
  5. The numbers weren't arbitrary. Choose "16px at a 400px viewport, 32px at 1200px", then slope = (32 − 16) / (1200 − 400) = 0.02 → 2vw, and intercept = 16 − 0.02 × 400 = 8px → 0.5rem. Online "fluid type calculators" do exactly this arithmetic.
AdvancedTip: component-relative type with cqi
AdvancedWhy the rem part matters: zoom and font-size settings

font-size: 4vw alone is fluid too, but it ignores the reader. A reader who sets their browser's default font size to 20px expects text to get bigger; rem scales with that setting, vw doesn't. Browser zoom has the same problem: zooming in makes CSS pixels bigger, so the viewport is fewer CSS pixels wide and 4vw gets smaller in CSS pixels, cancelling the zoom, which can fail WCAG 1.4.4 (text must be resizable to 200%). Keeping a rem term in the preferred value, and rem limits, keeps text responsive to both.

A 20px default font size: rem-based clamp vs vw only
CSS
/* Simulates a reader whose browser default font size is 20px instead of 16px. */
html { font-size: 20px; }
.clamped { font-size: clamp(1rem, 0.5rem + 2vw, 2rem); }
.vw-only { font-size: 3vw; }
.crossed { font-size: clamp(30px, 1vw, 20px); }
p { margin: 0 0 6px; }
HTML
<p class="clamped">clamp(1rem, 0.5rem + 2vw, 2rem)</p>
<p class="vw-only">3vw</p>
<p class="crossed">clamp(30px, 1vw, 20px)</p>
Resultlive, in your browser
What’s happening
  1. Setting html { font-size: 20px } here stands in for the browser preference, because that preference is what sets the root font size. (The test can't change the real setting or press zoom, so this is the closest honest check.)
  2. The clamped heading now computes 0.5rem + 2vw = 10 + 16 = 26px at 800px, instead of 24px with the default 16px root: it grew with the reader's setting, and its limits moved to 20px and 40px.
  3. 3vw is 24px at 800px no matter what the reader prefers.
  4. clamp(30px, 1vw, 20px) has its minimum above its maximum. That's not an error: max(30px, min(1vw, 20px)) evaluates to 30px, so the minimum wins. Worth knowing when a calculator produces a crossed pair.
Work out the size

With font-size: clamp(1rem, 0.5rem + 2vw, 2rem) and the default 16px root, what's the font size at a 1000px viewport? At 300px?

Show answer

28px at 1000px, 16px at 300px.

What’s happening
  1. At 1000px: 0.5rem + 2vw = 8px + 20px = 28px, which is between 16px and 32px, so it's used as is. The main example's test measures 28px at 1000px.
  2. At 300px: 8 + 6 = 14px, below the 16px minimum, so it's clamped up to 16px.

#Container queries

Added

Media queries ask about the viewport, but a component doesn't know where it will be placed. A product card might sit in a wide main column on one page and a narrow sidebar on another, on the same 1400px screen. A media query can't tell those apart. A container query asks about the size of an ancestor element, the container, so the card can switch to its wide layout when its container is wide, wherever that is.

It takes two steps. Mark an ancestor as a container with container-type: inline-size (and optionally container-name), then write @container (min-width: 400px) { … }: the rules inside apply to elements whose nearest container is at least 400px wide. Container size queries and their units (cqi, cqw and so on) are Baseline 2023 (Firefox 110 was last, in February 2023) and widely available since August 2025, per the web-features data in October 2026.

The mental model: media queries are for page layout, container queries are for components. A component styled with container queries is truly reusable: drop it anywhere and it adapts to the space it's given.

The same card in a 260px and a 520px container
CSS
.slot { container-type: inline-size; margin-bottom: 12px; }
.narrow { width: 260px; }
.wide { width: 520px; }

.card { display: grid; gap: 8px; background: #f8fafc; outline: 1px solid #cbd5e1; padding: 8px; }
.card .img { height: 80px; background: linear-gradient(135deg, #93c5fd, #c4b5fd); }
.card .text { background: #e2e8f0; height: 40px; }

@container (min-width: 400px) {
  .card { grid-template-columns: 140px 1fr; }
}
HTML
<div class="slot narrow"><div class="card"><div class="img"></div><div class="text">Title and price</div></div></div>
<div class="slot wide"><div class="card"><div class="img"></div><div class="text">Title and price</div></div></div>
Resultlive, in your browser
What’s happening
  1. Both slots are containers (container-type: inline-size) in the same 640px viewport, so a media query would treat the two cards identically. The test confirms the viewport is 640px for both.
  2. @container (min-width: 400px) is evaluated separately for each card, against its nearest container ancestor: the .slot it's in.
  3. The narrow slot is 260px, so the query is false: the card stays a single column, the image is 244px wide (260 minus 16px of padding) and the text sits below it.
  4. The wide slot is 520px, so the query is true: the card gets grid-template-columns: 140px 1fr, the image is 140px wide and the text starts 148px to its right (140 plus the 8px gap) on the same row.
  5. Notice where things are: container-type is on the slot, the query styles the card inside it. An element can't query its own size, because its styles would change its size, which would change the query result: a loop. That's why the container is always an ancestor.
AdvancedNamed containers and the nearest-container rule

An unnamed @container query uses the nearest ancestor that is a container. When containers are nested (a card inside a grid cell inside a sidebar), give them names with container-name and target one explicitly: @container sidebar (min-width: 300px). The shorthand container: sidebar / inline-size sets both.

A 200px container inside a 500px one
CSS
.outer { container: page / inline-size; width: 500px; background: #f1f5f9; padding: 8px; }
.inner { container-type: inline-size; width: 200px; background: #e2e8f0; padding: 8px; }
.label { padding: 6px; background: #fff; }
.padded { container-type: inline-size; width: 280px; padding: 20px; margin-top: 8px; background: #f1f5f9; }

@container (min-width: 300px) { .nearest, .inside-padded { background: #bbf7d0; } }
@container page (min-width: 300px) { .named { background: #bbf7d0; } }
HTML
<div class="outer"><div class="inner">
  <div class="label nearest">Unnamed query: nearest container (200px)</div>
  <div class="label named">Named query: the page container (500px)</div>
</div></div>
<div class="padded"><div class="label inside-padded">280px content + 40px padding: not 300px</div></div>
Resultlive, in your browser
What’s happening
  1. .outer is a container named page with a 500px content box. .inner is an unnamed container 200px wide inside it.
  2. Both labels are inside .inner. The unnamed query @container (min-width: 300px) uses the nearest container, .inner at 200px: false, so .nearest stays white.
  3. The named query @container page (min-width: 300px) skips .inner (it isn't called page) and measures .outer: true, so .named turns green.
  4. The test also reads back the shorthand: container: page / inline-size set container-name: page and container-type: inline-size. Naming containers in component libraries avoids accidentally querying whatever container happens to be closest.
  5. A query measures the container's content box. .padded is 320px on screen (280 plus 20px padding each side), but its query sees 280px, so (min-width: 300px) is false and its label stays white.
AdvancedContainer query units

Inside a container, cqi is 1% of the container's inline size (its width in horizontal writing), cqb 1% of its block size, and cqw, cqh, cqmin, cqmax follow the same pattern as the viewport units. They're how you scale type and spacing with a component. With no container ancestor, they fall back to the small viewport units (svw, svh), which on a desktop are the same as vw and vh.

5cqi in two containers, and with no container
CSS
.slot { container-type: inline-size; margin-bottom: 8px; background: #fef3c7; }
.a { width: 240px; }
.b { width: 480px; }
.heading { font-size: clamp(14px, 5cqi, 24px); margin: 0; }
.bar { height: 8px; width: 10cqi; background: #f59e0b; }
HTML
<div class="slot a"><p class="heading">Scales with the card</p></div>
<div class="slot b"><p class="heading">Scales with the card</p></div>
<div class="bar"></div>
Resultlive, in your browser
What’s happening
  1. In the 240px container, 5cqi is 12px, below the 14px minimum, so the heading is 14px.
  2. In the 480px container, 5cqi is 24px, right at the maximum: 24px.
  3. .bar has no container ancestor, so 10cqi falls back to 10svw: 10% of the 640px test viewport, 64px. (On a desktop svw and vw are equal, so this shows the fallback is the viewport, not which viewport.)
  4. Same CSS, three sizes, decided by where the element is. That's what makes cqi the component-level counterpart of vw (see Fluid typography with clamp()).
AdvancedGotcha: a container can't be sized by its content

container-type: inline-size applies size containment in the inline direction: the container's width is calculated as if it had no children, so its children's sizes can't feed back into it (that's what breaks the loop). If the container's width would normally come from its content (an inline-block, a float, a flex item with flex: none, a grid item in an auto column), it collapses to zero. container-type: size contains both directions, so it also needs an explicit height.

Three containers that collapse
CSS
.case { min-height: 60px; margin-bottom: 8px; border-bottom: 1px dashed #cbd5e1; }
.ib { display: inline-block; container-type: inline-size; padding: 4px; background: #fecaca; }
.row { display: flex; }
.row > .item { flex: none; container-type: inline-size; background: #fecaca; }
.both { container-type: size; background: #fecaca; }
.fixed { container-type: inline-size; width: 200px; background: #bbf7d0; }
HTML
<div class="case"><span class="ib"><span>inline-block</span></span></div>
<div class="case row"><div class="item">flex item</div></div>
<div class="case"><div class="both">size, no height</div></div>
<div class="case"><div class="fixed">explicit width: fine</div></div>
Resultlive, in your browser
What’s happening
  1. The inline-block would normally be as wide as its text. With inline-size containment its content is ignored for sizing, so it's just its padding: 8px. The text overflows it.
  2. The flex item with flex: none is sized from its content too, so it collapses to 0px wide.
  3. container-type: size contains the block direction as well, so the div with no height is 0px tall. Use size only for elements whose height is set (a full-height panel), and inline-size for almost everything else.
  4. The fix is a width that doesn't depend on the content: block-level elements that fill their parent (the normal case), stretched grid or flex items, or an explicit width, as in the last div (200px). If a component "disappears" after you add container-type, this is why.
AdvancedStyle queries

@container style(--theme: dark) { … } queries the computed value of a custom property on the container instead of its size. Any element is a style container (no container-type needed), so a parent can switch a whole subtree's styling by setting one variable, and the variable can itself be set by a class, a media query or inline style. Only custom properties can be queried. Style queries for custom properties became Baseline newly available in May 2026, when Firefox 151 shipped them (Chrome and Edge had them since 111, Safari since 18), per the web-features data in October 2026, so they aren't yet "widely available".

Switching a card's look from its parent
CSS
.slot { margin-bottom: 8px; }
.card { padding: 8px; background: #f1f5f9; color: #0f172a; }
@container style(--variant: promo) {
  .card { background: #be185d; color: #fff; }
}
/* A normal property in a style query: not supported in Chrome 155. */
@container style(color: rgb(0, 0, 255)) {
  .plain-prop { background: #be185d; }
}
HTML
<div class="slot"><div class="card">Normal card</div></div>
<div class="slot" style="--variant: promo"><div class="card">Promo card</div></div>
<div class="slot" style="color: rgb(0, 0, 255)"><div class="card plain-prop">Parent color: blue</div></div>
Resultlive, in your browser
What’s happening
  1. Neither slot has container-type: for style queries every element counts as a container.
  2. @container style(--variant: promo) tests the nearest ancestor's computed --variant. The first slot doesn't set it, so the first card keeps the default grey (rgb(241, 245, 249)).
  3. The second slot sets --variant: promo inline, so the query matches for its card, which turns pink (rgb(190, 24, 93)).
  4. The third slot sets an ordinary property, color, and a query for style(color: …) doesn't match in Chrome 155: only custom properties can be queried today.
  5. This is useful for theming components from outside without adding modifier classes to every child. Because support across all engines only arrived in 2026, keep the default look usable without it.
Media or container?

A card sits in a 380px sidebar on a viewport 640px wide. Its CSS has @media (min-width: 600px) { .card { border-color: red; } } and @container (min-width: 400px) { .card { background: yellow; } }, and the sidebar has container-type: inline-size. Is the card red-bordered, yellow, both or neither?

Show answer

Red-bordered but not yellow: the media query sees the 640px viewport, the container query sees the 380px sidebar.

The quiz, measured
CSS
.sidebar { container-type: inline-size; width: 380px; }
.card { border: 4px solid #94a3b8; background: #fff; height: 30px; }
@media (min-width: 600px) { .card { border-color: red; } }
@container (min-width: 400px) { .card { background: yellow; } }
HTML
<div class="sidebar"><div class="card"></div></div>
Resultlive, in your browser
What’s happening
  1. @media (min-width: 600px) asks about the viewport, 640px in the test: true, so the border is red.
  2. @container (min-width: 400px) asks about the card's nearest container, the 380px sidebar: false, so the background stays white.
  3. That's the whole difference: the card in the sidebar should usually get its narrow layout even on a big screen, which only a container query can express.

#Responsive images: srcset, sizes and picture

Added

An <img src="photo.jpg"> sends every visitor the same file, so you either send a 2000px photo to a 360px phone (slow, wasted data) or a 400px photo to a 27-inch 2x monitor (blurry). Responsive images let you list several files and let the browser pick the best one for the slot's width, the screen's pixel density and its own conditions (cache, data saver).

There are two separate problems with two tools. Resolution switching, the same picture at different sizes, uses srcset with width descriptors (photo-400.jpg 400w) plus a sizes attribute that tells the browser how wide the image will be displayed, before the CSS has loaded. The browser multiplies that width by the device pixel ratio and picks a file. For fixed-size images like logos, density descriptors (logo.png 1x, logo@2x.png 2x) are enough. Art direction, a different crop or image at different widths, and format fallback (AVIF, then WebP, then JPEG) use <picture> with <source> elements, where the browser takes the first source whose media and type match. srcset, sizes and <picture> have all been Baseline widely available for years (per the web-features data, October 2026).

The key mental model: srcset and sizes are hints, <picture> media is a command. With srcset the browser is allowed to choose (it may take a larger file it already has, or a smaller one on a slow connection); with <picture> the first matching <source> must be used.

The demos below load small images that print their own file name and size, so each Result frame shows which candidate your browser actually chose (try a phone, or zoom the page to change the pixel ratio). The test reads img.currentSrc, the URL of that candidate, and runs at a device pixel ratio of 1; your screen may pick a bigger file.

One srcset, three different sizes attributes
CSS
img { display: block; margin-bottom: 6px; background: #e0e7ff; color: #312e81; font-size: 13px; }
.thumb { width: 200px; height: 100px; }
.hero { width: 100%; height: 80px; }
HTML
<img class="thumb" alt="200px thumbnail"
     srcset="/demo/photo-400.jpg 400w, /demo/photo-800.jpg 800w, /demo/photo-1600.jpg 1600w"
     sizes="200px" src="/demo/photo-800.jpg">
<img class="hero" alt="full-width hero"
     srcset="/demo/photo-400.jpg 400w, /demo/photo-800.jpg 800w, /demo/photo-1600.jpg 1600w"
     sizes="100vw" src="/demo/photo-800.jpg">
<img class="half" alt="half width on wide screens" width="300" height="80"
     srcset="/demo/photo-400.jpg 400w, /demo/photo-800.jpg 800w, /demo/photo-1600.jpg 1600w"
     sizes="(min-width: 800px) 50vw, 100vw" src="/demo/photo-800.jpg">
Resultlive, in your browser
What’s happening
  1. All three images offer the same three files and say how wide each file is: 400w, 800w, 1600w. The browser never downloads them to find out; it trusts the descriptors.
  2. sizes tells it the display width. The thumbnail says 200px. At a pixel ratio of 1 it needs 200 image pixels, and the smallest file that covers that is photo-400.jpg, which is what currentSrc reports at both widths.
  3. The hero says 100vw. At 360px it needs 360 pixels: photo-400.jpg. At 1000px it needs 1000: photo-800.jpg is too small, so it takes photo-1600.jpg.
  4. The third uses a media condition in sizes: "50vw from 800px, otherwise 100vw". The first condition that matches wins, and the last entry has no condition (it's the default). At 360px that's 100vw = 360px → photo-400.jpg; at 1000px it's 50vw = 500px → photo-800.jpg.
  5. sizes must describe the real CSS width, and it's your job to keep them in sync, because the browser picks the file before layout. If sizes is missing, it defaults to 100vw, which on a page of thumbnails makes every phone download hero-sized files. src is the fallback for browsers that don't understand srcset (today, effectively none).
AdvancedHigher pixel densities and density descriptors

On a 2x or 3x screen the browser multiplies the slot width by the pixel ratio. The headless test browser is fixed at 1x, so the values in this table were measured separately in Chrome 155 with device emulation (through the DevTools protocol), not by the page's demo tests:

Image1x2x3x
sizes="200px", files 400w / 800w / 1600w, 360px viewport400w400w (exactly 2 × 200)800w (needs 600)
sizes="(min-width: 800px) 50vw, 100vw", files 480w / 960w, 1000px viewport960w960wnot measured
srcset="logo-1x.png 1x, logo-2x.png 2x"1x2x2x

In every case measured, Chrome 155 took the smallest file that covers slot width × pixel ratio, including at in-between ratios (2.5, 2.75 and 2.9 all chose photo-800.jpg for the 200px slot), unless a larger candidate was already cached (see the gotcha below). The specification leaves the choice to the browser, so other browsers, or Chrome on a slow connection, may pick differently: don't write tests or server logic that depend on one particular pick.

Density descriptors for a fixed-size logo
CSS
img { display: block; width: 120px; height: 40px; background: #fef3c7; color: #78350f; font-size: 12px; }
HTML
<img alt="logo" srcset="/demo/logo-1x.png 1x, /demo/logo-2x.png 2x" src="/demo/logo-1x.png">
Resultlive, in your browser
What’s happening
  1. 1x and 2x describe files for screens of that pixel density; no sizes is needed, because the image is always displayed at the same CSS size (120 × 40).
  2. The test browser's pixel ratio is 1, so it picks logo-1x.png. On a 2x phone it takes logo-2x.png (measured in emulation, table above).
  3. Use x descriptors for icons, logos and avatars with a fixed CSS size; use w descriptors with sizes when the display size varies with the layout.
Advancedpicture: art direction and formats
A different crop below 600px, and AVIF where supported
CSS
img { display: block; width: 100%; height: 70px; margin-bottom: 6px; background: #dcfce7; color: #14532d; font-size: 13px; }
HTML
<picture>
  <source media="(min-width: 600px)" srcset="/demo/hero-wide.jpg">
  <img class="art" alt="hero image, art-directed" src="/demo/hero-square.jpg">
</picture>
<picture>
  <source type="image/avif" srcset="/demo/hero.avif">
  <source type="image/webp" srcset="/demo/hero.webp">
  <img class="fmt" alt="hero image, best format" src="/demo/hero.jpg">
</picture>
Resultlive, in your browser
What’s happening
  1. A <picture> contains zero or more <source> elements and one <img>, which is required: it's the element that actually renders, carries the alt, the size and the CSS, and its src is the fallback. The sources only decide which URL that <img> loads; you style the <img>, not the <picture>.
  2. In the first picture, the source has media="(min-width: 600px)". At 800px it matches, so the <img> loads hero-wide.jpg; at 360px no source matches and the <img>'s own src, hero-square.jpg, is used. Unlike sizes, this isn't a hint: the browser must use the first matching source.
  3. In the second, the sources differ by type. The browser takes the first whose MIME type it supports, without downloading anything to check. Chrome supports AVIF, so it picks hero.avif; a browser without AVIF would try WebP, and the oldest would fall back to hero.jpg. Order matters: list the smallest, newest format first.
  4. Each <source> can also have its own srcset with w descriptors and sizes, so art direction and resolution switching combine. Add width and height attributes to the <img> so the browser reserves space before the file arrives (see Images, figure and media).
Advancedsizes="auto" for lazy images

For a lazy-loaded image the browser knows the layout before it fetches the file, so it can work out sizes itself. sizes="auto" (only honoured together with loading="lazy") does that. It's in Chrome and Edge 126+ and Firefox 150+, but not in Safari as of October 2026, per the web-features data; list a fallback after it, like sizes="auto, 100vw", for browsers that don't support it.

sizes="auto" picks from the rendered width
CSS
img { display: block; width: 200px; height: 100px; background: #ede9fe; color: #4c1d95; font-size: 13px; }
HTML
<img alt="lazy thumbnail" loading="lazy" sizes="auto, 100vw" width="200" height="100"
     srcset="/demo/photo-400.jpg 400w, /demo/photo-800.jpg 800w, /demo/photo-1600.jpg 1600w" src="/demo/photo-800.jpg">
Resultlive, in your browser
What’s happening
  1. The image is 200px wide in CSS and lazy. With sizes="auto" Chrome uses that rendered width as the slot size: it needs 200 pixels at 1x and picks photo-400.jpg.
  2. If auto weren't understood, the fallback 100vw (640px in the test) would have picked photo-800.jpg, so the result shows that auto was used.
Which file?

<img srcset="/demo/hero-480.jpg 480w, /demo/hero-960.jpg 960w" sizes="(min-width: 800px) 50vw, 100vw" src="/demo/hero-480.jpg"> at a viewport of 360px and of 1000px, both at a pixel ratio of 1. Which file is loaded in each?

Show answer

hero-480.jpg at 360px, and hero-960.jpg at 1000px.

The quiz, measured
CSS
img { display: block; width: 100%; height: 60px; background: #e0f2fe; }
@media (min-width: 800px) { img { width: 50%; } }
HTML
<img alt="hero" srcset="/demo/hero-480.jpg 480w, /demo/hero-960.jpg 960w" sizes="(min-width: 800px) 50vw, 100vw" src="/demo/hero-480.jpg">
Resultlive, in your browser
What’s happening
  1. At 360px, (min-width: 800px) is false, so the slot is 100vw = 360px. hero-480.jpg covers 360 pixels → chosen.
  2. At 1000px the slot is 50vw = 500px. hero-480.jpg would be 480 pixels for a 500px slot, slightly too few, so Chrome takes hero-960.jpg.
  3. On a 2x screen at 1000px it would need 1000 pixels and also takes hero-960.jpg (the largest available; measured in emulation, see the table above).

#Viewport units on phones: vh vs svh, lvh and dvh

Added

On a desktop, the viewport is the window's content area and 100vh is simply its height. On a phone it's more complicated, because the browser's own interface, the address bar and toolbar, slides away as you scroll down and comes back as you scroll up. The visible area has two heights, and a third that changes while you scroll:

  • Large viewport: the height with the browser UI retracted (the most you'll ever see). Units lvh, lvw.
  • Small viewport: the height with the UI fully shown (what you see when the page first loads). Units svh, svw.
  • Dynamic viewport: whatever is visible right now, somewhere between the two. Units dvh, dvw.

Plain vh is the large viewport. MDN (October 2026) states that all the default viewport units are currently equivalent to their large counterparts in browsers. So a height: 100vh hero on a phone is taller than the screen when the page loads, with the address bar showing: its bottom edge, and any "scroll down" arrow or button you put there, is hidden under the toolbar. That's the famous "100vh is broken on mobile" bug, and these units (Baseline since December 2022, widely available since June 2025, per web-features) are the fix.

What can and can't be shown here: the Result frames on this page and the headless browser that tests them are desktop viewports with no retracting toolbar, so all four units are equal in them. The demo below proves exactly that much, and that the units resolve against the viewport's height. The difference between them on a phone can only be seen on a phone (or in a mobile browser's device mode that simulates the toolbar), so this page doesn't claim to measure it.

100vh, 100svh, 100lvh and 100dvh on a desktop viewport
CSS
.bar { position: fixed; top: 0; width: 40px; color: #fff; font-size: 11px; text-align: center; }
.vh { left: 12px; height: 100vh; background: #64748b; }
.svh { left: 60px; height: 100svh; background: #2563eb; }
.lvh { left: 108px; height: 100lvh; background: #16a34a; }
.dvh { left: 156px; height: 100dvh; background: #b45309; }
.note { margin-left: 200px; min-height: 80px; }
HTML
<div class="bar vh">vh</div><div class="bar svh">svh</div><div class="bar lvh">lvh</div><div class="bar dvh">dvh</div>
<p class="note">Each bar is 100 of one unit tall, and fixed to the frame, so it measures the frame without adding to its height. On a desktop all four are the same.</p>
Resultlive, in your browser
What’s happening
  1. Each bar is position: fixed with top: 0 and a height of 100 of one unit, so it measures the viewport without becoming part of the page's height.
  2. The test frame's viewport is 600px tall (innerHeight), and all four bars are measured at 600px: on a desktop there's no retracting UI, so small, large and dynamic viewports are the same size.
  3. Chrome 155 understands the whole family: svh, lvh, dvh, the width versions (svw), the logical versions (dvi for the inline axis, svb for block), and svmin/svmax. CSS.supports returns true for each.
  4. On a phone, with the page just loaded, the vh and lvh bars would extend below the visible area by the height of the browser UI, svh would fit exactly, and dvh would fit and then grow as the UI slides away. That's documented behaviour (MDN, web.dev); it isn't measured here.
AdvancedPitfall: the on-screen keyboard
AdvancedNote: 100vw on desktops with scrollbars
AdvancedChoosing svh, dvh or lvh
UseForWhy
100svhHero sections, splash screens, anything that must fit when the page loadsNever hidden behind the browser UI; stable, so nothing jumps while scrolling
100dvhApp-like full-screen layouts (a chat app with a fixed input at the bottom, a map)Always exactly the visible area. But it changes while the user scrolls, which re-runs layout; MDN warns this can cause content to resize mid-scroll and a performance hit, and web.dev notes the updates are throttled, not every frame
100lvh / 100vhBackgrounds that should cover the screen even when the UI retractsBiggest; overflowing under the toolbar doesn't matter for a background

A common, safe pattern is to write the old unit first as a fallback and the new one second: min-height: 100vh; min-height: 100svh;. A browser that doesn't know svh drops the second declaration and keeps the first. And prefer min-height to height for full-screen sections, so content that's taller than the screen can still grow.

Why does the button disappear?

A landing page has .hero { height: 100vh; display: flex; flex-direction: column; justify-content: flex-end; } with a "Get started" button at the bottom. On desktop it's perfect. On phones, people say the button isn't there until they scroll. Why, and what's the fix?

Show answer

100vh is the large viewport, measured with the browser UI hidden. When the page loads, the UI is showing, so the hero is taller than the visible area and its bottom edge, with the button, is under the toolbar. Use min-height: 100svh (with min-height: 100vh before it as a fallback).

What’s happening
  1. On page load the address bar and toolbar are visible, so the visible height is the small viewport.
  2. 100vh equals 100lvh, the height with the UI retracted, which is larger by the height of the browser UI. The hero overflows the visible area by that much.
  3. justify-content: flex-end puts the button at the hero's bottom edge, which is exactly the part that's hidden.
  4. 100svh is the small viewport, so the hero fits on load and the button is visible. 100dvh would also fit on load but would resize as the user scrolls. min-height instead of height lets the hero grow if its content needs more room on a short screen.

Written fresh for interviews (there was no Google Doc for this one). Every live example was checked in headless Chrome 155 by scripts/check-demos.mjs.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.