01 / 12
All 12 in 12 Tailwind CSS Full Page Sections
- 01Tailwind Scroll Snap Full Screen Sections with 100dvh
- 02Tailwind Full Page Scroll Snap Without JavaScript
- 03Tailwind Scroll Snap Stop to Prevent Skipping Sections
- 04Tailwind Sticky Stacking Cards on Scroll
- 05Tailwind Full Page Sections with Sticky Dot Navigation
- 06Tailwind Scroll Spy Active Nav Link Without JavaScript
- 07Tailwind Parallax Scroll Sections with Scroll-Driven Animations
- 08Tailwind Full Page Section Reveal and Scale on Scroll
- 09Tailwind Full Page SaaS Product Feature Showcase
- 10Tailwind Full Screen Portfolio Gallery Scroll Sections
- 11Tailwind Horizontal Scroll Snap Full Screen Panels
- 12Accessible Tailwind Scroll Snap with Keyboard Navigation
Tailwind Scroll Snap Full Screen Sections with 100dvh
The reference build for the collection, ported whole into utilities: four snapping full-viewport panels sized with min-h-dvh, one owned snap-y snap-mandatory scrollport, and a fourth panel deliberately built on min-h-screen so you can see the iOS bug it causes. A device diagram shows what the collapsing address bar does to vh, and a table settles dvh vs svh vs lvh in Tailwind's own vocabulary. Zero JavaScript.
Complete Tailwind CSS landing page templates in HTML, React, Next.js and Vue · from $49
Cruip · Affiliate link
The code
<div class="tfps-01 relative isolate w-full min-h-svh bg-tfps01-bg font-tfps01-sans text-tfps01-ink antialiased [&_code]:font-tfps01-mono [&_code]:text-[.92em]">
<input class="peer sr-only" type="checkbox" id="tfps-01-theme">
<label for="tfps-01-theme" class="fixed top-[1.15rem] right-[1.15rem] z-80 grid size-11 cursor-pointer place-items-center rounded-full border border-tfps01-ink/10 bg-white/60 text-tfps01-ink hover:bg-white/85 tfps01-dark:bg-white/[.07] tfps01-dark:hover:bg-white/15 shadow-[0_8px_24px_-12px_var(--color-tfps01-shade)] backdrop-blur-[10px] backdrop-saturate-150 transition-[background-color,scale] duration-200 active:scale-95 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-3 peer-focus-visible:outline-tfps01-acc forced-colors:border-[CanvasText]">
<svg class="size-[1.05rem] tfps01-dark:hidden" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><path d="M12 2.6v2.3M12 19.1v2.3M2.6 12h2.3M19.1 12h2.3M5.3 5.3l1.7 1.7M17 17l1.7 1.7M18.7 5.3L17 7M7 17l-1.7 1.7" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/><circle cx="12" cy="12" r="4.4" stroke="currentColor" stroke-width="1.7"/></svg>
<svg class="hidden size-[1.05rem] tfps01-dark:block" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><path d="M20.5 14.4A8.4 8.4 0 0 1 9.6 3.5 8.6 8.6 0 1 0 20.5 14.4Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>
<span class="sr-only">Switch light or dark theme</span>
</label>
<div class="h-dvh overflow-x-hidden overflow-y-auto snap-y snap-mandatory scroll-smooth overscroll-y-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tfps01-acc [@media(max-height:40rem)]:snap-proximity motion-reduce:snap-none motion-reduce:scroll-auto" tabindex="0" role="group" aria-label="Full viewport section scroller, four panels">
<section class="relative flex min-h-dvh snap-start items-center justify-center bg-tfps01-panel px-[clamp(1.25rem,5vw,4.5rem)] pt-[clamp(2rem,5vh,3.75rem)] pb-[calc(clamp(2rem,5vh,3.75rem)+4.5rem)] even:bg-tfps01-panel2 not-first:-mt-18 not-first:rounded-t-[2rem] not-first:border-t not-first:border-tfps01-ink/10 not-first:shadow-[0_-30px_64px_-34px_var(--color-tfps01-shade)] forced-colors:border-[CanvasText]" id="tfps-01-s1" aria-labelledby="tfps-01-h1">
<div class="grid w-full max-w-[64rem] justify-items-start gap-[clamp(.95rem,2.3vh,1.5rem)] transition-[opacity,translate] duration-700 ease-out starting:translate-y-4 starting:opacity-0 motion-reduce:transition-none">
<p class="flex flex-wrap items-center gap-3"><span class="font-tfps01-mono text-[.7rem] font-medium tracking-[.14em] uppercase text-tfps01-acc">01 / 04</span><span class="rounded-[.45rem] border border-tfps01-ink/12 px-2 py-[.22rem] font-tfps01-mono text-[.7rem] text-tfps01-ink/75">min-h-dvh</span></p>
<h2 class="max-w-[22ch] text-[clamp(2.1rem,6.2vw,4.5rem)] leading-[1.02] font-semibold tracking-[-.042em] text-balance" id="tfps-01-h1">Panels that fill the screen — and keep filling it</h2>
<p class="max-w-[56ch] text-[clamp(1rem,1.4vw,1.15rem)] leading-[1.62] text-pretty text-tfps01-ink/75 [&_code]:text-tfps01-ink">Four snapping sections, one owned scroll container, zero JavaScript. Every panel is exactly as tall as the visible viewport, so the snap position never drifts when a mobile toolbar slides away.</p>
<ul class="flex flex-wrap gap-2.5">
<li class="grid gap-0.5 rounded-[.85rem] border px-3.5 py-2.5 font-tfps01-mono text-[.9rem] border-tfps01-acc bg-tfps01-acc/8 text-tfps01-acc">h-dvh<span class="font-tfps01-sans text-[.74rem] text-tfps01-acc">tracks the live viewport</span></li>
<li class="grid gap-0.5 rounded-[.85rem] border px-3.5 py-2.5 font-tfps01-mono text-[.9rem] border-tfps01-ink/10 bg-tfps01-ink/[.03]">h-svh<span class="font-tfps01-sans text-[.74rem] text-tfps01-ink/70">toolbars shown</span></li>
<li class="grid gap-0.5 rounded-[.85rem] border px-3.5 py-2.5 font-tfps01-mono text-[.9rem] border-tfps01-ink/10 bg-tfps01-ink/[.03]">h-lvh<span class="font-tfps01-sans text-[.74rem] text-tfps01-ink/70">toolbars hidden</span></li>
</ul>
<a href="#tfps-01-s2" class="inline-flex min-h-11 items-center gap-2.5 rounded-full pe-3 font-tfps01-mono text-[.82rem] text-tfps01-ink/75 no-underline transition-colors hover:text-tfps01-acc focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tfps01-acc"><svg class="size-[1.1rem] animate-tfps01-bob motion-reduce:animate-none" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><path d="M12 4.5v13.5M6.5 12.6 12 18.2l5.5-5.6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>Scroll — the next panel’s edge is already showing</a>
</div>
</section>
<section class="relative flex min-h-dvh snap-start items-center justify-center bg-tfps01-panel px-[clamp(1.25rem,5vw,4.5rem)] pt-[clamp(2rem,5vh,3.75rem)] pb-[calc(clamp(2rem,5vh,3.75rem)+4.5rem)] even:bg-tfps01-panel2 not-first:-mt-18 not-first:rounded-t-[2rem] not-first:border-t not-first:border-tfps01-ink/10 not-first:shadow-[0_-30px_64px_-34px_var(--color-tfps01-shade)] forced-colors:border-[CanvasText]" id="tfps-01-s2" aria-labelledby="tfps-01-h2">
<div class="grid w-full max-w-[64rem] justify-items-start gap-[clamp(.95rem,2.3vh,1.5rem)]">
<p class="flex flex-wrap items-center gap-3"><span class="font-tfps01-mono text-[.7rem] font-medium tracking-[.14em] uppercase text-tfps01-acc">02 / 04</span><span class="rounded-[.45rem] border border-tfps01-ink/12 px-2 py-[.22rem] font-tfps01-mono text-[.7rem] text-tfps01-ink/75">the address-bar problem</span></p>
<h2 class="max-w-[26ch] text-[clamp(1.7rem,3.7vw,2.7rem)] leading-[1.06] font-semibold tracking-[-.032em] text-balance" id="tfps-01-h2">What the collapsing toolbar actually does</h2>
<div class="grid w-full grid-cols-[repeat(auto-fit,minmax(15rem,1fr))] gap-[clamp(1rem,3vw,2rem)]">
<figure class="grid gap-3.5">
<div class="relative grid h-[clamp(11rem,26vh,16rem)] grid-rows-[auto_1fr] overflow-hidden rounded-[1.4rem] border border-tfps01-ink/10 bg-tfps01-panel shadow-[0_22px_44px_-28px_var(--color-tfps01-shade)] forced-colors:border-[CanvasText]">
<span class="border-b border-tfps01-ink/10 bg-tfps01-ink/[.04] px-3 py-2 font-tfps01-mono text-[.62rem] text-tfps01-ink/70">codefronts.com</span>
<div class="grid h-[calc(100%+2.4rem)] place-items-center pb-16 border-t-2 border-tfps01-warn bg-tfps01-warn/10 bg-[repeating-linear-gradient(135deg,color-mix(in_oklab,var(--color-tfps01-warn)_14%,transparent)_0_10px,transparent_10px_20px)] font-tfps01-mono text-[1.1rem] font-medium text-tfps01-warn"><span>100vh</span></div>
<span aria-hidden="true" class="absolute inset-x-0 bottom-0 flex h-7 items-center justify-around border-t border-tfps01-ink/10 bg-tfps01-panel/80 backdrop-blur-sm"><i class="size-1.5 rounded-full bg-tfps01-ink/30"></i><i class="size-1.5 rounded-full bg-tfps01-ink/30"></i><i class="size-1.5 rounded-full bg-tfps01-ink/30"></i></span>
<span class="absolute inset-x-0 bottom-7 bg-tfps01-warn px-3 py-1.5 text-center font-tfps01-mono text-[.62rem] text-white tfps01-dark:text-[#1c1305]">bottom 96px under the toolbar</span>
</div>
<figcaption class="text-[.88rem] leading-[1.5] text-pretty text-tfps01-ink/75">Legacy <code>100vh</code> (Tailwind’s <code>h-screen</code>) resolves against the <em>large</em> viewport. The panel is taller than the screen from the first paint.</figcaption>
</figure>
<figure class="grid gap-3.5">
<div class="relative grid h-[clamp(11rem,26vh,16rem)] grid-rows-[auto_1fr] overflow-hidden rounded-[1.4rem] border border-tfps01-ink/10 bg-tfps01-panel shadow-[0_22px_44px_-28px_var(--color-tfps01-shade)] forced-colors:border-[CanvasText]">
<span class="border-b border-tfps01-ink/10 bg-tfps01-ink/[.04] px-3 py-2 font-tfps01-mono text-[.62rem] text-tfps01-ink/70">codefronts.com</span>
<div class="mb-7 grid place-items-center border-t-2 border-tfps01-acc bg-tfps01-acc/12 font-tfps01-mono text-[1.1rem] font-medium text-tfps01-acc"><span>100dvh</span></div>
<span aria-hidden="true" class="absolute inset-x-0 bottom-0 flex h-7 items-center justify-around border-t border-tfps01-ink/10 bg-tfps01-panel/80 backdrop-blur-sm"><i class="size-1.5 rounded-full bg-tfps01-ink/30"></i><i class="size-1.5 rounded-full bg-tfps01-ink/30"></i><i class="size-1.5 rounded-full bg-tfps01-ink/30"></i></span>
</div>
<figcaption class="text-[.88rem] leading-[1.5] text-pretty text-tfps01-ink/75"><code>h-dvh</code> measures what the user can see right now, and re-measures as the browser chrome moves.</figcaption>
</figure>
</div>
</div>
</section>
<section class="relative flex min-h-dvh snap-start items-center justify-center bg-tfps01-panel px-[clamp(1.25rem,5vw,4.5rem)] pt-[clamp(2rem,5vh,3.75rem)] pb-[calc(clamp(2rem,5vh,3.75rem)+4.5rem)] even:bg-tfps01-panel2 not-first:-mt-18 not-first:rounded-t-[2rem] not-first:border-t not-first:border-tfps01-ink/10 not-first:shadow-[0_-30px_64px_-34px_var(--color-tfps01-shade)] forced-colors:border-[CanvasText]" id="tfps-01-s3" aria-labelledby="tfps-01-h3">
<div class="grid w-full max-w-[64rem] justify-items-start gap-[clamp(.95rem,2.3vh,1.5rem)]">
<p class="flex flex-wrap items-center gap-3"><span class="font-tfps01-mono text-[.7rem] font-medium tracking-[.14em] uppercase text-tfps01-acc">03 / 04</span><span class="rounded-[.45rem] border border-tfps01-ink/12 px-2 py-[.22rem] font-tfps01-mono text-[.7rem] text-tfps01-ink/75">unit reference</span></p>
<h2 class="max-w-[26ch] text-[clamp(1.7rem,3.7vw,2.7rem)] leading-[1.06] font-semibold tracking-[-.032em] text-balance" id="tfps-01-h3">Which viewport utility, and when</h2>
<dl class="grid w-full gap-1">
<div class="grid grid-cols-[minmax(5.5rem,7rem)_1fr] items-start gap-[clamp(.75rem,2vw,1.75rem)] border-t border-tfps01-ink/10 py-[clamp(.8rem,1.6vh,1.1rem)]"><dt class="font-tfps01-mono text-[.95rem] text-tfps01-acc">min-h-dvh</dt><dd class="text-[.98rem] leading-[1.55] text-pretty text-tfps01-ink/75"><strong class="font-semibold text-tfps01-ink">Snapping panels.</strong> Height follows the live viewport, so panel height and scrollport height stay equal while toolbars animate.</dd></div>
<div class="grid grid-cols-[minmax(5.5rem,7rem)_1fr] items-start gap-[clamp(.75rem,2vw,1.75rem)] border-t border-tfps01-ink/10 py-[clamp(.8rem,1.6vh,1.1rem)]"><dt class="font-tfps01-mono text-[.95rem] text-tfps01-acc">min-h-svh</dt><dd class="text-[.98rem] leading-[1.55] text-pretty text-tfps01-ink/75"><strong class="font-semibold text-tfps01-ink">Panels with pinned bottom chrome.</strong> Always the short measurement, so a sticky action bar is never swallowed by the browser UI.</dd></div>
<div class="grid grid-cols-[minmax(5.5rem,7rem)_1fr] items-start gap-[clamp(.75rem,2vw,1.75rem)] border-t border-tfps01-ink/10 py-[clamp(.8rem,1.6vh,1.1rem)]"><dt class="font-tfps01-mono text-[.95rem] text-tfps01-acc">min-h-lvh</dt><dd class="text-[.98rem] leading-[1.55] text-pretty text-tfps01-ink/75"><strong class="font-semibold text-tfps01-ink">Decorative full-bleed art.</strong> Intentionally taller than the screen; fine for a background image, wrong for a snap target.</dd></div>
<div class="grid grid-cols-[minmax(5.5rem,7rem)_1fr] items-start gap-[clamp(.75rem,2vw,1.75rem)] border-t border-tfps01-ink/10 py-[clamp(.8rem,1.6vh,1.1rem)]"><dt class="font-tfps01-mono text-[.95rem] text-tfps01-warn">min-h-screen</dt><dd class="text-[.98rem] leading-[1.55] text-pretty text-tfps01-ink/75"><strong class="font-semibold text-tfps01-ink">Legacy <code>100vh</code>.</strong> Identical to <code>lvh</code>. Every browser that runs Tailwind v4 understands <code>dvh</code>, so there is no fallback left to write.</dd></div>
</dl>
</div>
</section>
<section class="relative flex min-h-screen snap-start items-center justify-center bg-[color-mix(in_oklab,var(--color-tfps01-warn)_9%,var(--color-tfps01-panel))] px-[clamp(1.25rem,5vw,4.5rem)] pt-[clamp(2rem,5vh,3.75rem)] pb-[calc(clamp(2rem,5vh,3.75rem)+4.5rem)] not-first:-mt-18 not-first:rounded-t-[2rem] not-first:border-t not-first:border-tfps01-ink/10 not-first:shadow-[0_-30px_64px_-34px_var(--color-tfps01-shade)] forced-colors:border-[CanvasText]" id="tfps-01-s4" aria-labelledby="tfps-01-h4">
<div class="grid w-full max-w-[64rem] justify-items-start gap-[clamp(.95rem,2.3vh,1.5rem)]">
<p class="flex flex-wrap items-center gap-3"><span class="font-tfps01-mono text-[.7rem] font-medium tracking-[.14em] uppercase text-tfps01-acc">04 / 04</span><span class="rounded-[.45rem] border border-tfps01-warn/40 px-2 py-[.22rem] font-tfps01-mono text-[.7rem] text-tfps01-warn">min-h-screen · labelled comparison</span></p>
<h2 class="max-w-[26ch] text-[clamp(1.7rem,3.7vw,2.7rem)] leading-[1.06] font-semibold tracking-[-.032em] text-balance" id="tfps-01-h4">This panel is the broken one</h2>
<p class="max-w-[56ch] text-[clamp(1rem,1.4vw,1.15rem)] leading-[1.62] text-pretty text-tfps01-ink/75 [&_code]:text-tfps01-ink">It is the only panel in the collection sized with <code>min-h-screen</code> (<code>100vh</code>). On a desktop it looks identical. Open the demo on an iPhone and this panel overshoots the screen: its footer line hides behind the address bar, and the snap edge lands mid-sentence.</p>
<p class="w-full border-t border-dashed border-tfps01-warn/45 pt-2.5 font-tfps01-mono text-[.8rem] text-tfps01-warn">Bottom line of panel 04 — visible here, hidden on iOS Safari.</p>
</div>
</section>
</div>
</div><div class="tfps-01 relative isolate w-full min-h-svh bg-tfps01-bg font-tfps01-sans text-tfps01-ink antialiased [&_code]:font-tfps01-mono [&_code]:text-[.92em]">
<input class="peer sr-only" type="checkbox" id="tfps-01-theme">
<label for="tfps-01-theme" class="fixed top-[1.15rem] right-[1.15rem] z-80 grid size-11 cursor-pointer place-items-center rounded-full border border-tfps01-ink/10 bg-white/60 text-tfps01-ink hover:bg-white/85 tfps01-dark:bg-white/[.07] tfps01-dark:hover:bg-white/15 shadow-[0_8px_24px_-12px_var(--color-tfps01-shade)] backdrop-blur-[10px] backdrop-saturate-150 transition-[background-color,scale] duration-200 active:scale-95 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-3 peer-focus-visible:outline-tfps01-acc forced-colors:border-[CanvasText]">
<svg class="size-[1.05rem] tfps01-dark:hidden" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><path d="M12 2.6v2.3M12 19.1v2.3M2.6 12h2.3M19.1 12h2.3M5.3 5.3l1.7 1.7M17 17l1.7 1.7M18.7 5.3L17 7M7 17l-1.7 1.7" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/><circle cx="12" cy="12" r="4.4" stroke="currentColor" stroke-width="1.7"/></svg>
<svg class="hidden size-[1.05rem] tfps01-dark:block" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><path d="M20.5 14.4A8.4 8.4 0 0 1 9.6 3.5 8.6 8.6 0 1 0 20.5 14.4Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>
<span class="sr-only">Switch light or dark theme</span>
</label>
<div class="h-dvh overflow-x-hidden overflow-y-auto snap-y snap-mandatory scroll-smooth overscroll-y-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tfps01-acc [@media(max-height:40rem)]:snap-proximity motion-reduce:snap-none motion-reduce:scroll-auto" tabindex="0" role="group" aria-label="Full viewport section scroller, four panels">
<section class="relative flex min-h-dvh snap-start items-center justify-center bg-tfps01-panel px-[clamp(1.25rem,5vw,4.5rem)] pt-[clamp(2rem,5vh,3.75rem)] pb-[calc(clamp(2rem,5vh,3.75rem)+4.5rem)] even:bg-tfps01-panel2 not-first:-mt-18 not-first:rounded-t-[2rem] not-first:border-t not-first:border-tfps01-ink/10 not-first:shadow-[0_-30px_64px_-34px_var(--color-tfps01-shade)] forced-colors:border-[CanvasText]" id="tfps-01-s1" aria-labelledby="tfps-01-h1">
<div class="grid w-full max-w-[64rem] justify-items-start gap-[clamp(.95rem,2.3vh,1.5rem)] transition-[opacity,translate] duration-700 ease-out starting:translate-y-4 starting:opacity-0 motion-reduce:transition-none">
<p class="flex flex-wrap items-center gap-3"><span class="font-tfps01-mono text-[.7rem] font-medium tracking-[.14em] uppercase text-tfps01-acc">01 / 04</span><span class="rounded-[.45rem] border border-tfps01-ink/12 px-2 py-[.22rem] font-tfps01-mono text-[.7rem] text-tfps01-ink/75">min-h-dvh</span></p>
<h2 class="max-w-[22ch] text-[clamp(2.1rem,6.2vw,4.5rem)] leading-[1.02] font-semibold tracking-[-.042em] text-balance" id="tfps-01-h1">Panels that fill the screen — and keep filling it</h2>
<p class="max-w-[56ch] text-[clamp(1rem,1.4vw,1.15rem)] leading-[1.62] text-pretty text-tfps01-ink/75 [&_code]:text-tfps01-ink">Four snapping sections, one owned scroll container, zero JavaScript. Every panel is exactly as tall as the visible viewport, so the snap position never drifts when a mobile toolbar slides away.</p>
<ul class="flex flex-wrap gap-2.5">
<li class="grid gap-0.5 rounded-[.85rem] border px-3.5 py-2.5 font-tfps01-mono text-[.9rem] border-tfps01-acc bg-tfps01-acc/8 text-tfps01-acc">h-dvh<span class="font-tfps01-sans text-[.74rem] text-tfps01-acc">tracks the live viewport</span></li>
<li class="grid gap-0.5 rounded-[.85rem] border px-3.5 py-2.5 font-tfps01-mono text-[.9rem] border-tfps01-ink/10 bg-tfps01-ink/[.03]">h-svh<span class="font-tfps01-sans text-[.74rem] text-tfps01-ink/70">toolbars shown</span></li>
<li class="grid gap-0.5 rounded-[.85rem] border px-3.5 py-2.5 font-tfps01-mono text-[.9rem] border-tfps01-ink/10 bg-tfps01-ink/[.03]">h-lvh<span class="font-tfps01-sans text-[.74rem] text-tfps01-ink/70">toolbars hidden</span></li>
</ul>
<a href="#tfps-01-s2" class="inline-flex min-h-11 items-center gap-2.5 rounded-full pe-3 font-tfps01-mono text-[.82rem] text-tfps01-ink/75 no-underline transition-colors hover:text-tfps01-acc focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tfps01-acc"><svg class="size-[1.1rem] animate-tfps01-bob motion-reduce:animate-none" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><path d="M12 4.5v13.5M6.5 12.6 12 18.2l5.5-5.6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>Scroll — the next panel’s edge is already showing</a>
</div>
</section>
<section class="relative flex min-h-dvh snap-start items-center justify-center bg-tfps01-panel px-[clamp(1.25rem,5vw,4.5rem)] pt-[clamp(2rem,5vh,3.75rem)] pb-[calc(clamp(2rem,5vh,3.75rem)+4.5rem)] even:bg-tfps01-panel2 not-first:-mt-18 not-first:rounded-t-[2rem] not-first:border-t not-first:border-tfps01-ink/10 not-first:shadow-[0_-30px_64px_-34px_var(--color-tfps01-shade)] forced-colors:border-[CanvasText]" id="tfps-01-s2" aria-labelledby="tfps-01-h2">
<div class="grid w-full max-w-[64rem] justify-items-start gap-[clamp(.95rem,2.3vh,1.5rem)]">
<p class="flex flex-wrap items-center gap-3"><span class="font-tfps01-mono text-[.7rem] font-medium tracking-[.14em] uppercase text-tfps01-acc">02 / 04</span><span class="rounded-[.45rem] border border-tfps01-ink/12 px-2 py-[.22rem] font-tfps01-mono text-[.7rem] text-tfps01-ink/75">the address-bar problem</span></p>
<h2 class="max-w-[26ch] text-[clamp(1.7rem,3.7vw,2.7rem)] leading-[1.06] font-semibold tracking-[-.032em] text-balance" id="tfps-01-h2">What the collapsing toolbar actually does</h2>
<div class="grid w-full grid-cols-[repeat(auto-fit,minmax(15rem,1fr))] gap-[clamp(1rem,3vw,2rem)]">
<figure class="grid gap-3.5">
<div class="relative grid h-[clamp(11rem,26vh,16rem)] grid-rows-[auto_1fr] overflow-hidden rounded-[1.4rem] border border-tfps01-ink/10 bg-tfps01-panel shadow-[0_22px_44px_-28px_var(--color-tfps01-shade)] forced-colors:border-[CanvasText]">
<span class="border-b border-tfps01-ink/10 bg-tfps01-ink/[.04] px-3 py-2 font-tfps01-mono text-[.62rem] text-tfps01-ink/70">codefronts.com</span>
<div class="grid h-[calc(100%+2.4rem)] place-items-center pb-16 border-t-2 border-tfps01-warn bg-tfps01-warn/10 bg-[repeating-linear-gradient(135deg,color-mix(in_oklab,var(--color-tfps01-warn)_14%,transparent)_0_10px,transparent_10px_20px)] font-tfps01-mono text-[1.1rem] font-medium text-tfps01-warn"><span>100vh</span></div>
<span aria-hidden="true" class="absolute inset-x-0 bottom-0 flex h-7 items-center justify-around border-t border-tfps01-ink/10 bg-tfps01-panel/80 backdrop-blur-sm"><i class="size-1.5 rounded-full bg-tfps01-ink/30"></i><i class="size-1.5 rounded-full bg-tfps01-ink/30"></i><i class="size-1.5 rounded-full bg-tfps01-ink/30"></i></span>
<span class="absolute inset-x-0 bottom-7 bg-tfps01-warn px-3 py-1.5 text-center font-tfps01-mono text-[.62rem] text-white tfps01-dark:text-[#1c1305]">bottom 96px under the toolbar</span>
</div>
<figcaption class="text-[.88rem] leading-[1.5] text-pretty text-tfps01-ink/75">Legacy <code>100vh</code> (Tailwind’s <code>h-screen</code>) resolves against the <em>large</em> viewport. The panel is taller than the screen from the first paint.</figcaption>
</figure>
<figure class="grid gap-3.5">
<div class="relative grid h-[clamp(11rem,26vh,16rem)] grid-rows-[auto_1fr] overflow-hidden rounded-[1.4rem] border border-tfps01-ink/10 bg-tfps01-panel shadow-[0_22px_44px_-28px_var(--color-tfps01-shade)] forced-colors:border-[CanvasText]">
<span class="border-b border-tfps01-ink/10 bg-tfps01-ink/[.04] px-3 py-2 font-tfps01-mono text-[.62rem] text-tfps01-ink/70">codefronts.com</span>
<div class="mb-7 grid place-items-center border-t-2 border-tfps01-acc bg-tfps01-acc/12 font-tfps01-mono text-[1.1rem] font-medium text-tfps01-acc"><span>100dvh</span></div>
<span aria-hidden="true" class="absolute inset-x-0 bottom-0 flex h-7 items-center justify-around border-t border-tfps01-ink/10 bg-tfps01-panel/80 backdrop-blur-sm"><i class="size-1.5 rounded-full bg-tfps01-ink/30"></i><i class="size-1.5 rounded-full bg-tfps01-ink/30"></i><i class="size-1.5 rounded-full bg-tfps01-ink/30"></i></span>
</div>
<figcaption class="text-[.88rem] leading-[1.5] text-pretty text-tfps01-ink/75"><code>h-dvh</code> measures what the user can see right now, and re-measures as the browser chrome moves.</figcaption>
</figure>
</div>
</div>
</section>
<section class="relative flex min-h-dvh snap-start items-center justify-center bg-tfps01-panel px-[clamp(1.25rem,5vw,4.5rem)] pt-[clamp(2rem,5vh,3.75rem)] pb-[calc(clamp(2rem,5vh,3.75rem)+4.5rem)] even:bg-tfps01-panel2 not-first:-mt-18 not-first:rounded-t-[2rem] not-first:border-t not-first:border-tfps01-ink/10 not-first:shadow-[0_-30px_64px_-34px_var(--color-tfps01-shade)] forced-colors:border-[CanvasText]" id="tfps-01-s3" aria-labelledby="tfps-01-h3">
<div class="grid w-full max-w-[64rem] justify-items-start gap-[clamp(.95rem,2.3vh,1.5rem)]">
<p class="flex flex-wrap items-center gap-3"><span class="font-tfps01-mono text-[.7rem] font-medium tracking-[.14em] uppercase text-tfps01-acc">03 / 04</span><span class="rounded-[.45rem] border border-tfps01-ink/12 px-2 py-[.22rem] font-tfps01-mono text-[.7rem] text-tfps01-ink/75">unit reference</span></p>
<h2 class="max-w-[26ch] text-[clamp(1.7rem,3.7vw,2.7rem)] leading-[1.06] font-semibold tracking-[-.032em] text-balance" id="tfps-01-h3">Which viewport utility, and when</h2>
<dl class="grid w-full gap-1">
<div class="grid grid-cols-[minmax(5.5rem,7rem)_1fr] items-start gap-[clamp(.75rem,2vw,1.75rem)] border-t border-tfps01-ink/10 py-[clamp(.8rem,1.6vh,1.1rem)]"><dt class="font-tfps01-mono text-[.95rem] text-tfps01-acc">min-h-dvh</dt><dd class="text-[.98rem] leading-[1.55] text-pretty text-tfps01-ink/75"><strong class="font-semibold text-tfps01-ink">Snapping panels.</strong> Height follows the live viewport, so panel height and scrollport height stay equal while toolbars animate.</dd></div>
<div class="grid grid-cols-[minmax(5.5rem,7rem)_1fr] items-start gap-[clamp(.75rem,2vw,1.75rem)] border-t border-tfps01-ink/10 py-[clamp(.8rem,1.6vh,1.1rem)]"><dt class="font-tfps01-mono text-[.95rem] text-tfps01-acc">min-h-svh</dt><dd class="text-[.98rem] leading-[1.55] text-pretty text-tfps01-ink/75"><strong class="font-semibold text-tfps01-ink">Panels with pinned bottom chrome.</strong> Always the short measurement, so a sticky action bar is never swallowed by the browser UI.</dd></div>
<div class="grid grid-cols-[minmax(5.5rem,7rem)_1fr] items-start gap-[clamp(.75rem,2vw,1.75rem)] border-t border-tfps01-ink/10 py-[clamp(.8rem,1.6vh,1.1rem)]"><dt class="font-tfps01-mono text-[.95rem] text-tfps01-acc">min-h-lvh</dt><dd class="text-[.98rem] leading-[1.55] text-pretty text-tfps01-ink/75"><strong class="font-semibold text-tfps01-ink">Decorative full-bleed art.</strong> Intentionally taller than the screen; fine for a background image, wrong for a snap target.</dd></div>
<div class="grid grid-cols-[minmax(5.5rem,7rem)_1fr] items-start gap-[clamp(.75rem,2vw,1.75rem)] border-t border-tfps01-ink/10 py-[clamp(.8rem,1.6vh,1.1rem)]"><dt class="font-tfps01-mono text-[.95rem] text-tfps01-warn">min-h-screen</dt><dd class="text-[.98rem] leading-[1.55] text-pretty text-tfps01-ink/75"><strong class="font-semibold text-tfps01-ink">Legacy <code>100vh</code>.</strong> Identical to <code>lvh</code>. Every browser that runs Tailwind v4 understands <code>dvh</code>, so there is no fallback left to write.</dd></div>
</dl>
</div>
</section>
<section class="relative flex min-h-screen snap-start items-center justify-center bg-[color-mix(in_oklab,var(--color-tfps01-warn)_9%,var(--color-tfps01-panel))] px-[clamp(1.25rem,5vw,4.5rem)] pt-[clamp(2rem,5vh,3.75rem)] pb-[calc(clamp(2rem,5vh,3.75rem)+4.5rem)] not-first:-mt-18 not-first:rounded-t-[2rem] not-first:border-t not-first:border-tfps01-ink/10 not-first:shadow-[0_-30px_64px_-34px_var(--color-tfps01-shade)] forced-colors:border-[CanvasText]" id="tfps-01-s4" aria-labelledby="tfps-01-h4">
<div class="grid w-full max-w-[64rem] justify-items-start gap-[clamp(.95rem,2.3vh,1.5rem)]">
<p class="flex flex-wrap items-center gap-3"><span class="font-tfps01-mono text-[.7rem] font-medium tracking-[.14em] uppercase text-tfps01-acc">04 / 04</span><span class="rounded-[.45rem] border border-tfps01-warn/40 px-2 py-[.22rem] font-tfps01-mono text-[.7rem] text-tfps01-warn">min-h-screen · labelled comparison</span></p>
<h2 class="max-w-[26ch] text-[clamp(1.7rem,3.7vw,2.7rem)] leading-[1.06] font-semibold tracking-[-.032em] text-balance" id="tfps-01-h4">This panel is the broken one</h2>
<p class="max-w-[56ch] text-[clamp(1rem,1.4vw,1.15rem)] leading-[1.62] text-pretty text-tfps01-ink/75 [&_code]:text-tfps01-ink">It is the only panel in the collection sized with <code>min-h-screen</code> (<code>100vh</code>). On a desktop it looks identical. Open the demo on an iPhone and this panel overshoots the screen: its footer line hides behind the address bar, and the snap edge lands mid-sentence.</p>
<p class="w-full border-t border-dashed border-tfps01-warn/45 pt-2.5 font-tfps01-mono text-[.8rem] text-tfps01-warn">Bottom line of panel 04 — visible here, hidden on iOS Safari.</p>
</div>
</section>
</div>
</div>@import "tailwindcss";
@theme {
--color-tfps01-bg: #f7f8fa;
--color-tfps01-bg: oklch(0.979 0.003 264.5);
--color-tfps01-panel: #ffffff;
--color-tfps01-panel: oklch(1 0 0);
--color-tfps01-panel2: #eef1f6;
--color-tfps01-panel2: oklch(0.957 0.007 260.7);
--color-tfps01-ink: #0c0e14;
--color-tfps01-ink: oklch(16% .015 265);
--color-tfps01-acc: #2563eb;
--color-tfps01-acc: oklch(58% .19 258);
--color-tfps01-warn: #b45309;
--color-tfps01-warn: oklch(56% .13 62);
--color-tfps01-shade: rgb(12 14 20 / .2);
--font-tfps01-sans: 'Geist', system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-tfps01-mono: 'Geist Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace;
--animate-tfps01-bob: tfps01-bob 2.4s ease-in-out infinite;
@keyframes tfps01-bob {
0%,
100% {
translate: 0 -2px;
}
50% {
translate: 0 3px;
}
}
}
/* Theme state. Dark = OS preference XOR the toggle, so the switch always flips what you see —
the CSS original's checkbox did nothing for visitors whose OS was already dark. */
@custom-variant tfps01-dark {
@media (prefers-color-scheme: light) {
&:where(.tfps-01:has(#tfps-01-theme:checked), .tfps-01:has(#tfps-01-theme:checked) *) {
@slot;
}
}
@media (prefers-color-scheme: dark) {
&:where(.tfps-01:not(:has(#tfps-01-theme:checked)), .tfps-01:not(:has(#tfps-01-theme:checked)) *) {
@slot;
}
}
}
/* Root rule: the full-bleed contract, stated before the browser build compiles. */
.tfps-01 {
width: 100%;
min-height: 100svh;
@variant tfps01-dark {
--color-tfps01-bg: #07080b;
--color-tfps01-bg: oklch(0.135 0.007 269);
--color-tfps01-panel: #0b0d12;
--color-tfps01-panel: oklch(0.159 0.011 268);
--color-tfps01-panel2: #11141b;
--color-tfps01-panel2: oklch(0.191 0.015 266.8);
--color-tfps01-ink: #f2f4f9;
--color-tfps01-ink: oklch(0.967 0.007 268.5);
--color-tfps01-acc: #7dd3fc;
--color-tfps01-acc: oklch(0.828 0.101 230.3);
--color-tfps01-warn: #fbbf24;
--color-tfps01-warn: oklch(0.837 0.164 84.4);
--color-tfps01-shade: rgb(0 0 0 / .7);
}
}@import "tailwindcss";
@theme {
--color-tfps01-bg: #f7f8fa;
--color-tfps01-bg: oklch(0.979 0.003 264.5);
--color-tfps01-panel: #ffffff;
--color-tfps01-panel: oklch(1 0 0);
--color-tfps01-panel2: #eef1f6;
--color-tfps01-panel2: oklch(0.957 0.007 260.7);
--color-tfps01-ink: #0c0e14;
--color-tfps01-ink: oklch(16% .015 265);
--color-tfps01-acc: #2563eb;
--color-tfps01-acc: oklch(58% .19 258);
--color-tfps01-warn: #b45309;
--color-tfps01-warn: oklch(56% .13 62);
--color-tfps01-shade: rgb(12 14 20 / .2);
--font-tfps01-sans: 'Geist', system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-tfps01-mono: 'Geist Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace;
--animate-tfps01-bob: tfps01-bob 2.4s ease-in-out infinite;
@keyframes tfps01-bob {
0%,
100% {
translate: 0 -2px;
}
50% {
translate: 0 3px;
}
}
}
/* Theme state. Dark = OS preference XOR the toggle, so the switch always flips what you see —
the CSS original's checkbox did nothing for visitors whose OS was already dark. */
@custom-variant tfps01-dark {
@media (prefers-color-scheme: light) {
&:where(.tfps-01:has(#tfps-01-theme:checked), .tfps-01:has(#tfps-01-theme:checked) *) {
@slot;
}
}
@media (prefers-color-scheme: dark) {
&:where(.tfps-01:not(:has(#tfps-01-theme:checked)), .tfps-01:not(:has(#tfps-01-theme:checked)) *) {
@slot;
}
}
}
/* Root rule: the full-bleed contract, stated before the browser build compiles. */
.tfps-01 {
width: 100%;
min-height: 100svh;
@variant tfps01-dark {
--color-tfps01-bg: #07080b;
--color-tfps01-bg: oklch(0.135 0.007 269);
--color-tfps01-panel: #0b0d12;
--color-tfps01-panel: oklch(0.159 0.011 268);
--color-tfps01-panel2: #11141b;
--color-tfps01-panel2: oklch(0.191 0.015 266.8);
--color-tfps01-ink: #f2f4f9;
--color-tfps01-ink: oklch(0.967 0.007 268.5);
--color-tfps01-acc: #7dd3fc;
--color-tfps01-acc: oklch(0.828 0.101 230.3);
--color-tfps01-warn: #fbbf24;
--color-tfps01-warn: oklch(0.837 0.164 84.4);
--color-tfps01-shade: rgb(0 0 0 / .7);
}
}Setup — do this once per project
Skip this if your project already runs Tailwind v4.
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>Compiles in the browser — perfect for a CodePen or a quick test, not for production.
npm install tailwindcss @tailwindcss/vite
npm install tailwindcss @tailwindcss/vite// vite.config.js ("type": "module" in package.json)
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss()],
})// vite.config.js ("type": "module" in package.json)
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss()],
})Any v4 setup works — Vite, PostCSS or the CLI. Nothing in this demo is bundler-specific.
@import "tailwindcss";
@theme {
--color-tfps01-bg: #f7f8fa;
--color-tfps01-bg: oklch(0.979 0.003 264.5);
--color-tfps01-panel: #ffffff;
--color-tfps01-panel: oklch(1 0 0);
--color-tfps01-panel2: #eef1f6;
--color-tfps01-panel2: oklch(0.957 0.007 260.7);
--color-tfps01-ink: #0c0e14;
--color-tfps01-ink: oklch(16% .015 265);
--color-tfps01-acc: #2563eb;
--color-tfps01-acc: oklch(58% .19 258);
--color-tfps01-warn: #b45309;
--color-tfps01-warn: oklch(56% .13 62);
--color-tfps01-shade: rgb(12 14 20 / .2);
--font-tfps01-sans: 'Geist', system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-tfps01-mono: 'Geist Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace;
--animate-tfps01-bob: tfps01-bob 2.4s ease-in-out infinite;
@keyframes tfps01-bob {
0%,
100% {
translate: 0 -2px;
}
50% {
translate: 0 3px;
}
}
}
/* Theme state. Dark = OS preference XOR the toggle, so the switch always flips what you see —
the CSS original's checkbox did nothing for visitors whose OS was already dark. */
@custom-variant tfps01-dark {
@media (prefers-color-scheme: light) {
&:where(.tfps-01:has(#tfps-01-theme:checked), .tfps-01:has(#tfps-01-theme:checked) *) {
@slot;
}
}
@media (prefers-color-scheme: dark) {
&:where(.tfps-01:not(:has(#tfps-01-theme:checked)), .tfps-01:not(:has(#tfps-01-theme:checked)) *) {
@slot;
}
}
}
/* Root rule: the full-bleed contract, stated before the browser build compiles. */
.tfps-01 {
width: 100%;
min-height: 100svh;
@variant tfps01-dark {
--color-tfps01-bg: #07080b;
--color-tfps01-bg: oklch(0.135 0.007 269);
--color-tfps01-panel: #0b0d12;
--color-tfps01-panel: oklch(0.159 0.011 268);
--color-tfps01-panel2: #11141b;
--color-tfps01-panel2: oklch(0.191 0.015 266.8);
--color-tfps01-ink: #f2f4f9;
--color-tfps01-ink: oklch(0.967 0.007 268.5);
--color-tfps01-acc: #7dd3fc;
--color-tfps01-acc: oklch(0.828 0.101 230.3);
--color-tfps01-warn: #fbbf24;
--color-tfps01-warn: oklch(0.837 0.164 84.4);
--color-tfps01-shade: rgb(0 0 0 / .7);
}
}@import "tailwindcss";
@theme {
--color-tfps01-bg: #f7f8fa;
--color-tfps01-bg: oklch(0.979 0.003 264.5);
--color-tfps01-panel: #ffffff;
--color-tfps01-panel: oklch(1 0 0);
--color-tfps01-panel2: #eef1f6;
--color-tfps01-panel2: oklch(0.957 0.007 260.7);
--color-tfps01-ink: #0c0e14;
--color-tfps01-ink: oklch(16% .015 265);
--color-tfps01-acc: #2563eb;
--color-tfps01-acc: oklch(58% .19 258);
--color-tfps01-warn: #b45309;
--color-tfps01-warn: oklch(56% .13 62);
--color-tfps01-shade: rgb(12 14 20 / .2);
--font-tfps01-sans: 'Geist', system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-tfps01-mono: 'Geist Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace;
--animate-tfps01-bob: tfps01-bob 2.4s ease-in-out infinite;
@keyframes tfps01-bob {
0%,
100% {
translate: 0 -2px;
}
50% {
translate: 0 3px;
}
}
}
/* Theme state. Dark = OS preference XOR the toggle, so the switch always flips what you see —
the CSS original's checkbox did nothing for visitors whose OS was already dark. */
@custom-variant tfps01-dark {
@media (prefers-color-scheme: light) {
&:where(.tfps-01:has(#tfps-01-theme:checked), .tfps-01:has(#tfps-01-theme:checked) *) {
@slot;
}
}
@media (prefers-color-scheme: dark) {
&:where(.tfps-01:not(:has(#tfps-01-theme:checked)), .tfps-01:not(:has(#tfps-01-theme:checked)) *) {
@slot;
}
}
}
/* Root rule: the full-bleed contract, stated before the browser build compiles. */
.tfps-01 {
width: 100%;
min-height: 100svh;
@variant tfps01-dark {
--color-tfps01-bg: #07080b;
--color-tfps01-bg: oklch(0.135 0.007 269);
--color-tfps01-panel: #0b0d12;
--color-tfps01-panel: oklch(0.159 0.011 268);
--color-tfps01-panel2: #11141b;
--color-tfps01-panel2: oklch(0.191 0.015 266.8);
--color-tfps01-ink: #f2f4f9;
--color-tfps01-ink: oklch(0.967 0.007 268.5);
--color-tfps01-acc: #7dd3fc;
--color-tfps01-acc: oklch(0.828 0.101 230.3);
--color-tfps01-warn: #fbbf24;
--color-tfps01-warn: oklch(0.837 0.164 84.4);
--color-tfps01-shade: rgb(0 0 0 / .7);
}
}<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@400..700&family=Geist+Mono:wght@400;500&display=swap">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@400..700&family=Geist+Mono:wght@400;500&display=swap">What changed in the port
Which parts became utilities, which stayed CSS, and why.
- Four CSS rule blocks (scroller, panel, sibling seam,
@supportsfallback) became two class lists; the sibling-combinator seam is nownot-first:on the panel itself. - The
@supports not (height:100dvh)and@supports not selector(:has(*))blocks were deleted, not ported: no browser that can parse Tailwind v4's sheet lacks either feature. - UI upgrade from the CSS original: the theme toggle now flips relative to the OS preference — in the original, a visitor already in dark mode clicked it and nothing happened. It also grew from 40px to 44px (
size-11). - UI upgrade: the scroll cue is a real anchor to panel 02 (keyboard and switch users can act on it), the first panel eases in with
starting:, and both device mock-ups now draw the bottom toolbar, so the 100vh overflow is visibly under something.
Here's a working CSS demo from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Tailwind Scroll Snap Full Screen Sections with 100dvh
Source: https://codefronts.com/layouts/tailwind-css-full-page-sections/tailwind-scroll-snap-full-screen-sections-with-100dvh/
The reference build for the collection, ported whole into utilities: four snapping full-viewport panels sized with min-h-dvh, one owned snap-y snap-mandatory scrollport, and a fourth panel deliberately built on min-h-screen so you can see the iOS bug it causes. A device diagram shows what the collapsing address bar does to vh, and a table settles dvh vs svh vs lvh in Tailwind's own vocabulary. Zero JavaScript.
## HTML
```html
<div class="tfps-01 relative isolate w-full min-h-svh bg-tfps01-bg font-tfps01-sans text-tfps01-ink antialiased [&_code]:font-tfps01-mono [&_code]:text-[.92em]">
<input class="peer sr-only" type="checkbox" id="tfps-01-theme">
<label for="tfps-01-theme" class="fixed top-[1.15rem] right-[1.15rem] z-80 grid size-11 cursor-pointer place-items-center rounded-full border border-tfps01-ink/10 bg-white/60 text-tfps01-ink hover:bg-white/85 tfps01-dark:bg-white/[.07] tfps01-dark:hover:bg-white/15 shadow-[0_8px_24px_-12px_var(--color-tfps01-shade)] backdrop-blur-[10px] backdrop-saturate-150 transition-[background-color,scale] duration-200 active:scale-95 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-3 peer-focus-visible:outline-tfps01-acc forced-colors:border-[CanvasText]">
<svg class="size-[1.05rem] tfps01-dark:hidden" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><path d="M12 2.6v2.3M12 19.1v2.3M2.6 12h2.3M19.1 12h2.3M5.3 5.3l1.7 1.7M17 17l1.7 1.7M18.7 5.3L17 7M7 17l-1.7 1.7" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/><circle cx="12" cy="12" r="4.4" stroke="currentColor" stroke-width="1.7"/></svg>
<svg class="hidden size-[1.05rem] tfps01-dark:block" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><path d="M20.5 14.4A8.4 8.4 0 0 1 9.6 3.5 8.6 8.6 0 1 0 20.5 14.4Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>
<span class="sr-only">Switch light or dark theme</span>
</label>
<div class="h-dvh overflow-x-hidden overflow-y-auto snap-y snap-mandatory scroll-smooth overscroll-y-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tfps01-acc [@media(max-height:40rem)]:snap-proximity motion-reduce:snap-none motion-reduce:scroll-auto" tabindex="0" role="group" aria-label="Full viewport section scroller, four panels">
<section class="relative flex min-h-dvh snap-start items-center justify-center bg-tfps01-panel px-[clamp(1.25rem,5vw,4.5rem)] pt-[clamp(2rem,5vh,3.75rem)] pb-[calc(clamp(2rem,5vh,3.75rem)+4.5rem)] even:bg-tfps01-panel2 not-first:-mt-18 not-first:rounded-t-[2rem] not-first:border-t not-first:border-tfps01-ink/10 not-first:shadow-[0_-30px_64px_-34px_var(--color-tfps01-shade)] forced-colors:border-[CanvasText]" id="tfps-01-s1" aria-labelledby="tfps-01-h1">
<div class="grid w-full max-w-[64rem] justify-items-start gap-[clamp(.95rem,2.3vh,1.5rem)] transition-[opacity,translate] duration-700 ease-out starting:translate-y-4 starting:opacity-0 motion-reduce:transition-none">
<p class="flex flex-wrap items-center gap-3"><span class="font-tfps01-mono text-[.7rem] font-medium tracking-[.14em] uppercase text-tfps01-acc">01 / 04</span><span class="rounded-[.45rem] border border-tfps01-ink/12 px-2 py-[.22rem] font-tfps01-mono text-[.7rem] text-tfps01-ink/75">min-h-dvh</span></p>
<h2 class="max-w-[22ch] text-[clamp(2.1rem,6.2vw,4.5rem)] leading-[1.02] font-semibold tracking-[-.042em] text-balance" id="tfps-01-h1">Panels that fill the screen — and keep filling it</h2>
<p class="max-w-[56ch] text-[clamp(1rem,1.4vw,1.15rem)] leading-[1.62] text-pretty text-tfps01-ink/75 [&_code]:text-tfps01-ink">Four snapping sections, one owned scroll container, zero JavaScript. Every panel is exactly as tall as the visible viewport, so the snap position never drifts when a mobile toolbar slides away.</p>
<ul class="flex flex-wrap gap-2.5">
<li class="grid gap-0.5 rounded-[.85rem] border px-3.5 py-2.5 font-tfps01-mono text-[.9rem] border-tfps01-acc bg-tfps01-acc/8 text-tfps01-acc">h-dvh<span class="font-tfps01-sans text-[.74rem] text-tfps01-acc">tracks the live viewport</span></li>
<li class="grid gap-0.5 rounded-[.85rem] border px-3.5 py-2.5 font-tfps01-mono text-[.9rem] border-tfps01-ink/10 bg-tfps01-ink/[.03]">h-svh<span class="font-tfps01-sans text-[.74rem] text-tfps01-ink/70">toolbars shown</span></li>
<li class="grid gap-0.5 rounded-[.85rem] border px-3.5 py-2.5 font-tfps01-mono text-[.9rem] border-tfps01-ink/10 bg-tfps01-ink/[.03]">h-lvh<span class="font-tfps01-sans text-[.74rem] text-tfps01-ink/70">toolbars hidden</span></li>
</ul>
<a href="#tfps-01-s2" class="inline-flex min-h-11 items-center gap-2.5 rounded-full pe-3 font-tfps01-mono text-[.82rem] text-tfps01-ink/75 no-underline transition-colors hover:text-tfps01-acc focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tfps01-acc"><svg class="size-[1.1rem] animate-tfps01-bob motion-reduce:animate-none" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><path d="M12 4.5v13.5M6.5 12.6 12 18.2l5.5-5.6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>Scroll — the next panel’s edge is already showing</a>
</div>
</section>
<section class="relative flex min-h-dvh snap-start items-center justify-center bg-tfps01-panel px-[clamp(1.25rem,5vw,4.5rem)] pt-[clamp(2rem,5vh,3.75rem)] pb-[calc(clamp(2rem,5vh,3.75rem)+4.5rem)] even:bg-tfps01-panel2 not-first:-mt-18 not-first:rounded-t-[2rem] not-first:border-t not-first:border-tfps01-ink/10 not-first:shadow-[0_-30px_64px_-34px_var(--color-tfps01-shade)] forced-colors:border-[CanvasText]" id="tfps-01-s2" aria-labelledby="tfps-01-h2">
<div class="grid w-full max-w-[64rem] justify-items-start gap-[clamp(.95rem,2.3vh,1.5rem)]">
<p class="flex flex-wrap items-center gap-3"><span class="font-tfps01-mono text-[.7rem] font-medium tracking-[.14em] uppercase text-tfps01-acc">02 / 04</span><span class="rounded-[.45rem] border border-tfps01-ink/12 px-2 py-[.22rem] font-tfps01-mono text-[.7rem] text-tfps01-ink/75">the address-bar problem</span></p>
<h2 class="max-w-[26ch] text-[clamp(1.7rem,3.7vw,2.7rem)] leading-[1.06] font-semibold tracking-[-.032em] text-balance" id="tfps-01-h2">What the collapsing toolbar actually does</h2>
<div class="grid w-full grid-cols-[repeat(auto-fit,minmax(15rem,1fr))] gap-[clamp(1rem,3vw,2rem)]">
<figure class="grid gap-3.5">
<div class="relative grid h-[clamp(11rem,26vh,16rem)] grid-rows-[auto_1fr] overflow-hidden rounded-[1.4rem] border border-tfps01-ink/10 bg-tfps01-panel shadow-[0_22px_44px_-28px_var(--color-tfps01-shade)] forced-colors:border-[CanvasText]">
<span class="border-b border-tfps01-ink/10 bg-tfps01-ink/[.04] px-3 py-2 font-tfps01-mono text-[.62rem] text-tfps01-ink/70">codefronts.com</span>
<div class="grid h-[calc(100%+2.4rem)] place-items-center pb-16 border-t-2 border-tfps01-warn bg-tfps01-warn/10 bg-[repeating-linear-gradient(135deg,color-mix(in_oklab,var(--color-tfps01-warn)_14%,transparent)_0_10px,transparent_10px_20px)] font-tfps01-mono text-[1.1rem] font-medium text-tfps01-warn"><span>100vh</span></div>
<span aria-hidden="true" class="absolute inset-x-0 bottom-0 flex h-7 items-center justify-around border-t border-tfps01-ink/10 bg-tfps01-panel/80 backdrop-blur-sm"><i class="size-1.5 rounded-full bg-tfps01-ink/30"></i><i class="size-1.5 rounded-full bg-tfps01-ink/30"></i><i class="size-1.5 rounded-full bg-tfps01-ink/30"></i></span>
<span class="absolute inset-x-0 bottom-7 bg-tfps01-warn px-3 py-1.5 text-center font-tfps01-mono text-[.62rem] text-white tfps01-dark:text-[#1c1305]">bottom 96px under the toolbar</span>
</div>
<figcaption class="text-[.88rem] leading-[1.5] text-pretty text-tfps01-ink/75">Legacy <code>100vh</code> (Tailwind’s <code>h-screen</code>) resolves against the <em>large</em> viewport. The panel is taller than the screen from the first paint.</figcaption>
</figure>
<figure class="grid gap-3.5">
<div class="relative grid h-[clamp(11rem,26vh,16rem)] grid-rows-[auto_1fr] overflow-hidden rounded-[1.4rem] border border-tfps01-ink/10 bg-tfps01-panel shadow-[0_22px_44px_-28px_var(--color-tfps01-shade)] forced-colors:border-[CanvasText]">
<span class="border-b border-tfps01-ink/10 bg-tfps01-ink/[.04] px-3 py-2 font-tfps01-mono text-[.62rem] text-tfps01-ink/70">codefronts.com</span>
<div class="mb-7 grid place-items-center border-t-2 border-tfps01-acc bg-tfps01-acc/12 font-tfps01-mono text-[1.1rem] font-medium text-tfps01-acc"><span>100dvh</span></div>
<span aria-hidden="true" class="absolute inset-x-0 bottom-0 flex h-7 items-center justify-around border-t border-tfps01-ink/10 bg-tfps01-panel/80 backdrop-blur-sm"><i class="size-1.5 rounded-full bg-tfps01-ink/30"></i><i class="size-1.5 rounded-full bg-tfps01-ink/30"></i><i class="size-1.5 rounded-full bg-tfps01-ink/30"></i></span>
</div>
<figcaption class="text-[.88rem] leading-[1.5] text-pretty text-tfps01-ink/75"><code>h-dvh</code> measures what the user can see right now, and re-measures as the browser chrome moves.</figcaption>
</figure>
</div>
</div>
</section>
<section class="relative flex min-h-dvh snap-start items-center justify-center bg-tfps01-panel px-[clamp(1.25rem,5vw,4.5rem)] pt-[clamp(2rem,5vh,3.75rem)] pb-[calc(clamp(2rem,5vh,3.75rem)+4.5rem)] even:bg-tfps01-panel2 not-first:-mt-18 not-first:rounded-t-[2rem] not-first:border-t not-first:border-tfps01-ink/10 not-first:shadow-[0_-30px_64px_-34px_var(--color-tfps01-shade)] forced-colors:border-[CanvasText]" id="tfps-01-s3" aria-labelledby="tfps-01-h3">
<div class="grid w-full max-w-[64rem] justify-items-start gap-[clamp(.95rem,2.3vh,1.5rem)]">
<p class="flex flex-wrap items-center gap-3"><span class="font-tfps01-mono text-[.7rem] font-medium tracking-[.14em] uppercase text-tfps01-acc">03 / 04</span><span class="rounded-[.45rem] border border-tfps01-ink/12 px-2 py-[.22rem] font-tfps01-mono text-[.7rem] text-tfps01-ink/75">unit reference</span></p>
<h2 class="max-w-[26ch] text-[clamp(1.7rem,3.7vw,2.7rem)] leading-[1.06] font-semibold tracking-[-.032em] text-balance" id="tfps-01-h3">Which viewport utility, and when</h2>
<dl class="grid w-full gap-1">
<div class="grid grid-cols-[minmax(5.5rem,7rem)_1fr] items-start gap-[clamp(.75rem,2vw,1.75rem)] border-t border-tfps01-ink/10 py-[clamp(.8rem,1.6vh,1.1rem)]"><dt class="font-tfps01-mono text-[.95rem] text-tfps01-acc">min-h-dvh</dt><dd class="text-[.98rem] leading-[1.55] text-pretty text-tfps01-ink/75"><strong class="font-semibold text-tfps01-ink">Snapping panels.</strong> Height follows the live viewport, so panel height and scrollport height stay equal while toolbars animate.</dd></div>
<div class="grid grid-cols-[minmax(5.5rem,7rem)_1fr] items-start gap-[clamp(.75rem,2vw,1.75rem)] border-t border-tfps01-ink/10 py-[clamp(.8rem,1.6vh,1.1rem)]"><dt class="font-tfps01-mono text-[.95rem] text-tfps01-acc">min-h-svh</dt><dd class="text-[.98rem] leading-[1.55] text-pretty text-tfps01-ink/75"><strong class="font-semibold text-tfps01-ink">Panels with pinned bottom chrome.</strong> Always the short measurement, so a sticky action bar is never swallowed by the browser UI.</dd></div>
<div class="grid grid-cols-[minmax(5.5rem,7rem)_1fr] items-start gap-[clamp(.75rem,2vw,1.75rem)] border-t border-tfps01-ink/10 py-[clamp(.8rem,1.6vh,1.1rem)]"><dt class="font-tfps01-mono text-[.95rem] text-tfps01-acc">min-h-lvh</dt><dd class="text-[.98rem] leading-[1.55] text-pretty text-tfps01-ink/75"><strong class="font-semibold text-tfps01-ink">Decorative full-bleed art.</strong> Intentionally taller than the screen; fine for a background image, wrong for a snap target.</dd></div>
<div class="grid grid-cols-[minmax(5.5rem,7rem)_1fr] items-start gap-[clamp(.75rem,2vw,1.75rem)] border-t border-tfps01-ink/10 py-[clamp(.8rem,1.6vh,1.1rem)]"><dt class="font-tfps01-mono text-[.95rem] text-tfps01-warn">min-h-screen</dt><dd class="text-[.98rem] leading-[1.55] text-pretty text-tfps01-ink/75"><strong class="font-semibold text-tfps01-ink">Legacy <code>100vh</code>.</strong> Identical to <code>lvh</code>. Every browser that runs Tailwind v4 understands <code>dvh</code>, so there is no fallback left to write.</dd></div>
</dl>
</div>
</section>
<section class="relative flex min-h-screen snap-start items-center justify-center bg-[color-mix(in_oklab,var(--color-tfps01-warn)_9%,var(--color-tfps01-panel))] px-[clamp(1.25rem,5vw,4.5rem)] pt-[clamp(2rem,5vh,3.75rem)] pb-[calc(clamp(2rem,5vh,3.75rem)+4.5rem)] not-first:-mt-18 not-first:rounded-t-[2rem] not-first:border-t not-first:border-tfps01-ink/10 not-first:shadow-[0_-30px_64px_-34px_var(--color-tfps01-shade)] forced-colors:border-[CanvasText]" id="tfps-01-s4" aria-labelledby="tfps-01-h4">
<div class="grid w-full max-w-[64rem] justify-items-start gap-[clamp(.95rem,2.3vh,1.5rem)]">
<p class="flex flex-wrap items-center gap-3"><span class="font-tfps01-mono text-[.7rem] font-medium tracking-[.14em] uppercase text-tfps01-acc">04 / 04</span><span class="rounded-[.45rem] border border-tfps01-warn/40 px-2 py-[.22rem] font-tfps01-mono text-[.7rem] text-tfps01-warn">min-h-screen · labelled comparison</span></p>
<h2 class="max-w-[26ch] text-[clamp(1.7rem,3.7vw,2.7rem)] leading-[1.06] font-semibold tracking-[-.032em] text-balance" id="tfps-01-h4">This panel is the broken one</h2>
<p class="max-w-[56ch] text-[clamp(1rem,1.4vw,1.15rem)] leading-[1.62] text-pretty text-tfps01-ink/75 [&_code]:text-tfps01-ink">It is the only panel in the collection sized with <code>min-h-screen</code> (<code>100vh</code>). On a desktop it looks identical. Open the demo on an iPhone and this panel overshoots the screen: its footer line hides behind the address bar, and the snap edge lands mid-sentence.</p>
<p class="w-full border-t border-dashed border-tfps01-warn/45 pt-2.5 font-tfps01-mono text-[.8rem] text-tfps01-warn">Bottom line of panel 04 — visible here, hidden on iOS Safari.</p>
</div>
</section>
</div>
</div>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-tfps01-bg: #f7f8fa;
--color-tfps01-bg: oklch(0.979 0.003 264.5);
--color-tfps01-panel: #ffffff;
--color-tfps01-panel: oklch(1 0 0);
--color-tfps01-panel2: #eef1f6;
--color-tfps01-panel2: oklch(0.957 0.007 260.7);
--color-tfps01-ink: #0c0e14;
--color-tfps01-ink: oklch(16% .015 265);
--color-tfps01-acc: #2563eb;
--color-tfps01-acc: oklch(58% .19 258);
--color-tfps01-warn: #b45309;
--color-tfps01-warn: oklch(56% .13 62);
--color-tfps01-shade: rgb(12 14 20 / .2);
--font-tfps01-sans: 'Geist', system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-tfps01-mono: 'Geist Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace;
--animate-tfps01-bob: tfps01-bob 2.4s ease-in-out infinite;
@keyframes tfps01-bob {
0%,
100% {
translate: 0 -2px;
}
50% {
translate: 0 3px;
}
}
}
/* Theme state. Dark = OS preference XOR the toggle, so the switch always flips what you see —
the CSS original's checkbox did nothing for visitors whose OS was already dark. */
@custom-variant tfps01-dark {
@media (prefers-color-scheme: light) {
&:where(.tfps-01:has(#tfps-01-theme:checked), .tfps-01:has(#tfps-01-theme:checked) *) {
@slot;
}
}
@media (prefers-color-scheme: dark) {
&:where(.tfps-01:not(:has(#tfps-01-theme:checked)), .tfps-01:not(:has(#tfps-01-theme:checked)) *) {
@slot;
}
}
}
/* Root rule: the full-bleed contract, stated before the browser build compiles. */
.tfps-01 {
width: 100%;
min-height: 100svh;
@variant tfps01-dark {
--color-tfps01-bg: #07080b;
--color-tfps01-bg: oklch(0.135 0.007 269);
--color-tfps01-panel: #0b0d12;
--color-tfps01-panel: oklch(0.159 0.011 268);
--color-tfps01-panel2: #11141b;
--color-tfps01-panel2: oklch(0.191 0.015 266.8);
--color-tfps01-ink: #f2f4f9;
--color-tfps01-ink: oklch(0.967 0.007 268.5);
--color-tfps01-acc: #7dd3fc;
--color-tfps01-acc: oklch(0.828 0.101 230.3);
--color-tfps01-warn: #fbbf24;
--color-tfps01-warn: oklch(0.837 0.164 84.4);
--color-tfps01-shade: rgb(0 0 0 / .7);
}
}
```Here's a working CSS demo from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Tailwind Scroll Snap Full Screen Sections with 100dvh
Source: https://codefronts.com/layouts/tailwind-css-full-page-sections/tailwind-scroll-snap-full-screen-sections-with-100dvh/
The reference build for the collection, ported whole into utilities: four snapping full-viewport panels sized with min-h-dvh, one owned snap-y snap-mandatory scrollport, and a fourth panel deliberately built on min-h-screen so you can see the iOS bug it causes. A device diagram shows what the collapsing address bar does to vh, and a table settles dvh vs svh vs lvh in Tailwind's own vocabulary. Zero JavaScript.
## HTML
```html
<div class="tfps-01 relative isolate w-full min-h-svh bg-tfps01-bg font-tfps01-sans text-tfps01-ink antialiased [&_code]:font-tfps01-mono [&_code]:text-[.92em]">
<input class="peer sr-only" type="checkbox" id="tfps-01-theme">
<label for="tfps-01-theme" class="fixed top-[1.15rem] right-[1.15rem] z-80 grid size-11 cursor-pointer place-items-center rounded-full border border-tfps01-ink/10 bg-white/60 text-tfps01-ink hover:bg-white/85 tfps01-dark:bg-white/[.07] tfps01-dark:hover:bg-white/15 shadow-[0_8px_24px_-12px_var(--color-tfps01-shade)] backdrop-blur-[10px] backdrop-saturate-150 transition-[background-color,scale] duration-200 active:scale-95 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-3 peer-focus-visible:outline-tfps01-acc forced-colors:border-[CanvasText]">
<svg class="size-[1.05rem] tfps01-dark:hidden" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><path d="M12 2.6v2.3M12 19.1v2.3M2.6 12h2.3M19.1 12h2.3M5.3 5.3l1.7 1.7M17 17l1.7 1.7M18.7 5.3L17 7M7 17l-1.7 1.7" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/><circle cx="12" cy="12" r="4.4" stroke="currentColor" stroke-width="1.7"/></svg>
<svg class="hidden size-[1.05rem] tfps01-dark:block" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><path d="M20.5 14.4A8.4 8.4 0 0 1 9.6 3.5 8.6 8.6 0 1 0 20.5 14.4Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>
<span class="sr-only">Switch light or dark theme</span>
</label>
<div class="h-dvh overflow-x-hidden overflow-y-auto snap-y snap-mandatory scroll-smooth overscroll-y-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tfps01-acc [@media(max-height:40rem)]:snap-proximity motion-reduce:snap-none motion-reduce:scroll-auto" tabindex="0" role="group" aria-label="Full viewport section scroller, four panels">
<section class="relative flex min-h-dvh snap-start items-center justify-center bg-tfps01-panel px-[clamp(1.25rem,5vw,4.5rem)] pt-[clamp(2rem,5vh,3.75rem)] pb-[calc(clamp(2rem,5vh,3.75rem)+4.5rem)] even:bg-tfps01-panel2 not-first:-mt-18 not-first:rounded-t-[2rem] not-first:border-t not-first:border-tfps01-ink/10 not-first:shadow-[0_-30px_64px_-34px_var(--color-tfps01-shade)] forced-colors:border-[CanvasText]" id="tfps-01-s1" aria-labelledby="tfps-01-h1">
<div class="grid w-full max-w-[64rem] justify-items-start gap-[clamp(.95rem,2.3vh,1.5rem)] transition-[opacity,translate] duration-700 ease-out starting:translate-y-4 starting:opacity-0 motion-reduce:transition-none">
<p class="flex flex-wrap items-center gap-3"><span class="font-tfps01-mono text-[.7rem] font-medium tracking-[.14em] uppercase text-tfps01-acc">01 / 04</span><span class="rounded-[.45rem] border border-tfps01-ink/12 px-2 py-[.22rem] font-tfps01-mono text-[.7rem] text-tfps01-ink/75">min-h-dvh</span></p>
<h2 class="max-w-[22ch] text-[clamp(2.1rem,6.2vw,4.5rem)] leading-[1.02] font-semibold tracking-[-.042em] text-balance" id="tfps-01-h1">Panels that fill the screen — and keep filling it</h2>
<p class="max-w-[56ch] text-[clamp(1rem,1.4vw,1.15rem)] leading-[1.62] text-pretty text-tfps01-ink/75 [&_code]:text-tfps01-ink">Four snapping sections, one owned scroll container, zero JavaScript. Every panel is exactly as tall as the visible viewport, so the snap position never drifts when a mobile toolbar slides away.</p>
<ul class="flex flex-wrap gap-2.5">
<li class="grid gap-0.5 rounded-[.85rem] border px-3.5 py-2.5 font-tfps01-mono text-[.9rem] border-tfps01-acc bg-tfps01-acc/8 text-tfps01-acc">h-dvh<span class="font-tfps01-sans text-[.74rem] text-tfps01-acc">tracks the live viewport</span></li>
<li class="grid gap-0.5 rounded-[.85rem] border px-3.5 py-2.5 font-tfps01-mono text-[.9rem] border-tfps01-ink/10 bg-tfps01-ink/[.03]">h-svh<span class="font-tfps01-sans text-[.74rem] text-tfps01-ink/70">toolbars shown</span></li>
<li class="grid gap-0.5 rounded-[.85rem] border px-3.5 py-2.5 font-tfps01-mono text-[.9rem] border-tfps01-ink/10 bg-tfps01-ink/[.03]">h-lvh<span class="font-tfps01-sans text-[.74rem] text-tfps01-ink/70">toolbars hidden</span></li>
</ul>
<a href="#tfps-01-s2" class="inline-flex min-h-11 items-center gap-2.5 rounded-full pe-3 font-tfps01-mono text-[.82rem] text-tfps01-ink/75 no-underline transition-colors hover:text-tfps01-acc focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tfps01-acc"><svg class="size-[1.1rem] animate-tfps01-bob motion-reduce:animate-none" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><path d="M12 4.5v13.5M6.5 12.6 12 18.2l5.5-5.6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>Scroll — the next panel’s edge is already showing</a>
</div>
</section>
<section class="relative flex min-h-dvh snap-start items-center justify-center bg-tfps01-panel px-[clamp(1.25rem,5vw,4.5rem)] pt-[clamp(2rem,5vh,3.75rem)] pb-[calc(clamp(2rem,5vh,3.75rem)+4.5rem)] even:bg-tfps01-panel2 not-first:-mt-18 not-first:rounded-t-[2rem] not-first:border-t not-first:border-tfps01-ink/10 not-first:shadow-[0_-30px_64px_-34px_var(--color-tfps01-shade)] forced-colors:border-[CanvasText]" id="tfps-01-s2" aria-labelledby="tfps-01-h2">
<div class="grid w-full max-w-[64rem] justify-items-start gap-[clamp(.95rem,2.3vh,1.5rem)]">
<p class="flex flex-wrap items-center gap-3"><span class="font-tfps01-mono text-[.7rem] font-medium tracking-[.14em] uppercase text-tfps01-acc">02 / 04</span><span class="rounded-[.45rem] border border-tfps01-ink/12 px-2 py-[.22rem] font-tfps01-mono text-[.7rem] text-tfps01-ink/75">the address-bar problem</span></p>
<h2 class="max-w-[26ch] text-[clamp(1.7rem,3.7vw,2.7rem)] leading-[1.06] font-semibold tracking-[-.032em] text-balance" id="tfps-01-h2">What the collapsing toolbar actually does</h2>
<div class="grid w-full grid-cols-[repeat(auto-fit,minmax(15rem,1fr))] gap-[clamp(1rem,3vw,2rem)]">
<figure class="grid gap-3.5">
<div class="relative grid h-[clamp(11rem,26vh,16rem)] grid-rows-[auto_1fr] overflow-hidden rounded-[1.4rem] border border-tfps01-ink/10 bg-tfps01-panel shadow-[0_22px_44px_-28px_var(--color-tfps01-shade)] forced-colors:border-[CanvasText]">
<span class="border-b border-tfps01-ink/10 bg-tfps01-ink/[.04] px-3 py-2 font-tfps01-mono text-[.62rem] text-tfps01-ink/70">codefronts.com</span>
<div class="grid h-[calc(100%+2.4rem)] place-items-center pb-16 border-t-2 border-tfps01-warn bg-tfps01-warn/10 bg-[repeating-linear-gradient(135deg,color-mix(in_oklab,var(--color-tfps01-warn)_14%,transparent)_0_10px,transparent_10px_20px)] font-tfps01-mono text-[1.1rem] font-medium text-tfps01-warn"><span>100vh</span></div>
<span aria-hidden="true" class="absolute inset-x-0 bottom-0 flex h-7 items-center justify-around border-t border-tfps01-ink/10 bg-tfps01-panel/80 backdrop-blur-sm"><i class="size-1.5 rounded-full bg-tfps01-ink/30"></i><i class="size-1.5 rounded-full bg-tfps01-ink/30"></i><i class="size-1.5 rounded-full bg-tfps01-ink/30"></i></span>
<span class="absolute inset-x-0 bottom-7 bg-tfps01-warn px-3 py-1.5 text-center font-tfps01-mono text-[.62rem] text-white tfps01-dark:text-[#1c1305]">bottom 96px under the toolbar</span>
</div>
<figcaption class="text-[.88rem] leading-[1.5] text-pretty text-tfps01-ink/75">Legacy <code>100vh</code> (Tailwind’s <code>h-screen</code>) resolves against the <em>large</em> viewport. The panel is taller than the screen from the first paint.</figcaption>
</figure>
<figure class="grid gap-3.5">
<div class="relative grid h-[clamp(11rem,26vh,16rem)] grid-rows-[auto_1fr] overflow-hidden rounded-[1.4rem] border border-tfps01-ink/10 bg-tfps01-panel shadow-[0_22px_44px_-28px_var(--color-tfps01-shade)] forced-colors:border-[CanvasText]">
<span class="border-b border-tfps01-ink/10 bg-tfps01-ink/[.04] px-3 py-2 font-tfps01-mono text-[.62rem] text-tfps01-ink/70">codefronts.com</span>
<div class="mb-7 grid place-items-center border-t-2 border-tfps01-acc bg-tfps01-acc/12 font-tfps01-mono text-[1.1rem] font-medium text-tfps01-acc"><span>100dvh</span></div>
<span aria-hidden="true" class="absolute inset-x-0 bottom-0 flex h-7 items-center justify-around border-t border-tfps01-ink/10 bg-tfps01-panel/80 backdrop-blur-sm"><i class="size-1.5 rounded-full bg-tfps01-ink/30"></i><i class="size-1.5 rounded-full bg-tfps01-ink/30"></i><i class="size-1.5 rounded-full bg-tfps01-ink/30"></i></span>
</div>
<figcaption class="text-[.88rem] leading-[1.5] text-pretty text-tfps01-ink/75"><code>h-dvh</code> measures what the user can see right now, and re-measures as the browser chrome moves.</figcaption>
</figure>
</div>
</div>
</section>
<section class="relative flex min-h-dvh snap-start items-center justify-center bg-tfps01-panel px-[clamp(1.25rem,5vw,4.5rem)] pt-[clamp(2rem,5vh,3.75rem)] pb-[calc(clamp(2rem,5vh,3.75rem)+4.5rem)] even:bg-tfps01-panel2 not-first:-mt-18 not-first:rounded-t-[2rem] not-first:border-t not-first:border-tfps01-ink/10 not-first:shadow-[0_-30px_64px_-34px_var(--color-tfps01-shade)] forced-colors:border-[CanvasText]" id="tfps-01-s3" aria-labelledby="tfps-01-h3">
<div class="grid w-full max-w-[64rem] justify-items-start gap-[clamp(.95rem,2.3vh,1.5rem)]">
<p class="flex flex-wrap items-center gap-3"><span class="font-tfps01-mono text-[.7rem] font-medium tracking-[.14em] uppercase text-tfps01-acc">03 / 04</span><span class="rounded-[.45rem] border border-tfps01-ink/12 px-2 py-[.22rem] font-tfps01-mono text-[.7rem] text-tfps01-ink/75">unit reference</span></p>
<h2 class="max-w-[26ch] text-[clamp(1.7rem,3.7vw,2.7rem)] leading-[1.06] font-semibold tracking-[-.032em] text-balance" id="tfps-01-h3">Which viewport utility, and when</h2>
<dl class="grid w-full gap-1">
<div class="grid grid-cols-[minmax(5.5rem,7rem)_1fr] items-start gap-[clamp(.75rem,2vw,1.75rem)] border-t border-tfps01-ink/10 py-[clamp(.8rem,1.6vh,1.1rem)]"><dt class="font-tfps01-mono text-[.95rem] text-tfps01-acc">min-h-dvh</dt><dd class="text-[.98rem] leading-[1.55] text-pretty text-tfps01-ink/75"><strong class="font-semibold text-tfps01-ink">Snapping panels.</strong> Height follows the live viewport, so panel height and scrollport height stay equal while toolbars animate.</dd></div>
<div class="grid grid-cols-[minmax(5.5rem,7rem)_1fr] items-start gap-[clamp(.75rem,2vw,1.75rem)] border-t border-tfps01-ink/10 py-[clamp(.8rem,1.6vh,1.1rem)]"><dt class="font-tfps01-mono text-[.95rem] text-tfps01-acc">min-h-svh</dt><dd class="text-[.98rem] leading-[1.55] text-pretty text-tfps01-ink/75"><strong class="font-semibold text-tfps01-ink">Panels with pinned bottom chrome.</strong> Always the short measurement, so a sticky action bar is never swallowed by the browser UI.</dd></div>
<div class="grid grid-cols-[minmax(5.5rem,7rem)_1fr] items-start gap-[clamp(.75rem,2vw,1.75rem)] border-t border-tfps01-ink/10 py-[clamp(.8rem,1.6vh,1.1rem)]"><dt class="font-tfps01-mono text-[.95rem] text-tfps01-acc">min-h-lvh</dt><dd class="text-[.98rem] leading-[1.55] text-pretty text-tfps01-ink/75"><strong class="font-semibold text-tfps01-ink">Decorative full-bleed art.</strong> Intentionally taller than the screen; fine for a background image, wrong for a snap target.</dd></div>
<div class="grid grid-cols-[minmax(5.5rem,7rem)_1fr] items-start gap-[clamp(.75rem,2vw,1.75rem)] border-t border-tfps01-ink/10 py-[clamp(.8rem,1.6vh,1.1rem)]"><dt class="font-tfps01-mono text-[.95rem] text-tfps01-warn">min-h-screen</dt><dd class="text-[.98rem] leading-[1.55] text-pretty text-tfps01-ink/75"><strong class="font-semibold text-tfps01-ink">Legacy <code>100vh</code>.</strong> Identical to <code>lvh</code>. Every browser that runs Tailwind v4 understands <code>dvh</code>, so there is no fallback left to write.</dd></div>
</dl>
</div>
</section>
<section class="relative flex min-h-screen snap-start items-center justify-center bg-[color-mix(in_oklab,var(--color-tfps01-warn)_9%,var(--color-tfps01-panel))] px-[clamp(1.25rem,5vw,4.5rem)] pt-[clamp(2rem,5vh,3.75rem)] pb-[calc(clamp(2rem,5vh,3.75rem)+4.5rem)] not-first:-mt-18 not-first:rounded-t-[2rem] not-first:border-t not-first:border-tfps01-ink/10 not-first:shadow-[0_-30px_64px_-34px_var(--color-tfps01-shade)] forced-colors:border-[CanvasText]" id="tfps-01-s4" aria-labelledby="tfps-01-h4">
<div class="grid w-full max-w-[64rem] justify-items-start gap-[clamp(.95rem,2.3vh,1.5rem)]">
<p class="flex flex-wrap items-center gap-3"><span class="font-tfps01-mono text-[.7rem] font-medium tracking-[.14em] uppercase text-tfps01-acc">04 / 04</span><span class="rounded-[.45rem] border border-tfps01-warn/40 px-2 py-[.22rem] font-tfps01-mono text-[.7rem] text-tfps01-warn">min-h-screen · labelled comparison</span></p>
<h2 class="max-w-[26ch] text-[clamp(1.7rem,3.7vw,2.7rem)] leading-[1.06] font-semibold tracking-[-.032em] text-balance" id="tfps-01-h4">This panel is the broken one</h2>
<p class="max-w-[56ch] text-[clamp(1rem,1.4vw,1.15rem)] leading-[1.62] text-pretty text-tfps01-ink/75 [&_code]:text-tfps01-ink">It is the only panel in the collection sized with <code>min-h-screen</code> (<code>100vh</code>). On a desktop it looks identical. Open the demo on an iPhone and this panel overshoots the screen: its footer line hides behind the address bar, and the snap edge lands mid-sentence.</p>
<p class="w-full border-t border-dashed border-tfps01-warn/45 pt-2.5 font-tfps01-mono text-[.8rem] text-tfps01-warn">Bottom line of panel 04 — visible here, hidden on iOS Safari.</p>
</div>
</section>
</div>
</div>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-tfps01-bg: #f7f8fa;
--color-tfps01-bg: oklch(0.979 0.003 264.5);
--color-tfps01-panel: #ffffff;
--color-tfps01-panel: oklch(1 0 0);
--color-tfps01-panel2: #eef1f6;
--color-tfps01-panel2: oklch(0.957 0.007 260.7);
--color-tfps01-ink: #0c0e14;
--color-tfps01-ink: oklch(16% .015 265);
--color-tfps01-acc: #2563eb;
--color-tfps01-acc: oklch(58% .19 258);
--color-tfps01-warn: #b45309;
--color-tfps01-warn: oklch(56% .13 62);
--color-tfps01-shade: rgb(12 14 20 / .2);
--font-tfps01-sans: 'Geist', system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-tfps01-mono: 'Geist Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace;
--animate-tfps01-bob: tfps01-bob 2.4s ease-in-out infinite;
@keyframes tfps01-bob {
0%,
100% {
translate: 0 -2px;
}
50% {
translate: 0 3px;
}
}
}
/* Theme state. Dark = OS preference XOR the toggle, so the switch always flips what you see —
the CSS original's checkbox did nothing for visitors whose OS was already dark. */
@custom-variant tfps01-dark {
@media (prefers-color-scheme: light) {
&:where(.tfps-01:has(#tfps-01-theme:checked), .tfps-01:has(#tfps-01-theme:checked) *) {
@slot;
}
}
@media (prefers-color-scheme: dark) {
&:where(.tfps-01:not(:has(#tfps-01-theme:checked)), .tfps-01:not(:has(#tfps-01-theme:checked)) *) {
@slot;
}
}
}
/* Root rule: the full-bleed contract, stated before the browser build compiles. */
.tfps-01 {
width: 100%;
min-height: 100svh;
@variant tfps01-dark {
--color-tfps01-bg: #07080b;
--color-tfps01-bg: oklch(0.135 0.007 269);
--color-tfps01-panel: #0b0d12;
--color-tfps01-panel: oklch(0.159 0.011 268);
--color-tfps01-panel2: #11141b;
--color-tfps01-panel2: oklch(0.191 0.015 266.8);
--color-tfps01-ink: #f2f4f9;
--color-tfps01-ink: oklch(0.967 0.007 268.5);
--color-tfps01-acc: #7dd3fc;
--color-tfps01-acc: oklch(0.828 0.101 230.3);
--color-tfps01-warn: #fbbf24;
--color-tfps01-warn: oklch(0.837 0.164 84.4);
--color-tfps01-shade: rgb(0 0 0 / .7);
}
}
```How this works
Three utilities make the scroller. The CSS original needed a scroller rule, a panel rule and an @supports not (height:100dvh) fallback. In Tailwind it is two class lists:
<div class="h-dvh overflow-y-auto snap-y snap-mandatory
scroll-smooth overscroll-y-contain">
<section class="min-h-dvh snap-start">…</section>
<section class="min-h-dvh snap-start not-first:-mt-18
not-first:rounded-t-[2rem]">…</section>
</div>The fallback is gone, deliberately. Tailwind v4's generated sheet already requires Chrome 111, Safari 16.4 and Firefox 128 — every one of which ships dynamic viewport units. The vh fallback in the CSS original can never run under this stylesheet, so it was deleted rather than ported.
Why h-screen breaks on phones. h-screen is 100vh, which resolves against the large viewport — the height with browser UI retracted. Mobile Safari shows the address bar on load, so the bottom 60–100px of every panel sits under the toolbar, and the snap positions shift under the user's thumb when the bar collapses. h-dvh tracks the live viewport; h-svh is the always-safe short measure.
Theme state as a variant. The toggle is a visually hidden peer sr-only checkbox. One @custom-variant turns "OS dark XOR checked" into tfps01-dark:, which both flips the colour tokens (in the root rule) and swaps the sun/moon icons in markup.
Make it yours
- Swap
snap-mandatoryforsnap-proximityif panels hold long copy — proximity only snaps when the reader stops near an edge. - Change the visible lip by editing
not-first:-mt-18andpb-[calc(…+4.5rem)]together; set both to 0 and drop the radius for hard edge-to-edge panels. - Re-theme by editing the
--color-tfps01-*entries in@themeand the matching dark block in the root rule — every/10,/75opacity variant follows. - Use
min-h-svhon any panel with a pinned bottom action bar so mobile chrome can never cover it.
Gotchas — read before shipping
snap-yon<html>or<body>silently does nothing inside an iframe or any embedded context. Keep it on a container you own.scroll-smoothalso animates anchor jumps;motion-reduce:scroll-automust sit next to it, not in a separate stylesheet.- Mandatory snapping plus a panel taller than the scrollport creates content nobody can reach. Use
snap-proximityfor those — see demo 12. - A parent with
overflow-hiddenand atransformcan trap the scroller; keep it a direct, unfiltered child of the root.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 111+ | 16.4+ | 128+ | 111+ |
Scroll snap and dynamic viewport units are universal inside Tailwind v4's own floor (cascade layers, @property, color-mix()). starting: entry animations need Chrome 117 / Safari 17.5 / Firefox 129 and simply don't play elsewhere.