Pure TailwindMIT licensed

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.

Live Demo
Plain CSSTailwind v4
Try it

Mosaic by Cruip: a complete Tailwind admin dashboard template in HTML, React, Next.js and Vue · $69

Cruip · Mosaic by Cruip · Affiliate link

View Mosaic dashboard

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 () =&gt; rail.remove();
}

observe(rail, () =&gt; {
  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, () =&gt; 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;
  }
}

Setup — do this once per project

Skip this if your project already runs Tailwind v4.

1Load Tailwind
<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.

2Add the stylesheetRequiredthe @theme block is what makes the custom utilities exist
@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;
  }
}
3Fonts used by this demooptional — skip if you have your own type stack
<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-0 on the rail, h-dvh overflow-y-auto on the shell, and min-w-0 on the content. In utilities the fix is visible on the elements it applies to, which is a real improvement over hunting a stylesheet for align-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-dvh rather than min-h-svh on the shell: sticky needs a bounded scrolling ancestor, and min-h-* does not bound anything.
  • z-2 is a small number doing important work; without it the scrolling code column paints over the pinned rail.
  • The <pre> keeps whitespace-pre explicitly 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.
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
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/

&ldquo;My sticky sidebar is not sticking&rdquo; 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 &mdash; 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 () =&gt; rail.remove();
}

observe(rail, () =&gt; {
  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, () =&gt; 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-2 instead of top-0 if 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-dvh for h-svh if you would rather the shell not resize with mobile browser chrome.
  • Move the scroll container to the page (<body>) instead and the same self-start sticky top-0 still 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: hidden or overflow: auto on 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-0 on the content column is required or a long <pre> line expands the grid and the rail's width drifts.
  • h-dvh resizes with mobile browser chrome, which can cause a visible reflow mid-scroll; h-svh is the stable alternative.

Browser support

ChromeSafariFirefoxEdge
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.

Techniques used in this demo

Utilitiesfirst-class utility or variant

Theme tokensset in @theme, used by a utility

Search CodeFronts

Loading…