17 / 20
All 20 in 20 Tailwind CSS Sidebars
- 01Tailwind Accessible Sidebar Navigation ARIA Reference
- 02Tailwind Sidebar Focus Trap and Restore on Close
- 03Tailwind Inert Background Content Behind a Drawer
- 04Tailwind Sidebar Navigation Is Not a Tab List
- 05Tailwind Announcing Collapsed and Expanded Sidebar State
- 06Tailwind Roving Tabindex for Sidebar Keyboard Navigation
- 07Tailwind Persisting Sidebar Collapse State Across Reloads
- 08Tailwind Sidebar Scroll Containment with Overscroll Behavior
- 09Tailwind Nested Submenu Keyboard and Screen Reader Behavior
- 10Tailwind Resizable Sidebar with a Keyboard Accessible Handle
- 11Pure Tailwind Collapsible Sidebar with Checkbox State
- 12Tailwind Responsive Sidebar Breakpoint Behavior
- 13Pure Tailwind Icon Rail Sidebar with Accessible Labels
- 14Tailwind Dashboard Sidebar with Current Page State
- 15Pure Tailwind Multi Level Sidebar Menu Disclosure Pattern
- 16Pure Tailwind Accordion Sidebar with Details and Summary
- 17Pure Tailwind Sticky Full Height Sidebar Layout Mechanics
- 18Pure Tailwind Floating Sidebar Stacking and Overlay Order
- 19Tailwind Off Canvas Sidebar Drawer with Focus Return
- 20Pure Tailwind Sidebar Footer Pinning with Scrollable Nav
Pure Tailwind Sticky Full Height Sidebar Layout Mechanics
“My sticky sidebar is not sticking” is almost always the same two-line bug, and this demo is the fix stated as utilities: self-start so the rail is shorter than its containing block, and a scroll container that is the GRID, not the page. The rail never declares a height — the stretched grid column gives it one.
Mosaic by Cruip: a complete Tailwind admin dashboard template in HTML, React, Next.js and Vue · $69
Cruip · Mosaic by Cruip · Affiliate link
The code
<section class="tsb-17 block relative w-full min-h-svh bg-tsb17-bg text-tsb17-ink font-tsb17-sans leading-[1.55]" aria-label="Talon editor">
<div class="absolute top-3.5 right-3.5 z-60">
<input class="tsb-17-theme-input peer absolute inset-0 w-full h-full m-0 opacity-0 cursor-pointer" type="checkbox" id="tsb-17-theme">
<label class="grid place-items-center w-11 h-11 border border-tsb17-line bg-tsb17-surf2 text-tsb17-ink cursor-pointer peer-focus-visible:outline-2 peer-focus-visible:outline-tsb17-acc peer-focus-visible:outline-offset-2" for="tsb-17-theme">
<svg class="w-4.5 h-4.5" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="8.5" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M12 3.5a8.5 8.5 0 0 0 0 17Z" fill="currentColor"/></svg>
<span class="absolute w-px h-px overflow-hidden whitespace-nowrap [clip-path:inset(50%)]">Toggle dark theme</span>
</label>
</div>
<div class="grid grid-cols-[minmax(0,1fr)] h-dvh overflow-y-auto bg-tsb17-surf r40:grid-cols-[minmax(0,13rem)_minmax(0,1fr)]">
<nav class="min-w-0 self-start sticky top-0 z-2 px-1.5 py-2.5 bg-tsb17-surf2 border-r border-tsb17-line" aria-label="Talon explorer">
<p class="m-0 mb-1.5 px-2 font-tsb17-mono text-[0.7rem] tracking-[0.1em] uppercase text-tsb17-mut">Explorer</p>
<ul class="list-none m-0 p-0">
<li><a class="flex items-center min-w-0 min-h-11 px-2 py-1.5 font-tsb17-mono text-[0.8rem] text-tsb17-ink no-underline hover:bg-tsb17-surf focus-visible:outline-2 focus-visible:outline-tsb17-acc focus-visible:-outline-offset-2 aria-[current=page]:bg-tsb17-accsoft aria-[current=page]:text-tsb17-acc" href="#tsb-17-code" aria-current="page">index.ts</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2 py-1.5 font-tsb17-mono text-[0.8rem] text-tsb17-ink no-underline hover:bg-tsb17-surf focus-visible:outline-2 focus-visible:outline-tsb17-acc focus-visible:-outline-offset-2" href="#tsb-17-code">router.ts</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2 py-1.5 font-tsb17-mono text-[0.8rem] text-tsb17-ink no-underline hover:bg-tsb17-surf focus-visible:outline-2 focus-visible:outline-tsb17-acc focus-visible:-outline-offset-2" href="#tsb-17-code">tokens.css</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2 py-1.5 font-tsb17-mono text-[0.8rem] text-tsb17-ink no-underline hover:bg-tsb17-surf focus-visible:outline-2 focus-visible:outline-tsb17-acc focus-visible:-outline-offset-2" href="#tsb-17-code">shell.tsx</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2 py-1.5 font-tsb17-mono text-[0.8rem] text-tsb17-ink no-underline hover:bg-tsb17-surf focus-visible:outline-2 focus-visible:outline-tsb17-acc focus-visible:-outline-offset-2" href="#tsb-17-code">rail.tsx</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2 py-1.5 font-tsb17-mono text-[0.8rem] text-tsb17-ink no-underline hover:bg-tsb17-surf focus-visible:outline-2 focus-visible:outline-tsb17-acc focus-visible:-outline-offset-2" href="#tsb-17-code">measure.ts</a></li>
</ul>
</nav>
<div class="min-w-0 px-4 pt-3 pb-12" id="tsb-17-code">
<p class="m-0 mb-3 font-tsb17-mono text-[0.72rem] tracking-[0.06em] text-tsb17-acc">shell.tsx</p>
<pre class="m-0 font-tsb17-mono text-[0.78rem] leading-[1.85] text-tsb17-mut whitespace-pre">.shell {
display: grid;
grid-template-columns:
minmax(0, 13rem) minmax(0, 1fr);
block-size: 100dvh;
overflow-y: auto;
}
/* no height utility on the rail:
grid's align-items: stretch already
gives the column its full height */
.rail {
align-self: start;
position: sticky;
inset-block-start: 0;
}
.code { min-inline-size: 0; }
export function mount(root) {
const rail = root.querySelector('.rail');
const routes = ['overview', 'deploy'];
render(routes[0]);
return () => rail.remove();
}
observe(rail, () => {
measure(rail).height;
});
// keep scrolling — the rail holds its
// position because its containing block
// is taller than it is
scroll(0);
flush();
teardown();
const active = routes[0];
render(active);
observe(rail, () => measure(rail));
scroll(240);
flush();</pre>
</div>
</div>
</section><section class="tsb-17 block relative w-full min-h-svh bg-tsb17-bg text-tsb17-ink font-tsb17-sans leading-[1.55]" aria-label="Talon editor">
<div class="absolute top-3.5 right-3.5 z-60">
<input class="tsb-17-theme-input peer absolute inset-0 w-full h-full m-0 opacity-0 cursor-pointer" type="checkbox" id="tsb-17-theme">
<label class="grid place-items-center w-11 h-11 border border-tsb17-line bg-tsb17-surf2 text-tsb17-ink cursor-pointer peer-focus-visible:outline-2 peer-focus-visible:outline-tsb17-acc peer-focus-visible:outline-offset-2" for="tsb-17-theme">
<svg class="w-4.5 h-4.5" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="8.5" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M12 3.5a8.5 8.5 0 0 0 0 17Z" fill="currentColor"/></svg>
<span class="absolute w-px h-px overflow-hidden whitespace-nowrap [clip-path:inset(50%)]">Toggle dark theme</span>
</label>
</div>
<div class="grid grid-cols-[minmax(0,1fr)] h-dvh overflow-y-auto bg-tsb17-surf r40:grid-cols-[minmax(0,13rem)_minmax(0,1fr)]">
<nav class="min-w-0 self-start sticky top-0 z-2 px-1.5 py-2.5 bg-tsb17-surf2 border-r border-tsb17-line" aria-label="Talon explorer">
<p class="m-0 mb-1.5 px-2 font-tsb17-mono text-[0.7rem] tracking-[0.1em] uppercase text-tsb17-mut">Explorer</p>
<ul class="list-none m-0 p-0">
<li><a class="flex items-center min-w-0 min-h-11 px-2 py-1.5 font-tsb17-mono text-[0.8rem] text-tsb17-ink no-underline hover:bg-tsb17-surf focus-visible:outline-2 focus-visible:outline-tsb17-acc focus-visible:-outline-offset-2 aria-[current=page]:bg-tsb17-accsoft aria-[current=page]:text-tsb17-acc" href="#tsb-17-code" aria-current="page">index.ts</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2 py-1.5 font-tsb17-mono text-[0.8rem] text-tsb17-ink no-underline hover:bg-tsb17-surf focus-visible:outline-2 focus-visible:outline-tsb17-acc focus-visible:-outline-offset-2" href="#tsb-17-code">router.ts</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2 py-1.5 font-tsb17-mono text-[0.8rem] text-tsb17-ink no-underline hover:bg-tsb17-surf focus-visible:outline-2 focus-visible:outline-tsb17-acc focus-visible:-outline-offset-2" href="#tsb-17-code">tokens.css</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2 py-1.5 font-tsb17-mono text-[0.8rem] text-tsb17-ink no-underline hover:bg-tsb17-surf focus-visible:outline-2 focus-visible:outline-tsb17-acc focus-visible:-outline-offset-2" href="#tsb-17-code">shell.tsx</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2 py-1.5 font-tsb17-mono text-[0.8rem] text-tsb17-ink no-underline hover:bg-tsb17-surf focus-visible:outline-2 focus-visible:outline-tsb17-acc focus-visible:-outline-offset-2" href="#tsb-17-code">rail.tsx</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2 py-1.5 font-tsb17-mono text-[0.8rem] text-tsb17-ink no-underline hover:bg-tsb17-surf focus-visible:outline-2 focus-visible:outline-tsb17-acc focus-visible:-outline-offset-2" href="#tsb-17-code">measure.ts</a></li>
</ul>
</nav>
<div class="min-w-0 px-4 pt-3 pb-12" id="tsb-17-code">
<p class="m-0 mb-3 font-tsb17-mono text-[0.72rem] tracking-[0.06em] text-tsb17-acc">shell.tsx</p>
<pre class="m-0 font-tsb17-mono text-[0.78rem] leading-[1.85] text-tsb17-mut whitespace-pre">.shell {
display: grid;
grid-template-columns:
minmax(0, 13rem) minmax(0, 1fr);
block-size: 100dvh;
overflow-y: auto;
}
/* no height utility on the rail:
grid's align-items: stretch already
gives the column its full height */
.rail {
align-self: start;
position: sticky;
inset-block-start: 0;
}
.code { min-inline-size: 0; }
export function mount(root) {
const rail = root.querySelector('.rail');
const routes = ['overview', 'deploy'];
render(routes[0]);
return () => rail.remove();
}
observe(rail, () => {
measure(rail).height;
});
// keep scrolling — the rail holds its
// position because its containing block
// is taller than it is
scroll(0);
flush();
teardown();
const active = routes[0];
render(active);
observe(rail, () => measure(rail));
scroll(240);
flush();</pre>
</div>
</div>
</section>@import "tailwindcss";
@theme {
--color-tsb17-bg: #06070a;
--color-tsb17-bg: oklch(12% 0.008 260);
--color-tsb17-surf: #0d0f14;
--color-tsb17-surf: oklch(16% 0.01 260);
--color-tsb17-surf2: #13161d;
--color-tsb17-surf2: oklch(19% 0.012 260);
--color-tsb17-line: #1e2330;
--color-tsb17-line: oklch(25% 0.016 260);
--color-tsb17-ink: #e7ecf4;
--color-tsb17-ink: oklch(93% 0.01 260);
--color-tsb17-mut: #8b95a8;
--color-tsb17-mut: oklch(65% 0.02 260);
--color-tsb17-acc: #45e2e0;
--color-tsb17-acc: oklch(85% 0.13 190);
--color-tsb17-accsoft: #0d2b30;
--color-tsb17-accsoft: oklch(26% 0.04 195);
--font-tsb17-sans: system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-tsb17-mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
--breakpoint-r40: 40rem;
}
/* Root rule: the full-bleed contract. Note the app shell uses h-dvh, not min-height —
a sticky child needs a bounded, scrolling containing block. */
.tsb-17 {
width: 100%;
min-height: 100svh;
}
.tsb-17 *,
.tsb-17 *::before,
.tsb-17 *::after {
box-sizing: border-box;
}
/* Dark-first demo: the @theme block IS the dark palette, light mirror below. */
.tsb-17:has(.tsb-17-theme-input:checked) {
--color-tsb17-bg: #f5f7fa;
--color-tsb17-surf: #ffffff;
--color-tsb17-surf2: #eaeef4;
--color-tsb17-line: #d3dae4;
--color-tsb17-ink: #0d1117;
--color-tsb17-mut: #566072;
--color-tsb17-acc: #0a6d72;
--color-tsb17-acc: oklch(45% 0.09 195);
--color-tsb17-accsoft: #d6eef0;
}
@media (prefers-color-scheme: light) {
.tsb-17 {
--color-tsb17-bg: #f5f7fa;
--color-tsb17-surf: #ffffff;
--color-tsb17-surf2: #eaeef4;
--color-tsb17-line: #d3dae4;
--color-tsb17-ink: #0d1117;
--color-tsb17-mut: #566072;
--color-tsb17-acc: #0a6d72;
--color-tsb17-acc: oklch(45% 0.09 195);
--color-tsb17-accsoft: #d6eef0;
}
.tsb-17:has(.tsb-17-theme-input:checked) {
--color-tsb17-bg: #06070a;
--color-tsb17-surf: #0d0f14;
--color-tsb17-surf2: #13161d;
--color-tsb17-line: #1e2330;
--color-tsb17-ink: #e7ecf4;
--color-tsb17-mut: #8b95a8;
--color-tsb17-acc: #45e2e0;
--color-tsb17-acc: oklch(85% 0.13 190);
--color-tsb17-accsoft: #0d2b30;
}
}
@media (prefers-color-scheme: dark) {
.tsb-17:has(.tsb-17-theme-input:checked) {
--color-tsb17-bg: #f5f7fa;
--color-tsb17-surf: #ffffff;
--color-tsb17-surf2: #eaeef4;
--color-tsb17-line: #d3dae4;
--color-tsb17-ink: #0d1117;
--color-tsb17-mut: #566072;
--color-tsb17-acc: #0a6d72;
--color-tsb17-acc: oklch(45% 0.09 195);
--color-tsb17-accsoft: #d6eef0;
}
}@import "tailwindcss";
@theme {
--color-tsb17-bg: #06070a;
--color-tsb17-bg: oklch(12% 0.008 260);
--color-tsb17-surf: #0d0f14;
--color-tsb17-surf: oklch(16% 0.01 260);
--color-tsb17-surf2: #13161d;
--color-tsb17-surf2: oklch(19% 0.012 260);
--color-tsb17-line: #1e2330;
--color-tsb17-line: oklch(25% 0.016 260);
--color-tsb17-ink: #e7ecf4;
--color-tsb17-ink: oklch(93% 0.01 260);
--color-tsb17-mut: #8b95a8;
--color-tsb17-mut: oklch(65% 0.02 260);
--color-tsb17-acc: #45e2e0;
--color-tsb17-acc: oklch(85% 0.13 190);
--color-tsb17-accsoft: #0d2b30;
--color-tsb17-accsoft: oklch(26% 0.04 195);
--font-tsb17-sans: system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-tsb17-mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
--breakpoint-r40: 40rem;
}
/* Root rule: the full-bleed contract. Note the app shell uses h-dvh, not min-height —
a sticky child needs a bounded, scrolling containing block. */
.tsb-17 {
width: 100%;
min-height: 100svh;
}
.tsb-17 *,
.tsb-17 *::before,
.tsb-17 *::after {
box-sizing: border-box;
}
/* Dark-first demo: the @theme block IS the dark palette, light mirror below. */
.tsb-17:has(.tsb-17-theme-input:checked) {
--color-tsb17-bg: #f5f7fa;
--color-tsb17-surf: #ffffff;
--color-tsb17-surf2: #eaeef4;
--color-tsb17-line: #d3dae4;
--color-tsb17-ink: #0d1117;
--color-tsb17-mut: #566072;
--color-tsb17-acc: #0a6d72;
--color-tsb17-acc: oklch(45% 0.09 195);
--color-tsb17-accsoft: #d6eef0;
}
@media (prefers-color-scheme: light) {
.tsb-17 {
--color-tsb17-bg: #f5f7fa;
--color-tsb17-surf: #ffffff;
--color-tsb17-surf2: #eaeef4;
--color-tsb17-line: #d3dae4;
--color-tsb17-ink: #0d1117;
--color-tsb17-mut: #566072;
--color-tsb17-acc: #0a6d72;
--color-tsb17-acc: oklch(45% 0.09 195);
--color-tsb17-accsoft: #d6eef0;
}
.tsb-17:has(.tsb-17-theme-input:checked) {
--color-tsb17-bg: #06070a;
--color-tsb17-surf: #0d0f14;
--color-tsb17-surf2: #13161d;
--color-tsb17-line: #1e2330;
--color-tsb17-ink: #e7ecf4;
--color-tsb17-mut: #8b95a8;
--color-tsb17-acc: #45e2e0;
--color-tsb17-acc: oklch(85% 0.13 190);
--color-tsb17-accsoft: #0d2b30;
}
}
@media (prefers-color-scheme: dark) {
.tsb-17:has(.tsb-17-theme-input:checked) {
--color-tsb17-bg: #f5f7fa;
--color-tsb17-surf: #ffffff;
--color-tsb17-surf2: #eaeef4;
--color-tsb17-line: #d3dae4;
--color-tsb17-ink: #0d1117;
--color-tsb17-mut: #566072;
--color-tsb17-acc: #0a6d72;
--color-tsb17-acc: oklch(45% 0.09 195);
--color-tsb17-accsoft: #d6eef0;
}
}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-tsb17-bg: #06070a;
--color-tsb17-bg: oklch(12% 0.008 260);
--color-tsb17-surf: #0d0f14;
--color-tsb17-surf: oklch(16% 0.01 260);
--color-tsb17-surf2: #13161d;
--color-tsb17-surf2: oklch(19% 0.012 260);
--color-tsb17-line: #1e2330;
--color-tsb17-line: oklch(25% 0.016 260);
--color-tsb17-ink: #e7ecf4;
--color-tsb17-ink: oklch(93% 0.01 260);
--color-tsb17-mut: #8b95a8;
--color-tsb17-mut: oklch(65% 0.02 260);
--color-tsb17-acc: #45e2e0;
--color-tsb17-acc: oklch(85% 0.13 190);
--color-tsb17-accsoft: #0d2b30;
--color-tsb17-accsoft: oklch(26% 0.04 195);
--font-tsb17-sans: system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-tsb17-mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
--breakpoint-r40: 40rem;
}
/* Root rule: the full-bleed contract. Note the app shell uses h-dvh, not min-height —
a sticky child needs a bounded, scrolling containing block. */
.tsb-17 {
width: 100%;
min-height: 100svh;
}
.tsb-17 *,
.tsb-17 *::before,
.tsb-17 *::after {
box-sizing: border-box;
}
/* Dark-first demo: the @theme block IS the dark palette, light mirror below. */
.tsb-17:has(.tsb-17-theme-input:checked) {
--color-tsb17-bg: #f5f7fa;
--color-tsb17-surf: #ffffff;
--color-tsb17-surf2: #eaeef4;
--color-tsb17-line: #d3dae4;
--color-tsb17-ink: #0d1117;
--color-tsb17-mut: #566072;
--color-tsb17-acc: #0a6d72;
--color-tsb17-acc: oklch(45% 0.09 195);
--color-tsb17-accsoft: #d6eef0;
}
@media (prefers-color-scheme: light) {
.tsb-17 {
--color-tsb17-bg: #f5f7fa;
--color-tsb17-surf: #ffffff;
--color-tsb17-surf2: #eaeef4;
--color-tsb17-line: #d3dae4;
--color-tsb17-ink: #0d1117;
--color-tsb17-mut: #566072;
--color-tsb17-acc: #0a6d72;
--color-tsb17-acc: oklch(45% 0.09 195);
--color-tsb17-accsoft: #d6eef0;
}
.tsb-17:has(.tsb-17-theme-input:checked) {
--color-tsb17-bg: #06070a;
--color-tsb17-surf: #0d0f14;
--color-tsb17-surf2: #13161d;
--color-tsb17-line: #1e2330;
--color-tsb17-ink: #e7ecf4;
--color-tsb17-mut: #8b95a8;
--color-tsb17-acc: #45e2e0;
--color-tsb17-acc: oklch(85% 0.13 190);
--color-tsb17-accsoft: #0d2b30;
}
}
@media (prefers-color-scheme: dark) {
.tsb-17:has(.tsb-17-theme-input:checked) {
--color-tsb17-bg: #f5f7fa;
--color-tsb17-surf: #ffffff;
--color-tsb17-surf2: #eaeef4;
--color-tsb17-line: #d3dae4;
--color-tsb17-ink: #0d1117;
--color-tsb17-mut: #566072;
--color-tsb17-acc: #0a6d72;
--color-tsb17-acc: oklch(45% 0.09 195);
--color-tsb17-accsoft: #d6eef0;
}
}@import "tailwindcss";
@theme {
--color-tsb17-bg: #06070a;
--color-tsb17-bg: oklch(12% 0.008 260);
--color-tsb17-surf: #0d0f14;
--color-tsb17-surf: oklch(16% 0.01 260);
--color-tsb17-surf2: #13161d;
--color-tsb17-surf2: oklch(19% 0.012 260);
--color-tsb17-line: #1e2330;
--color-tsb17-line: oklch(25% 0.016 260);
--color-tsb17-ink: #e7ecf4;
--color-tsb17-ink: oklch(93% 0.01 260);
--color-tsb17-mut: #8b95a8;
--color-tsb17-mut: oklch(65% 0.02 260);
--color-tsb17-acc: #45e2e0;
--color-tsb17-acc: oklch(85% 0.13 190);
--color-tsb17-accsoft: #0d2b30;
--color-tsb17-accsoft: oklch(26% 0.04 195);
--font-tsb17-sans: system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-tsb17-mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
--breakpoint-r40: 40rem;
}
/* Root rule: the full-bleed contract. Note the app shell uses h-dvh, not min-height —
a sticky child needs a bounded, scrolling containing block. */
.tsb-17 {
width: 100%;
min-height: 100svh;
}
.tsb-17 *,
.tsb-17 *::before,
.tsb-17 *::after {
box-sizing: border-box;
}
/* Dark-first demo: the @theme block IS the dark palette, light mirror below. */
.tsb-17:has(.tsb-17-theme-input:checked) {
--color-tsb17-bg: #f5f7fa;
--color-tsb17-surf: #ffffff;
--color-tsb17-surf2: #eaeef4;
--color-tsb17-line: #d3dae4;
--color-tsb17-ink: #0d1117;
--color-tsb17-mut: #566072;
--color-tsb17-acc: #0a6d72;
--color-tsb17-acc: oklch(45% 0.09 195);
--color-tsb17-accsoft: #d6eef0;
}
@media (prefers-color-scheme: light) {
.tsb-17 {
--color-tsb17-bg: #f5f7fa;
--color-tsb17-surf: #ffffff;
--color-tsb17-surf2: #eaeef4;
--color-tsb17-line: #d3dae4;
--color-tsb17-ink: #0d1117;
--color-tsb17-mut: #566072;
--color-tsb17-acc: #0a6d72;
--color-tsb17-acc: oklch(45% 0.09 195);
--color-tsb17-accsoft: #d6eef0;
}
.tsb-17:has(.tsb-17-theme-input:checked) {
--color-tsb17-bg: #06070a;
--color-tsb17-surf: #0d0f14;
--color-tsb17-surf2: #13161d;
--color-tsb17-line: #1e2330;
--color-tsb17-ink: #e7ecf4;
--color-tsb17-mut: #8b95a8;
--color-tsb17-acc: #45e2e0;
--color-tsb17-acc: oklch(85% 0.13 190);
--color-tsb17-accsoft: #0d2b30;
}
}
@media (prefers-color-scheme: dark) {
.tsb-17:has(.tsb-17-theme-input:checked) {
--color-tsb17-bg: #f5f7fa;
--color-tsb17-surf: #ffffff;
--color-tsb17-surf2: #eaeef4;
--color-tsb17-line: #d3dae4;
--color-tsb17-ink: #0d1117;
--color-tsb17-mut: #566072;
--color-tsb17-acc: #0a6d72;
--color-tsb17-acc: oklch(45% 0.09 195);
--color-tsb17-accsoft: #d6eef0;
}
}<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=IBM+Plex+Mono:wght@400;500;600&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=IBM+Plex+Mono:wght@400;500;600&display=swap">What changed in the port
Which parts became utilities, which stayed CSS, and why.
- The entire demo is three utilities in the right places:
self-start sticky top-0on the rail,h-dvh overflow-y-autoon the shell, andmin-w-0on the content. In utilities the fix is visible on the elements it applies to, which is a real improvement over hunting a stylesheet foralign-self. - The rail carries NO height utility, and the CSS comment says so explicitly — in a class-list world the absence of a class is invisible, so the note has to live somewhere.
h-dvhrather thanmin-h-svhon the shell: sticky needs a bounded scrolling ancestor, andmin-h-*does not bound anything.z-2is a small number doing important work; without it the scrolling code column paints over the pinned rail.- The
<pre>keepswhitespace-preexplicitly even though it is the UA default, because Tailwind's preflight is not the only thing in the cascade and a global reset elsewhere could change it. - Dark-first palette with the light mirror in the stylesheet, same inverted structure as demos 08 and 10.
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: Pure Tailwind Sticky Full Height Sidebar Layout Mechanics
Source: https://codefronts.com/navigation/tailwind-css-sidebars/pure-tailwind-sticky-full-height-sidebar-layout-mechanics/
“My sticky sidebar is not sticking” is almost always the same two-line bug, and this demo is the fix stated as utilities: self-start so the rail is shorter than its containing block, and a scroll container that is the GRID, not the page. The rail never declares a height — the stretched grid column gives it one.
## HTML
```html
<section class="tsb-17 block relative w-full min-h-svh bg-tsb17-bg text-tsb17-ink font-tsb17-sans leading-[1.55]" aria-label="Talon editor">
<div class="absolute top-3.5 right-3.5 z-60">
<input class="tsb-17-theme-input peer absolute inset-0 w-full h-full m-0 opacity-0 cursor-pointer" type="checkbox" id="tsb-17-theme">
<label class="grid place-items-center w-11 h-11 border border-tsb17-line bg-tsb17-surf2 text-tsb17-ink cursor-pointer peer-focus-visible:outline-2 peer-focus-visible:outline-tsb17-acc peer-focus-visible:outline-offset-2" for="tsb-17-theme">
<svg class="w-4.5 h-4.5" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="8.5" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M12 3.5a8.5 8.5 0 0 0 0 17Z" fill="currentColor"/></svg>
<span class="absolute w-px h-px overflow-hidden whitespace-nowrap [clip-path:inset(50%)]">Toggle dark theme</span>
</label>
</div>
<div class="grid grid-cols-[minmax(0,1fr)] h-dvh overflow-y-auto bg-tsb17-surf r40:grid-cols-[minmax(0,13rem)_minmax(0,1fr)]">
<nav class="min-w-0 self-start sticky top-0 z-2 px-1.5 py-2.5 bg-tsb17-surf2 border-r border-tsb17-line" aria-label="Talon explorer">
<p class="m-0 mb-1.5 px-2 font-tsb17-mono text-[0.7rem] tracking-[0.1em] uppercase text-tsb17-mut">Explorer</p>
<ul class="list-none m-0 p-0">
<li><a class="flex items-center min-w-0 min-h-11 px-2 py-1.5 font-tsb17-mono text-[0.8rem] text-tsb17-ink no-underline hover:bg-tsb17-surf focus-visible:outline-2 focus-visible:outline-tsb17-acc focus-visible:-outline-offset-2 aria-[current=page]:bg-tsb17-accsoft aria-[current=page]:text-tsb17-acc" href="#tsb-17-code" aria-current="page">index.ts</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2 py-1.5 font-tsb17-mono text-[0.8rem] text-tsb17-ink no-underline hover:bg-tsb17-surf focus-visible:outline-2 focus-visible:outline-tsb17-acc focus-visible:-outline-offset-2" href="#tsb-17-code">router.ts</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2 py-1.5 font-tsb17-mono text-[0.8rem] text-tsb17-ink no-underline hover:bg-tsb17-surf focus-visible:outline-2 focus-visible:outline-tsb17-acc focus-visible:-outline-offset-2" href="#tsb-17-code">tokens.css</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2 py-1.5 font-tsb17-mono text-[0.8rem] text-tsb17-ink no-underline hover:bg-tsb17-surf focus-visible:outline-2 focus-visible:outline-tsb17-acc focus-visible:-outline-offset-2" href="#tsb-17-code">shell.tsx</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2 py-1.5 font-tsb17-mono text-[0.8rem] text-tsb17-ink no-underline hover:bg-tsb17-surf focus-visible:outline-2 focus-visible:outline-tsb17-acc focus-visible:-outline-offset-2" href="#tsb-17-code">rail.tsx</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2 py-1.5 font-tsb17-mono text-[0.8rem] text-tsb17-ink no-underline hover:bg-tsb17-surf focus-visible:outline-2 focus-visible:outline-tsb17-acc focus-visible:-outline-offset-2" href="#tsb-17-code">measure.ts</a></li>
</ul>
</nav>
<div class="min-w-0 px-4 pt-3 pb-12" id="tsb-17-code">
<p class="m-0 mb-3 font-tsb17-mono text-[0.72rem] tracking-[0.06em] text-tsb17-acc">shell.tsx</p>
<pre class="m-0 font-tsb17-mono text-[0.78rem] leading-[1.85] text-tsb17-mut whitespace-pre">.shell {
display: grid;
grid-template-columns:
minmax(0, 13rem) minmax(0, 1fr);
block-size: 100dvh;
overflow-y: auto;
}
/* no height utility on the rail:
grid's align-items: stretch already
gives the column its full height */
.rail {
align-self: start;
position: sticky;
inset-block-start: 0;
}
.code { min-inline-size: 0; }
export function mount(root) {
const rail = root.querySelector('.rail');
const routes = ['overview', 'deploy'];
render(routes[0]);
return () => rail.remove();
}
observe(rail, () => {
measure(rail).height;
});
// keep scrolling — the rail holds its
// position because its containing block
// is taller than it is
scroll(0);
flush();
teardown();
const active = routes[0];
render(active);
observe(rail, () => measure(rail));
scroll(240);
flush();</pre>
</div>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-tsb17-bg: #06070a;
--color-tsb17-bg: oklch(12% 0.008 260);
--color-tsb17-surf: #0d0f14;
--color-tsb17-surf: oklch(16% 0.01 260);
--color-tsb17-surf2: #13161d;
--color-tsb17-surf2: oklch(19% 0.012 260);
--color-tsb17-line: #1e2330;
--color-tsb17-line: oklch(25% 0.016 260);
--color-tsb17-ink: #e7ecf4;
--color-tsb17-ink: oklch(93% 0.01 260);
--color-tsb17-mut: #8b95a8;
--color-tsb17-mut: oklch(65% 0.02 260);
--color-tsb17-acc: #45e2e0;
--color-tsb17-acc: oklch(85% 0.13 190);
--color-tsb17-accsoft: #0d2b30;
--color-tsb17-accsoft: oklch(26% 0.04 195);
--font-tsb17-sans: system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-tsb17-mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
--breakpoint-r40: 40rem;
}
/* Root rule: the full-bleed contract. Note the app shell uses h-dvh, not min-height —
a sticky child needs a bounded, scrolling containing block. */
.tsb-17 {
width: 100%;
min-height: 100svh;
}
.tsb-17 *,
.tsb-17 *::before,
.tsb-17 *::after {
box-sizing: border-box;
}
/* Dark-first demo: the @theme block IS the dark palette, light mirror below. */
.tsb-17:has(.tsb-17-theme-input:checked) {
--color-tsb17-bg: #f5f7fa;
--color-tsb17-surf: #ffffff;
--color-tsb17-surf2: #eaeef4;
--color-tsb17-line: #d3dae4;
--color-tsb17-ink: #0d1117;
--color-tsb17-mut: #566072;
--color-tsb17-acc: #0a6d72;
--color-tsb17-acc: oklch(45% 0.09 195);
--color-tsb17-accsoft: #d6eef0;
}
@media (prefers-color-scheme: light) {
.tsb-17 {
--color-tsb17-bg: #f5f7fa;
--color-tsb17-surf: #ffffff;
--color-tsb17-surf2: #eaeef4;
--color-tsb17-line: #d3dae4;
--color-tsb17-ink: #0d1117;
--color-tsb17-mut: #566072;
--color-tsb17-acc: #0a6d72;
--color-tsb17-acc: oklch(45% 0.09 195);
--color-tsb17-accsoft: #d6eef0;
}
.tsb-17:has(.tsb-17-theme-input:checked) {
--color-tsb17-bg: #06070a;
--color-tsb17-surf: #0d0f14;
--color-tsb17-surf2: #13161d;
--color-tsb17-line: #1e2330;
--color-tsb17-ink: #e7ecf4;
--color-tsb17-mut: #8b95a8;
--color-tsb17-acc: #45e2e0;
--color-tsb17-acc: oklch(85% 0.13 190);
--color-tsb17-accsoft: #0d2b30;
}
}
@media (prefers-color-scheme: dark) {
.tsb-17:has(.tsb-17-theme-input:checked) {
--color-tsb17-bg: #f5f7fa;
--color-tsb17-surf: #ffffff;
--color-tsb17-surf2: #eaeef4;
--color-tsb17-line: #d3dae4;
--color-tsb17-ink: #0d1117;
--color-tsb17-mut: #566072;
--color-tsb17-acc: #0a6d72;
--color-tsb17-acc: oklch(45% 0.09 195);
--color-tsb17-accsoft: #d6eef0;
}
}
```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: Pure Tailwind Sticky Full Height Sidebar Layout Mechanics
Source: https://codefronts.com/navigation/tailwind-css-sidebars/pure-tailwind-sticky-full-height-sidebar-layout-mechanics/
“My sticky sidebar is not sticking” is almost always the same two-line bug, and this demo is the fix stated as utilities: self-start so the rail is shorter than its containing block, and a scroll container that is the GRID, not the page. The rail never declares a height — the stretched grid column gives it one.
## HTML
```html
<section class="tsb-17 block relative w-full min-h-svh bg-tsb17-bg text-tsb17-ink font-tsb17-sans leading-[1.55]" aria-label="Talon editor">
<div class="absolute top-3.5 right-3.5 z-60">
<input class="tsb-17-theme-input peer absolute inset-0 w-full h-full m-0 opacity-0 cursor-pointer" type="checkbox" id="tsb-17-theme">
<label class="grid place-items-center w-11 h-11 border border-tsb17-line bg-tsb17-surf2 text-tsb17-ink cursor-pointer peer-focus-visible:outline-2 peer-focus-visible:outline-tsb17-acc peer-focus-visible:outline-offset-2" for="tsb-17-theme">
<svg class="w-4.5 h-4.5" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="8.5" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M12 3.5a8.5 8.5 0 0 0 0 17Z" fill="currentColor"/></svg>
<span class="absolute w-px h-px overflow-hidden whitespace-nowrap [clip-path:inset(50%)]">Toggle dark theme</span>
</label>
</div>
<div class="grid grid-cols-[minmax(0,1fr)] h-dvh overflow-y-auto bg-tsb17-surf r40:grid-cols-[minmax(0,13rem)_minmax(0,1fr)]">
<nav class="min-w-0 self-start sticky top-0 z-2 px-1.5 py-2.5 bg-tsb17-surf2 border-r border-tsb17-line" aria-label="Talon explorer">
<p class="m-0 mb-1.5 px-2 font-tsb17-mono text-[0.7rem] tracking-[0.1em] uppercase text-tsb17-mut">Explorer</p>
<ul class="list-none m-0 p-0">
<li><a class="flex items-center min-w-0 min-h-11 px-2 py-1.5 font-tsb17-mono text-[0.8rem] text-tsb17-ink no-underline hover:bg-tsb17-surf focus-visible:outline-2 focus-visible:outline-tsb17-acc focus-visible:-outline-offset-2 aria-[current=page]:bg-tsb17-accsoft aria-[current=page]:text-tsb17-acc" href="#tsb-17-code" aria-current="page">index.ts</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2 py-1.5 font-tsb17-mono text-[0.8rem] text-tsb17-ink no-underline hover:bg-tsb17-surf focus-visible:outline-2 focus-visible:outline-tsb17-acc focus-visible:-outline-offset-2" href="#tsb-17-code">router.ts</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2 py-1.5 font-tsb17-mono text-[0.8rem] text-tsb17-ink no-underline hover:bg-tsb17-surf focus-visible:outline-2 focus-visible:outline-tsb17-acc focus-visible:-outline-offset-2" href="#tsb-17-code">tokens.css</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2 py-1.5 font-tsb17-mono text-[0.8rem] text-tsb17-ink no-underline hover:bg-tsb17-surf focus-visible:outline-2 focus-visible:outline-tsb17-acc focus-visible:-outline-offset-2" href="#tsb-17-code">shell.tsx</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2 py-1.5 font-tsb17-mono text-[0.8rem] text-tsb17-ink no-underline hover:bg-tsb17-surf focus-visible:outline-2 focus-visible:outline-tsb17-acc focus-visible:-outline-offset-2" href="#tsb-17-code">rail.tsx</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2 py-1.5 font-tsb17-mono text-[0.8rem] text-tsb17-ink no-underline hover:bg-tsb17-surf focus-visible:outline-2 focus-visible:outline-tsb17-acc focus-visible:-outline-offset-2" href="#tsb-17-code">measure.ts</a></li>
</ul>
</nav>
<div class="min-w-0 px-4 pt-3 pb-12" id="tsb-17-code">
<p class="m-0 mb-3 font-tsb17-mono text-[0.72rem] tracking-[0.06em] text-tsb17-acc">shell.tsx</p>
<pre class="m-0 font-tsb17-mono text-[0.78rem] leading-[1.85] text-tsb17-mut whitespace-pre">.shell {
display: grid;
grid-template-columns:
minmax(0, 13rem) minmax(0, 1fr);
block-size: 100dvh;
overflow-y: auto;
}
/* no height utility on the rail:
grid's align-items: stretch already
gives the column its full height */
.rail {
align-self: start;
position: sticky;
inset-block-start: 0;
}
.code { min-inline-size: 0; }
export function mount(root) {
const rail = root.querySelector('.rail');
const routes = ['overview', 'deploy'];
render(routes[0]);
return () => rail.remove();
}
observe(rail, () => {
measure(rail).height;
});
// keep scrolling — the rail holds its
// position because its containing block
// is taller than it is
scroll(0);
flush();
teardown();
const active = routes[0];
render(active);
observe(rail, () => measure(rail));
scroll(240);
flush();</pre>
</div>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-tsb17-bg: #06070a;
--color-tsb17-bg: oklch(12% 0.008 260);
--color-tsb17-surf: #0d0f14;
--color-tsb17-surf: oklch(16% 0.01 260);
--color-tsb17-surf2: #13161d;
--color-tsb17-surf2: oklch(19% 0.012 260);
--color-tsb17-line: #1e2330;
--color-tsb17-line: oklch(25% 0.016 260);
--color-tsb17-ink: #e7ecf4;
--color-tsb17-ink: oklch(93% 0.01 260);
--color-tsb17-mut: #8b95a8;
--color-tsb17-mut: oklch(65% 0.02 260);
--color-tsb17-acc: #45e2e0;
--color-tsb17-acc: oklch(85% 0.13 190);
--color-tsb17-accsoft: #0d2b30;
--color-tsb17-accsoft: oklch(26% 0.04 195);
--font-tsb17-sans: system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-tsb17-mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
--breakpoint-r40: 40rem;
}
/* Root rule: the full-bleed contract. Note the app shell uses h-dvh, not min-height —
a sticky child needs a bounded, scrolling containing block. */
.tsb-17 {
width: 100%;
min-height: 100svh;
}
.tsb-17 *,
.tsb-17 *::before,
.tsb-17 *::after {
box-sizing: border-box;
}
/* Dark-first demo: the @theme block IS the dark palette, light mirror below. */
.tsb-17:has(.tsb-17-theme-input:checked) {
--color-tsb17-bg: #f5f7fa;
--color-tsb17-surf: #ffffff;
--color-tsb17-surf2: #eaeef4;
--color-tsb17-line: #d3dae4;
--color-tsb17-ink: #0d1117;
--color-tsb17-mut: #566072;
--color-tsb17-acc: #0a6d72;
--color-tsb17-acc: oklch(45% 0.09 195);
--color-tsb17-accsoft: #d6eef0;
}
@media (prefers-color-scheme: light) {
.tsb-17 {
--color-tsb17-bg: #f5f7fa;
--color-tsb17-surf: #ffffff;
--color-tsb17-surf2: #eaeef4;
--color-tsb17-line: #d3dae4;
--color-tsb17-ink: #0d1117;
--color-tsb17-mut: #566072;
--color-tsb17-acc: #0a6d72;
--color-tsb17-acc: oklch(45% 0.09 195);
--color-tsb17-accsoft: #d6eef0;
}
.tsb-17:has(.tsb-17-theme-input:checked) {
--color-tsb17-bg: #06070a;
--color-tsb17-surf: #0d0f14;
--color-tsb17-surf2: #13161d;
--color-tsb17-line: #1e2330;
--color-tsb17-ink: #e7ecf4;
--color-tsb17-mut: #8b95a8;
--color-tsb17-acc: #45e2e0;
--color-tsb17-acc: oklch(85% 0.13 190);
--color-tsb17-accsoft: #0d2b30;
}
}
@media (prefers-color-scheme: dark) {
.tsb-17:has(.tsb-17-theme-input:checked) {
--color-tsb17-bg: #f5f7fa;
--color-tsb17-surf: #ffffff;
--color-tsb17-surf2: #eaeef4;
--color-tsb17-line: #d3dae4;
--color-tsb17-ink: #0d1117;
--color-tsb17-mut: #566072;
--color-tsb17-acc: #0a6d72;
--color-tsb17-acc: oklch(45% 0.09 195);
--color-tsb17-accsoft: #d6eef0;
}
}
```How this works
Sticky needs room to move inside its containing block. A grid item stretched to the full column height has zero slack, so it can never offset — it looks like position: sticky is being ignored. self-start shrinks the rail to its content, which gives the containing block the extra height sticky requires:
<div class="grid r40:grid-cols-[minmax(0,13rem)_minmax(0,1fr)] h-dvh overflow-y-auto">
<nav class="self-start sticky top-0 z-2">…</nav>
<div class="min-w-0">…</div>And the scroll container has to be the right element. Sticky is relative to the nearest scrolling ancestor. Here that is the shell (h-dvh overflow-y-auto), which is why the rail pins to the top of the app rather than to the viewport.
The rail has no height utility, deliberately. It is the demo's whole point: a full-height rail is a consequence of the grid, not something you declare. Adding h-full would undo self-start and break the stickiness — the most common “fix” that makes it worse.
The code column is the content. A long <pre> gives the shell something to scroll, which is the only way to demonstrate that any of this works.
Make it yours
- Offset the pin with
top-2instead oftop-0if the app has a fixed header. - Make the rail itself scrollable when its content is long: add
max-h-dvh overflow-y-auto [overscroll-behavior:contain]— demo 08's technique, composable with this one. - Rail width: the single
r40:grid-cols-[minmax(0,13rem)_minmax(0,1fr)]. - Retheme through
--color-tsb17-acc; this demo is dark-first with a cyan accent, and the light mirror is in the stylesheet. - Swap
h-dvhforh-svhif you would rather the shell not resize with mobile browser chrome. - Move the scroll container to the page (
<body>) instead and the sameself-start sticky top-0still works — that is the version to use inside a documentation site.
Gotchas — read before shipping
- Never put
h-full(or any height) on a sticky grid item. A stretched item has no slack in its containing block and cannot offset. overflow: hiddenoroverflow: autoon ANY ancestor between the rail and the scroll container silently kills sticky. It is the second most common cause after the stretch bug.- Sticky is relative to the nearest scrolling ancestor, not the viewport. If the shell scrolls, the rail pins inside the shell.
- A sticky element needs a
z-above the content it overlaps, or the scrolling column paints over it. min-w-0on the content column is required or a long<pre>line expands the grid and the rail's width drifts.h-dvhresizes with mobile browser chrome, which can cause a visible reflow mid-scroll;h-svhis the stable alternative.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 111+ | 16.4+ | 128+ | 111+ |
position: sticky is Baseline since 2017 and dvh units since 2023; Tailwind v4's generated sheet is the binding floor. The layout itself is plain grid.