12 / 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
Tailwind Responsive Sidebar Breakpoint Behavior
The same sidebar is a persistent rail on a laptop and a modal drawer on a phone — which means its ROLE changes with the viewport, not just its width. The port's discipline is the interesting part: the breakpoint is declared once, in matchMedia, and the class list keys off the resulting data-mode attribute rather than repeating the number as a Tailwind variant.
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-12 block relative w-full min-h-svh bg-tsb12-bg text-tsb12-ink font-tsb12-sans leading-[1.55]" aria-label="Orbit banking">
<div class="absolute top-3.5 right-3.5 z-60">
<input class="tsb-12-theme-input peer absolute inset-0 w-full h-full m-0 opacity-0 cursor-pointer" type="checkbox" id="tsb-12-theme">
<label class="grid place-items-center w-11 h-11 rounded-[0.9rem] border border-tsb12-line bg-tsb12-surf text-tsb12-ink cursor-pointer shadow-[0_0.3rem_0.8rem_oklch(20%_0.04_292/0.14)] peer-focus-visible:outline-2 peer-focus-visible:outline-tsb12-acc peer-focus-visible:outline-offset-2" for="tsb-12-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="group relative overflow-hidden flex min-w-0 min-h-svh bg-tsb12-surf" id="tsb-12-app" data-mode="persistent">
<div class="flex-1 flex flex-col min-w-0 transition-opacity duration-200 inert:opacity-50 motion-reduce:transition-none" id="tsb-12-content">
<div class="flex items-center gap-2 py-2.5 pl-3.5 pr-[4.2rem] border-b border-tsb12-line">
<button class="grid place-items-center w-11 h-11 rounded-[0.8rem] border-0 bg-tsb12-surf2 text-tsb12-ink cursor-pointer [&>svg]:w-4.5 [&>svg]:h-4.5 group-data-[mode=persistent]:hidden focus-visible:outline-2 focus-visible:outline-tsb12-acc focus-visible:outline-offset-2" type="button" id="tsb-12-open" aria-expanded="false" aria-controls="tsb-12-rail">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 7h16M4 12h16M4 17h16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<span class="absolute w-px h-px overflow-hidden whitespace-nowrap [clip-path:inset(50%)]">Open navigation</span>
</button>
<p class="mr-auto m-0 font-tsb12-display text-[1.15rem] font-bold tracking-[-0.02em] text-tsb12-acc">Orbit</p>
<a class="inline-flex items-center min-h-11 px-2.5 text-[0.86rem] text-tsb12-mut no-underline hover:text-tsb12-ink focus-visible:outline-2 focus-visible:outline-tsb12-acc focus-visible:outline-offset-2" href="#tsb-12-content">Help</a>
</div>
<div class="grid grid-cols-2 gap-2.5 px-4 pt-4 pb-2">
<div class="min-w-0 px-4 py-3.5 rounded-2xl bg-tsb12-surf2"><p class="m-0 mb-1 text-[0.72rem] tracking-[0.06em] uppercase text-tsb12-mut">Current account</p><p class="m-0 font-tsb12-display text-[1.25rem] font-bold tabular-nums">€4,182.60</p></div>
<div class="min-w-0 px-4 py-3.5 rounded-2xl bg-tsb12-surf2"><p class="m-0 mb-1 text-[0.72rem] tracking-[0.06em] uppercase text-tsb12-mut">Savings</p><p class="m-0 font-tsb12-display text-[1.25rem] font-bold tabular-nums">€16,400.00</p></div>
</div>
<ul class="list-none m-0 grid gap-1.5 px-4 pt-2 pb-6 [&>li]:flex [&>li]:gap-2.5 [&>li]:min-w-0 [&>li]:px-3 [&>li]:py-2.5 [&>li]:rounded-[0.7rem] [&>li]:bg-tsb12-surf2 [&>li]:text-[0.88rem] [&>li>span]:min-w-0 [&>li>span]:overflow-hidden [&>li>span]:text-ellipsis [&>li>span]:whitespace-nowrap [&>li>em]:ml-auto [&>li>em]:not-italic [&>li>em]:tabular-nums [&>li>em]:text-tsb12-mut">
<li><span>Meridian Ltd</span><em>−€42.00</em></li>
<li><span>Fern Studio</span><em>+€1,250.00</em></li>
<li><span>Basalt Hosting</span><em>−€18.00</em></li>
<li><span>Kestrel Rail</span><em>−€64.40</em></li>
<li><span>Slack Water Co</span><em>+€320.00</em></li>
</ul>
</div>
<div class="absolute inset-0 z-20 bg-[oklch(18%_0.04_292/0.45)] [&[hidden]]:hidden" data-tsb-12-scrim hidden></div>
<aside class="flex-none flex flex-col min-w-0 w-56 order-[-1] bg-tsb12-surf2 border-r border-tsb12-line group-data-[mode=modal]:absolute group-data-[mode=modal]:inset-y-0 group-data-[mode=modal]:left-0 group-data-[mode=modal]:z-30 group-data-[mode=modal]:w-[min(15rem,82%)] group-data-[mode=modal]:-translate-x-full group-data-[mode=modal]:shadow-[0_0_2rem_oklch(18%_0.04_292/0.4)] group-data-[mode=modal]:transition-transform group-data-[mode=modal]:duration-[220ms] group-data-[mode=modal]:ease-[cubic-bezier(0.22,1,0.36,1)] group-data-[mode=modal]:group-data-open:translate-x-0 motion-reduce:group-data-[mode=modal]:duration-[1ms] [&[hidden]]:hidden" id="tsb-12-rail" aria-labelledby="tsb-12-rail-h">
<div class="flex items-center gap-2 p-2.5">
<p class="mr-auto m-0 text-[0.74rem] tracking-[0.1em] uppercase text-tsb12-mut" id="tsb-12-rail-h">Accounts</p>
<button class="grid place-items-center w-11 h-11 rounded-[0.8rem] border-0 bg-tsb12-surf text-tsb12-ink cursor-pointer [&>svg]:w-4 [&>svg]:h-4 group-data-[mode=persistent]:hidden focus-visible:outline-2 focus-visible:outline-tsb12-acc focus-visible:outline-offset-2" type="button" data-tsb-12-close>
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M6 6l12 12M18 6L6 18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<span class="absolute w-px h-px overflow-hidden whitespace-nowrap [clip-path:inset(50%)]">Close navigation</span>
</button>
</div>
<nav class="flex-1 min-h-0 overflow-y-auto px-2 pt-1 pb-2.5 [overscroll-behavior:contain]" aria-label="Orbit accounts">
<ul class="list-none m-0 p-0 grid gap-0.5">
<li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.7rem] text-[0.9rem] text-tsb12-ink no-underline hover:bg-tsb12-surf focus-visible:outline-2 focus-visible:outline-tsb12-acc focus-visible:outline-offset-2 aria-[current=page]:bg-tsb12-acc aria-[current=page]:text-tsb12-onacc aria-[current=page]:font-semibold" href="#tsb-12-content" aria-current="page">Overview</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.7rem] text-[0.9rem] text-tsb12-ink no-underline hover:bg-tsb12-surf focus-visible:outline-2 focus-visible:outline-tsb12-acc focus-visible:outline-offset-2" href="#tsb-12-content">Payments</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.7rem] text-[0.9rem] text-tsb12-ink no-underline hover:bg-tsb12-surf focus-visible:outline-2 focus-visible:outline-tsb12-acc focus-visible:outline-offset-2" href="#tsb-12-content">Standing orders</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.7rem] text-[0.9rem] text-tsb12-ink no-underline hover:bg-tsb12-surf focus-visible:outline-2 focus-visible:outline-tsb12-acc focus-visible:outline-offset-2" href="#tsb-12-content">Statements</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.7rem] text-[0.9rem] text-tsb12-ink no-underline hover:bg-tsb12-surf focus-visible:outline-2 focus-visible:outline-tsb12-acc focus-visible:outline-offset-2" href="#tsb-12-content">Team settings</a></li>
</ul>
</nav>
</aside>
</div>
</section><section class="tsb-12 block relative w-full min-h-svh bg-tsb12-bg text-tsb12-ink font-tsb12-sans leading-[1.55]" aria-label="Orbit banking">
<div class="absolute top-3.5 right-3.5 z-60">
<input class="tsb-12-theme-input peer absolute inset-0 w-full h-full m-0 opacity-0 cursor-pointer" type="checkbox" id="tsb-12-theme">
<label class="grid place-items-center w-11 h-11 rounded-[0.9rem] border border-tsb12-line bg-tsb12-surf text-tsb12-ink cursor-pointer shadow-[0_0.3rem_0.8rem_oklch(20%_0.04_292/0.14)] peer-focus-visible:outline-2 peer-focus-visible:outline-tsb12-acc peer-focus-visible:outline-offset-2" for="tsb-12-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="group relative overflow-hidden flex min-w-0 min-h-svh bg-tsb12-surf" id="tsb-12-app" data-mode="persistent">
<div class="flex-1 flex flex-col min-w-0 transition-opacity duration-200 inert:opacity-50 motion-reduce:transition-none" id="tsb-12-content">
<div class="flex items-center gap-2 py-2.5 pl-3.5 pr-[4.2rem] border-b border-tsb12-line">
<button class="grid place-items-center w-11 h-11 rounded-[0.8rem] border-0 bg-tsb12-surf2 text-tsb12-ink cursor-pointer [&>svg]:w-4.5 [&>svg]:h-4.5 group-data-[mode=persistent]:hidden focus-visible:outline-2 focus-visible:outline-tsb12-acc focus-visible:outline-offset-2" type="button" id="tsb-12-open" aria-expanded="false" aria-controls="tsb-12-rail">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 7h16M4 12h16M4 17h16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<span class="absolute w-px h-px overflow-hidden whitespace-nowrap [clip-path:inset(50%)]">Open navigation</span>
</button>
<p class="mr-auto m-0 font-tsb12-display text-[1.15rem] font-bold tracking-[-0.02em] text-tsb12-acc">Orbit</p>
<a class="inline-flex items-center min-h-11 px-2.5 text-[0.86rem] text-tsb12-mut no-underline hover:text-tsb12-ink focus-visible:outline-2 focus-visible:outline-tsb12-acc focus-visible:outline-offset-2" href="#tsb-12-content">Help</a>
</div>
<div class="grid grid-cols-2 gap-2.5 px-4 pt-4 pb-2">
<div class="min-w-0 px-4 py-3.5 rounded-2xl bg-tsb12-surf2"><p class="m-0 mb-1 text-[0.72rem] tracking-[0.06em] uppercase text-tsb12-mut">Current account</p><p class="m-0 font-tsb12-display text-[1.25rem] font-bold tabular-nums">€4,182.60</p></div>
<div class="min-w-0 px-4 py-3.5 rounded-2xl bg-tsb12-surf2"><p class="m-0 mb-1 text-[0.72rem] tracking-[0.06em] uppercase text-tsb12-mut">Savings</p><p class="m-0 font-tsb12-display text-[1.25rem] font-bold tabular-nums">€16,400.00</p></div>
</div>
<ul class="list-none m-0 grid gap-1.5 px-4 pt-2 pb-6 [&>li]:flex [&>li]:gap-2.5 [&>li]:min-w-0 [&>li]:px-3 [&>li]:py-2.5 [&>li]:rounded-[0.7rem] [&>li]:bg-tsb12-surf2 [&>li]:text-[0.88rem] [&>li>span]:min-w-0 [&>li>span]:overflow-hidden [&>li>span]:text-ellipsis [&>li>span]:whitespace-nowrap [&>li>em]:ml-auto [&>li>em]:not-italic [&>li>em]:tabular-nums [&>li>em]:text-tsb12-mut">
<li><span>Meridian Ltd</span><em>−€42.00</em></li>
<li><span>Fern Studio</span><em>+€1,250.00</em></li>
<li><span>Basalt Hosting</span><em>−€18.00</em></li>
<li><span>Kestrel Rail</span><em>−€64.40</em></li>
<li><span>Slack Water Co</span><em>+€320.00</em></li>
</ul>
</div>
<div class="absolute inset-0 z-20 bg-[oklch(18%_0.04_292/0.45)] [&[hidden]]:hidden" data-tsb-12-scrim hidden></div>
<aside class="flex-none flex flex-col min-w-0 w-56 order-[-1] bg-tsb12-surf2 border-r border-tsb12-line group-data-[mode=modal]:absolute group-data-[mode=modal]:inset-y-0 group-data-[mode=modal]:left-0 group-data-[mode=modal]:z-30 group-data-[mode=modal]:w-[min(15rem,82%)] group-data-[mode=modal]:-translate-x-full group-data-[mode=modal]:shadow-[0_0_2rem_oklch(18%_0.04_292/0.4)] group-data-[mode=modal]:transition-transform group-data-[mode=modal]:duration-[220ms] group-data-[mode=modal]:ease-[cubic-bezier(0.22,1,0.36,1)] group-data-[mode=modal]:group-data-open:translate-x-0 motion-reduce:group-data-[mode=modal]:duration-[1ms] [&[hidden]]:hidden" id="tsb-12-rail" aria-labelledby="tsb-12-rail-h">
<div class="flex items-center gap-2 p-2.5">
<p class="mr-auto m-0 text-[0.74rem] tracking-[0.1em] uppercase text-tsb12-mut" id="tsb-12-rail-h">Accounts</p>
<button class="grid place-items-center w-11 h-11 rounded-[0.8rem] border-0 bg-tsb12-surf text-tsb12-ink cursor-pointer [&>svg]:w-4 [&>svg]:h-4 group-data-[mode=persistent]:hidden focus-visible:outline-2 focus-visible:outline-tsb12-acc focus-visible:outline-offset-2" type="button" data-tsb-12-close>
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M6 6l12 12M18 6L6 18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<span class="absolute w-px h-px overflow-hidden whitespace-nowrap [clip-path:inset(50%)]">Close navigation</span>
</button>
</div>
<nav class="flex-1 min-h-0 overflow-y-auto px-2 pt-1 pb-2.5 [overscroll-behavior:contain]" aria-label="Orbit accounts">
<ul class="list-none m-0 p-0 grid gap-0.5">
<li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.7rem] text-[0.9rem] text-tsb12-ink no-underline hover:bg-tsb12-surf focus-visible:outline-2 focus-visible:outline-tsb12-acc focus-visible:outline-offset-2 aria-[current=page]:bg-tsb12-acc aria-[current=page]:text-tsb12-onacc aria-[current=page]:font-semibold" href="#tsb-12-content" aria-current="page">Overview</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.7rem] text-[0.9rem] text-tsb12-ink no-underline hover:bg-tsb12-surf focus-visible:outline-2 focus-visible:outline-tsb12-acc focus-visible:outline-offset-2" href="#tsb-12-content">Payments</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.7rem] text-[0.9rem] text-tsb12-ink no-underline hover:bg-tsb12-surf focus-visible:outline-2 focus-visible:outline-tsb12-acc focus-visible:outline-offset-2" href="#tsb-12-content">Standing orders</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.7rem] text-[0.9rem] text-tsb12-ink no-underline hover:bg-tsb12-surf focus-visible:outline-2 focus-visible:outline-tsb12-acc focus-visible:outline-offset-2" href="#tsb-12-content">Statements</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.7rem] text-[0.9rem] text-tsb12-ink no-underline hover:bg-tsb12-surf focus-visible:outline-2 focus-visible:outline-tsb12-acc focus-visible:outline-offset-2" href="#tsb-12-content">Team settings</a></li>
</ul>
</nav>
</aside>
</div>
</section>@import "tailwindcss";
@theme {
--color-tsb12-bg: #f4f1fb;
--color-tsb12-bg: oklch(96% 0.014 292);
--color-tsb12-surf: #ffffff;
--color-tsb12-surf: oklch(100% 0 0);
--color-tsb12-surf2: #ece7f9;
--color-tsb12-surf2: oklch(93.5% 0.022 292);
--color-tsb12-line: #d9d1f0;
--color-tsb12-line: oklch(87% 0.035 292);
--color-tsb12-ink: #1d1830;
--color-tsb12-ink: oklch(22% 0.04 292);
--color-tsb12-mut: #625b80;
--color-tsb12-mut: oklch(48% 0.05 292);
--color-tsb12-acc: #6b3fd4;
--color-tsb12-acc: oklch(52% 0.2 292);
--color-tsb12-accsoft: #e6dcfb;
--color-tsb12-accsoft: oklch(92% 0.04 292);
--color-tsb12-onacc: #ffffff;
--color-tsb12-onacc: oklch(100% 0 0);
--font-tsb12-sans: system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-tsb12-display: 'Plus Jakarta Sans', 'SF Pro Display', system-ui, sans-serif;
}
/* Root rule: the full-bleed contract. The mode breakpoint is deliberately NOT here — it
lives once, in the script's matchMedia, and the markup keys off data-mode. */
.tsb-12 {
width: 100%;
min-height: 100svh;
}
.tsb-12 *,
.tsb-12 *::before,
.tsb-12 *::after {
box-sizing: border-box;
}
/* Token flip for the pure-CSS theme toggle, plus the two mandatory mirror blocks. */
.tsb-12:has(.tsb-12-theme-input:checked) {
--color-tsb12-bg: #0d0a17;
--color-tsb12-surf: #151020;
--color-tsb12-surf2: #1d1730;
--color-tsb12-line: #2b2342;
--color-tsb12-ink: #ece8f8;
--color-tsb12-mut: #a099c0;
--color-tsb12-acc: #b394ff;
--color-tsb12-acc: oklch(80% 0.14 292);
--color-tsb12-accsoft: #241c3c;
--color-tsb12-onacc: #0d0a17;
}
@media (prefers-color-scheme: dark) {
.tsb-12 {
--color-tsb12-bg: #0d0a17;
--color-tsb12-surf: #151020;
--color-tsb12-surf2: #1d1730;
--color-tsb12-line: #2b2342;
--color-tsb12-ink: #ece8f8;
--color-tsb12-mut: #a099c0;
--color-tsb12-acc: #b394ff;
--color-tsb12-acc: oklch(80% 0.14 292);
--color-tsb12-accsoft: #241c3c;
--color-tsb12-onacc: #0d0a17;
}
.tsb-12:has(.tsb-12-theme-input:checked) {
--color-tsb12-bg: #f4f1fb;
--color-tsb12-surf: #ffffff;
--color-tsb12-surf2: #ece7f9;
--color-tsb12-line: #d9d1f0;
--color-tsb12-ink: #1d1830;
--color-tsb12-mut: #625b80;
--color-tsb12-acc: #6b3fd4;
--color-tsb12-acc: oklch(52% 0.2 292);
--color-tsb12-accsoft: #e6dcfb;
--color-tsb12-onacc: #ffffff;
}
}
@media (prefers-color-scheme: light) {
.tsb-12:has(.tsb-12-theme-input:checked) {
--color-tsb12-bg: #0d0a17;
--color-tsb12-surf: #151020;
--color-tsb12-surf2: #1d1730;
--color-tsb12-line: #2b2342;
--color-tsb12-ink: #ece8f8;
--color-tsb12-mut: #a099c0;
--color-tsb12-acc: #b394ff;
--color-tsb12-acc: oklch(80% 0.14 292);
--color-tsb12-accsoft: #241c3c;
--color-tsb12-onacc: #0d0a17;
}
}@import "tailwindcss";
@theme {
--color-tsb12-bg: #f4f1fb;
--color-tsb12-bg: oklch(96% 0.014 292);
--color-tsb12-surf: #ffffff;
--color-tsb12-surf: oklch(100% 0 0);
--color-tsb12-surf2: #ece7f9;
--color-tsb12-surf2: oklch(93.5% 0.022 292);
--color-tsb12-line: #d9d1f0;
--color-tsb12-line: oklch(87% 0.035 292);
--color-tsb12-ink: #1d1830;
--color-tsb12-ink: oklch(22% 0.04 292);
--color-tsb12-mut: #625b80;
--color-tsb12-mut: oklch(48% 0.05 292);
--color-tsb12-acc: #6b3fd4;
--color-tsb12-acc: oklch(52% 0.2 292);
--color-tsb12-accsoft: #e6dcfb;
--color-tsb12-accsoft: oklch(92% 0.04 292);
--color-tsb12-onacc: #ffffff;
--color-tsb12-onacc: oklch(100% 0 0);
--font-tsb12-sans: system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-tsb12-display: 'Plus Jakarta Sans', 'SF Pro Display', system-ui, sans-serif;
}
/* Root rule: the full-bleed contract. The mode breakpoint is deliberately NOT here — it
lives once, in the script's matchMedia, and the markup keys off data-mode. */
.tsb-12 {
width: 100%;
min-height: 100svh;
}
.tsb-12 *,
.tsb-12 *::before,
.tsb-12 *::after {
box-sizing: border-box;
}
/* Token flip for the pure-CSS theme toggle, plus the two mandatory mirror blocks. */
.tsb-12:has(.tsb-12-theme-input:checked) {
--color-tsb12-bg: #0d0a17;
--color-tsb12-surf: #151020;
--color-tsb12-surf2: #1d1730;
--color-tsb12-line: #2b2342;
--color-tsb12-ink: #ece8f8;
--color-tsb12-mut: #a099c0;
--color-tsb12-acc: #b394ff;
--color-tsb12-acc: oklch(80% 0.14 292);
--color-tsb12-accsoft: #241c3c;
--color-tsb12-onacc: #0d0a17;
}
@media (prefers-color-scheme: dark) {
.tsb-12 {
--color-tsb12-bg: #0d0a17;
--color-tsb12-surf: #151020;
--color-tsb12-surf2: #1d1730;
--color-tsb12-line: #2b2342;
--color-tsb12-ink: #ece8f8;
--color-tsb12-mut: #a099c0;
--color-tsb12-acc: #b394ff;
--color-tsb12-acc: oklch(80% 0.14 292);
--color-tsb12-accsoft: #241c3c;
--color-tsb12-onacc: #0d0a17;
}
.tsb-12:has(.tsb-12-theme-input:checked) {
--color-tsb12-bg: #f4f1fb;
--color-tsb12-surf: #ffffff;
--color-tsb12-surf2: #ece7f9;
--color-tsb12-line: #d9d1f0;
--color-tsb12-ink: #1d1830;
--color-tsb12-mut: #625b80;
--color-tsb12-acc: #6b3fd4;
--color-tsb12-acc: oklch(52% 0.2 292);
--color-tsb12-accsoft: #e6dcfb;
--color-tsb12-onacc: #ffffff;
}
}
@media (prefers-color-scheme: light) {
.tsb-12:has(.tsb-12-theme-input:checked) {
--color-tsb12-bg: #0d0a17;
--color-tsb12-surf: #151020;
--color-tsb12-surf2: #1d1730;
--color-tsb12-line: #2b2342;
--color-tsb12-ink: #ece8f8;
--color-tsb12-mut: #a099c0;
--color-tsb12-acc: #b394ff;
--color-tsb12-acc: oklch(80% 0.14 292);
--color-tsb12-accsoft: #241c3c;
--color-tsb12-onacc: #0d0a17;
}
}(function () {
document.querySelectorAll('.tsb-12').forEach(function (root) {
if (root.dataset.bound) return; root.dataset.bound = '1';
var app = root.querySelector('#tsb-12-app'),
rail = root.querySelector('#tsb-12-rail'),
content = root.querySelector('#tsb-12-content'),
scrim = root.querySelector('[data-tsb-12-scrim]'),
trigger = root.querySelector('#tsb-12-open'),
supportsInert = 'inert' in HTMLElement.prototype,
// One source of truth for the mode: the markup keys off data-mode, never its own
// copy of the breakpoint.
mq = window.matchMedia('(min-width: 44rem)'),
open = false, returnTo = null;
function modal() { return !mq.matches; }
function onKey(e) { if (e.key === 'Escape' && open) setOpen(false); }
function paint() {
app.setAttribute('data-mode', modal() ? 'modal' : 'persistent');
app.toggleAttribute('data-open', open);
rail.hidden = modal() && !open;
scrim.hidden = !(modal() && open);
rail.setAttribute('role', modal() ? 'dialog' : 'complementary');
if (modal()) rail.setAttribute('aria-modal', 'true'); else rail.removeAttribute('aria-modal');
trigger.setAttribute('aria-expanded', String(modal() && open));
var block = modal() && open;
if (supportsInert) content.inert = block;
else content.setAttribute('aria-hidden', String(block));
}
function setOpen(next) {
open = next;
if (next) { returnTo = document.activeElement; document.addEventListener('keydown', onKey); }
else document.removeEventListener('keydown', onKey);
paint();
if (next) requestAnimationFrame(function () { rail.querySelector('[data-tsb-12-close]').focus(); });
else if (returnTo) { returnTo.focus({ preventScroll: true }); returnTo = null; }
}
function onModeChange() { if (open && !modal()) setOpen(false); else paint(); }
mq.addEventListener('change', onModeChange);
trigger.addEventListener('click', function () { setOpen(true); });
scrim.addEventListener('click', function () { setOpen(false); });
root.querySelectorAll('[data-tsb-12-close]').forEach(function (b) { b.addEventListener('click', function () { setOpen(false); }); });
paint();
// matchMedia can read stale at parse time, so settle the mode after first layout too.
requestAnimationFrame(paint);
root.__tsbCleanup = function () {
document.removeEventListener('keydown', onKey);
mq.removeEventListener('change', onModeChange);
};
});
})();(function () {
document.querySelectorAll('.tsb-12').forEach(function (root) {
if (root.dataset.bound) return; root.dataset.bound = '1';
var app = root.querySelector('#tsb-12-app'),
rail = root.querySelector('#tsb-12-rail'),
content = root.querySelector('#tsb-12-content'),
scrim = root.querySelector('[data-tsb-12-scrim]'),
trigger = root.querySelector('#tsb-12-open'),
supportsInert = 'inert' in HTMLElement.prototype,
// One source of truth for the mode: the markup keys off data-mode, never its own
// copy of the breakpoint.
mq = window.matchMedia('(min-width: 44rem)'),
open = false, returnTo = null;
function modal() { return !mq.matches; }
function onKey(e) { if (e.key === 'Escape' && open) setOpen(false); }
function paint() {
app.setAttribute('data-mode', modal() ? 'modal' : 'persistent');
app.toggleAttribute('data-open', open);
rail.hidden = modal() && !open;
scrim.hidden = !(modal() && open);
rail.setAttribute('role', modal() ? 'dialog' : 'complementary');
if (modal()) rail.setAttribute('aria-modal', 'true'); else rail.removeAttribute('aria-modal');
trigger.setAttribute('aria-expanded', String(modal() && open));
var block = modal() && open;
if (supportsInert) content.inert = block;
else content.setAttribute('aria-hidden', String(block));
}
function setOpen(next) {
open = next;
if (next) { returnTo = document.activeElement; document.addEventListener('keydown', onKey); }
else document.removeEventListener('keydown', onKey);
paint();
if (next) requestAnimationFrame(function () { rail.querySelector('[data-tsb-12-close]').focus(); });
else if (returnTo) { returnTo.focus({ preventScroll: true }); returnTo = null; }
}
function onModeChange() { if (open && !modal()) setOpen(false); else paint(); }
mq.addEventListener('change', onModeChange);
trigger.addEventListener('click', function () { setOpen(true); });
scrim.addEventListener('click', function () { setOpen(false); });
root.querySelectorAll('[data-tsb-12-close]').forEach(function (b) { b.addEventListener('click', function () { setOpen(false); }); });
paint();
// matchMedia can read stale at parse time, so settle the mode after first layout too.
requestAnimationFrame(paint);
root.__tsbCleanup = function () {
document.removeEventListener('keydown', onKey);
mq.removeEventListener('change', onModeChange);
};
});
})();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-tsb12-bg: #f4f1fb;
--color-tsb12-bg: oklch(96% 0.014 292);
--color-tsb12-surf: #ffffff;
--color-tsb12-surf: oklch(100% 0 0);
--color-tsb12-surf2: #ece7f9;
--color-tsb12-surf2: oklch(93.5% 0.022 292);
--color-tsb12-line: #d9d1f0;
--color-tsb12-line: oklch(87% 0.035 292);
--color-tsb12-ink: #1d1830;
--color-tsb12-ink: oklch(22% 0.04 292);
--color-tsb12-mut: #625b80;
--color-tsb12-mut: oklch(48% 0.05 292);
--color-tsb12-acc: #6b3fd4;
--color-tsb12-acc: oklch(52% 0.2 292);
--color-tsb12-accsoft: #e6dcfb;
--color-tsb12-accsoft: oklch(92% 0.04 292);
--color-tsb12-onacc: #ffffff;
--color-tsb12-onacc: oklch(100% 0 0);
--font-tsb12-sans: system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-tsb12-display: 'Plus Jakarta Sans', 'SF Pro Display', system-ui, sans-serif;
}
/* Root rule: the full-bleed contract. The mode breakpoint is deliberately NOT here — it
lives once, in the script's matchMedia, and the markup keys off data-mode. */
.tsb-12 {
width: 100%;
min-height: 100svh;
}
.tsb-12 *,
.tsb-12 *::before,
.tsb-12 *::after {
box-sizing: border-box;
}
/* Token flip for the pure-CSS theme toggle, plus the two mandatory mirror blocks. */
.tsb-12:has(.tsb-12-theme-input:checked) {
--color-tsb12-bg: #0d0a17;
--color-tsb12-surf: #151020;
--color-tsb12-surf2: #1d1730;
--color-tsb12-line: #2b2342;
--color-tsb12-ink: #ece8f8;
--color-tsb12-mut: #a099c0;
--color-tsb12-acc: #b394ff;
--color-tsb12-acc: oklch(80% 0.14 292);
--color-tsb12-accsoft: #241c3c;
--color-tsb12-onacc: #0d0a17;
}
@media (prefers-color-scheme: dark) {
.tsb-12 {
--color-tsb12-bg: #0d0a17;
--color-tsb12-surf: #151020;
--color-tsb12-surf2: #1d1730;
--color-tsb12-line: #2b2342;
--color-tsb12-ink: #ece8f8;
--color-tsb12-mut: #a099c0;
--color-tsb12-acc: #b394ff;
--color-tsb12-acc: oklch(80% 0.14 292);
--color-tsb12-accsoft: #241c3c;
--color-tsb12-onacc: #0d0a17;
}
.tsb-12:has(.tsb-12-theme-input:checked) {
--color-tsb12-bg: #f4f1fb;
--color-tsb12-surf: #ffffff;
--color-tsb12-surf2: #ece7f9;
--color-tsb12-line: #d9d1f0;
--color-tsb12-ink: #1d1830;
--color-tsb12-mut: #625b80;
--color-tsb12-acc: #6b3fd4;
--color-tsb12-acc: oklch(52% 0.2 292);
--color-tsb12-accsoft: #e6dcfb;
--color-tsb12-onacc: #ffffff;
}
}
@media (prefers-color-scheme: light) {
.tsb-12:has(.tsb-12-theme-input:checked) {
--color-tsb12-bg: #0d0a17;
--color-tsb12-surf: #151020;
--color-tsb12-surf2: #1d1730;
--color-tsb12-line: #2b2342;
--color-tsb12-ink: #ece8f8;
--color-tsb12-mut: #a099c0;
--color-tsb12-acc: #b394ff;
--color-tsb12-acc: oklch(80% 0.14 292);
--color-tsb12-accsoft: #241c3c;
--color-tsb12-onacc: #0d0a17;
}
}@import "tailwindcss";
@theme {
--color-tsb12-bg: #f4f1fb;
--color-tsb12-bg: oklch(96% 0.014 292);
--color-tsb12-surf: #ffffff;
--color-tsb12-surf: oklch(100% 0 0);
--color-tsb12-surf2: #ece7f9;
--color-tsb12-surf2: oklch(93.5% 0.022 292);
--color-tsb12-line: #d9d1f0;
--color-tsb12-line: oklch(87% 0.035 292);
--color-tsb12-ink: #1d1830;
--color-tsb12-ink: oklch(22% 0.04 292);
--color-tsb12-mut: #625b80;
--color-tsb12-mut: oklch(48% 0.05 292);
--color-tsb12-acc: #6b3fd4;
--color-tsb12-acc: oklch(52% 0.2 292);
--color-tsb12-accsoft: #e6dcfb;
--color-tsb12-accsoft: oklch(92% 0.04 292);
--color-tsb12-onacc: #ffffff;
--color-tsb12-onacc: oklch(100% 0 0);
--font-tsb12-sans: system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-tsb12-display: 'Plus Jakarta Sans', 'SF Pro Display', system-ui, sans-serif;
}
/* Root rule: the full-bleed contract. The mode breakpoint is deliberately NOT here — it
lives once, in the script's matchMedia, and the markup keys off data-mode. */
.tsb-12 {
width: 100%;
min-height: 100svh;
}
.tsb-12 *,
.tsb-12 *::before,
.tsb-12 *::after {
box-sizing: border-box;
}
/* Token flip for the pure-CSS theme toggle, plus the two mandatory mirror blocks. */
.tsb-12:has(.tsb-12-theme-input:checked) {
--color-tsb12-bg: #0d0a17;
--color-tsb12-surf: #151020;
--color-tsb12-surf2: #1d1730;
--color-tsb12-line: #2b2342;
--color-tsb12-ink: #ece8f8;
--color-tsb12-mut: #a099c0;
--color-tsb12-acc: #b394ff;
--color-tsb12-acc: oklch(80% 0.14 292);
--color-tsb12-accsoft: #241c3c;
--color-tsb12-onacc: #0d0a17;
}
@media (prefers-color-scheme: dark) {
.tsb-12 {
--color-tsb12-bg: #0d0a17;
--color-tsb12-surf: #151020;
--color-tsb12-surf2: #1d1730;
--color-tsb12-line: #2b2342;
--color-tsb12-ink: #ece8f8;
--color-tsb12-mut: #a099c0;
--color-tsb12-acc: #b394ff;
--color-tsb12-acc: oklch(80% 0.14 292);
--color-tsb12-accsoft: #241c3c;
--color-tsb12-onacc: #0d0a17;
}
.tsb-12:has(.tsb-12-theme-input:checked) {
--color-tsb12-bg: #f4f1fb;
--color-tsb12-surf: #ffffff;
--color-tsb12-surf2: #ece7f9;
--color-tsb12-line: #d9d1f0;
--color-tsb12-ink: #1d1830;
--color-tsb12-mut: #625b80;
--color-tsb12-acc: #6b3fd4;
--color-tsb12-acc: oklch(52% 0.2 292);
--color-tsb12-accsoft: #e6dcfb;
--color-tsb12-onacc: #ffffff;
}
}
@media (prefers-color-scheme: light) {
.tsb-12:has(.tsb-12-theme-input:checked) {
--color-tsb12-bg: #0d0a17;
--color-tsb12-surf: #151020;
--color-tsb12-surf2: #1d1730;
--color-tsb12-line: #2b2342;
--color-tsb12-ink: #ece8f8;
--color-tsb12-mut: #a099c0;
--color-tsb12-acc: #b394ff;
--color-tsb12-acc: oklch(80% 0.14 292);
--color-tsb12-accsoft: #241c3c;
--color-tsb12-onacc: #0d0a17;
}
}<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=Plus+Jakarta+Sans:wght@400;600;700&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=Plus+Jakarta+Sans:wght@400;600;700&display=swap">What changed in the port
Which parts became utilities, which stayed CSS, and why.
- This is the demo where a responsive variant would be WRONG.
r44:classes plus amatchMediain script means the breakpoint exists twice; keying every utility offdata-modekeeps the number in one place. - Two stacked group-data variants —
group-data-[mode=modal]:group-data-open:translate-x-0— are the port of a two-attribute descendant selector, and they read better than the CSS because the mode and the open state are visible on the element they move. group-data-[mode=persistent]:hiddenon the trigger and close button replaces twodisplay: nonerules, and it puts “this control only exists on small screens” on the control.inert:opacity-50is the same utility demo 03 uses, at a different opacity: the variant is reusable exactly because it is not tied to a layout.order-[-1]handles the visual-before/DOM-after split. In the CSS version this is easy to miss; in the class list it is next to the width.- Role and
aria-modalswitching stays in script, correctly. A variant can change how an element looks in a mode; only script can change what it IS.
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 Responsive Sidebar Breakpoint Behavior
Source: https://codefronts.com/navigation/tailwind-css-sidebars/tailwind-responsive-sidebar-breakpoint-behavior/
The same sidebar is a persistent rail on a laptop and a modal drawer on a phone — which means its ROLE changes with the viewport, not just its width. The port's discipline is the interesting part: the breakpoint is declared once, in matchMedia, and the class list keys off the resulting data-mode attribute rather than repeating the number as a Tailwind variant.
## HTML
```html
<section class="tsb-12 block relative w-full min-h-svh bg-tsb12-bg text-tsb12-ink font-tsb12-sans leading-[1.55]" aria-label="Orbit banking">
<div class="absolute top-3.5 right-3.5 z-60">
<input class="tsb-12-theme-input peer absolute inset-0 w-full h-full m-0 opacity-0 cursor-pointer" type="checkbox" id="tsb-12-theme">
<label class="grid place-items-center w-11 h-11 rounded-[0.9rem] border border-tsb12-line bg-tsb12-surf text-tsb12-ink cursor-pointer shadow-[0_0.3rem_0.8rem_oklch(20%_0.04_292/0.14)] peer-focus-visible:outline-2 peer-focus-visible:outline-tsb12-acc peer-focus-visible:outline-offset-2" for="tsb-12-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="group relative overflow-hidden flex min-w-0 min-h-svh bg-tsb12-surf" id="tsb-12-app" data-mode="persistent">
<div class="flex-1 flex flex-col min-w-0 transition-opacity duration-200 inert:opacity-50 motion-reduce:transition-none" id="tsb-12-content">
<div class="flex items-center gap-2 py-2.5 pl-3.5 pr-[4.2rem] border-b border-tsb12-line">
<button class="grid place-items-center w-11 h-11 rounded-[0.8rem] border-0 bg-tsb12-surf2 text-tsb12-ink cursor-pointer [&>svg]:w-4.5 [&>svg]:h-4.5 group-data-[mode=persistent]:hidden focus-visible:outline-2 focus-visible:outline-tsb12-acc focus-visible:outline-offset-2" type="button" id="tsb-12-open" aria-expanded="false" aria-controls="tsb-12-rail">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 7h16M4 12h16M4 17h16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<span class="absolute w-px h-px overflow-hidden whitespace-nowrap [clip-path:inset(50%)]">Open navigation</span>
</button>
<p class="mr-auto m-0 font-tsb12-display text-[1.15rem] font-bold tracking-[-0.02em] text-tsb12-acc">Orbit</p>
<a class="inline-flex items-center min-h-11 px-2.5 text-[0.86rem] text-tsb12-mut no-underline hover:text-tsb12-ink focus-visible:outline-2 focus-visible:outline-tsb12-acc focus-visible:outline-offset-2" href="#tsb-12-content">Help</a>
</div>
<div class="grid grid-cols-2 gap-2.5 px-4 pt-4 pb-2">
<div class="min-w-0 px-4 py-3.5 rounded-2xl bg-tsb12-surf2"><p class="m-0 mb-1 text-[0.72rem] tracking-[0.06em] uppercase text-tsb12-mut">Current account</p><p class="m-0 font-tsb12-display text-[1.25rem] font-bold tabular-nums">€4,182.60</p></div>
<div class="min-w-0 px-4 py-3.5 rounded-2xl bg-tsb12-surf2"><p class="m-0 mb-1 text-[0.72rem] tracking-[0.06em] uppercase text-tsb12-mut">Savings</p><p class="m-0 font-tsb12-display text-[1.25rem] font-bold tabular-nums">€16,400.00</p></div>
</div>
<ul class="list-none m-0 grid gap-1.5 px-4 pt-2 pb-6 [&>li]:flex [&>li]:gap-2.5 [&>li]:min-w-0 [&>li]:px-3 [&>li]:py-2.5 [&>li]:rounded-[0.7rem] [&>li]:bg-tsb12-surf2 [&>li]:text-[0.88rem] [&>li>span]:min-w-0 [&>li>span]:overflow-hidden [&>li>span]:text-ellipsis [&>li>span]:whitespace-nowrap [&>li>em]:ml-auto [&>li>em]:not-italic [&>li>em]:tabular-nums [&>li>em]:text-tsb12-mut">
<li><span>Meridian Ltd</span><em>−€42.00</em></li>
<li><span>Fern Studio</span><em>+€1,250.00</em></li>
<li><span>Basalt Hosting</span><em>−€18.00</em></li>
<li><span>Kestrel Rail</span><em>−€64.40</em></li>
<li><span>Slack Water Co</span><em>+€320.00</em></li>
</ul>
</div>
<div class="absolute inset-0 z-20 bg-[oklch(18%_0.04_292/0.45)] [&[hidden]]:hidden" data-tsb-12-scrim hidden></div>
<aside class="flex-none flex flex-col min-w-0 w-56 order-[-1] bg-tsb12-surf2 border-r border-tsb12-line group-data-[mode=modal]:absolute group-data-[mode=modal]:inset-y-0 group-data-[mode=modal]:left-0 group-data-[mode=modal]:z-30 group-data-[mode=modal]:w-[min(15rem,82%)] group-data-[mode=modal]:-translate-x-full group-data-[mode=modal]:shadow-[0_0_2rem_oklch(18%_0.04_292/0.4)] group-data-[mode=modal]:transition-transform group-data-[mode=modal]:duration-[220ms] group-data-[mode=modal]:ease-[cubic-bezier(0.22,1,0.36,1)] group-data-[mode=modal]:group-data-open:translate-x-0 motion-reduce:group-data-[mode=modal]:duration-[1ms] [&[hidden]]:hidden" id="tsb-12-rail" aria-labelledby="tsb-12-rail-h">
<div class="flex items-center gap-2 p-2.5">
<p class="mr-auto m-0 text-[0.74rem] tracking-[0.1em] uppercase text-tsb12-mut" id="tsb-12-rail-h">Accounts</p>
<button class="grid place-items-center w-11 h-11 rounded-[0.8rem] border-0 bg-tsb12-surf text-tsb12-ink cursor-pointer [&>svg]:w-4 [&>svg]:h-4 group-data-[mode=persistent]:hidden focus-visible:outline-2 focus-visible:outline-tsb12-acc focus-visible:outline-offset-2" type="button" data-tsb-12-close>
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M6 6l12 12M18 6L6 18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<span class="absolute w-px h-px overflow-hidden whitespace-nowrap [clip-path:inset(50%)]">Close navigation</span>
</button>
</div>
<nav class="flex-1 min-h-0 overflow-y-auto px-2 pt-1 pb-2.5 [overscroll-behavior:contain]" aria-label="Orbit accounts">
<ul class="list-none m-0 p-0 grid gap-0.5">
<li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.7rem] text-[0.9rem] text-tsb12-ink no-underline hover:bg-tsb12-surf focus-visible:outline-2 focus-visible:outline-tsb12-acc focus-visible:outline-offset-2 aria-[current=page]:bg-tsb12-acc aria-[current=page]:text-tsb12-onacc aria-[current=page]:font-semibold" href="#tsb-12-content" aria-current="page">Overview</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.7rem] text-[0.9rem] text-tsb12-ink no-underline hover:bg-tsb12-surf focus-visible:outline-2 focus-visible:outline-tsb12-acc focus-visible:outline-offset-2" href="#tsb-12-content">Payments</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.7rem] text-[0.9rem] text-tsb12-ink no-underline hover:bg-tsb12-surf focus-visible:outline-2 focus-visible:outline-tsb12-acc focus-visible:outline-offset-2" href="#tsb-12-content">Standing orders</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.7rem] text-[0.9rem] text-tsb12-ink no-underline hover:bg-tsb12-surf focus-visible:outline-2 focus-visible:outline-tsb12-acc focus-visible:outline-offset-2" href="#tsb-12-content">Statements</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.7rem] text-[0.9rem] text-tsb12-ink no-underline hover:bg-tsb12-surf focus-visible:outline-2 focus-visible:outline-tsb12-acc focus-visible:outline-offset-2" href="#tsb-12-content">Team settings</a></li>
</ul>
</nav>
</aside>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-tsb12-bg: #f4f1fb;
--color-tsb12-bg: oklch(96% 0.014 292);
--color-tsb12-surf: #ffffff;
--color-tsb12-surf: oklch(100% 0 0);
--color-tsb12-surf2: #ece7f9;
--color-tsb12-surf2: oklch(93.5% 0.022 292);
--color-tsb12-line: #d9d1f0;
--color-tsb12-line: oklch(87% 0.035 292);
--color-tsb12-ink: #1d1830;
--color-tsb12-ink: oklch(22% 0.04 292);
--color-tsb12-mut: #625b80;
--color-tsb12-mut: oklch(48% 0.05 292);
--color-tsb12-acc: #6b3fd4;
--color-tsb12-acc: oklch(52% 0.2 292);
--color-tsb12-accsoft: #e6dcfb;
--color-tsb12-accsoft: oklch(92% 0.04 292);
--color-tsb12-onacc: #ffffff;
--color-tsb12-onacc: oklch(100% 0 0);
--font-tsb12-sans: system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-tsb12-display: 'Plus Jakarta Sans', 'SF Pro Display', system-ui, sans-serif;
}
/* Root rule: the full-bleed contract. The mode breakpoint is deliberately NOT here — it
lives once, in the script's matchMedia, and the markup keys off data-mode. */
.tsb-12 {
width: 100%;
min-height: 100svh;
}
.tsb-12 *,
.tsb-12 *::before,
.tsb-12 *::after {
box-sizing: border-box;
}
/* Token flip for the pure-CSS theme toggle, plus the two mandatory mirror blocks. */
.tsb-12:has(.tsb-12-theme-input:checked) {
--color-tsb12-bg: #0d0a17;
--color-tsb12-surf: #151020;
--color-tsb12-surf2: #1d1730;
--color-tsb12-line: #2b2342;
--color-tsb12-ink: #ece8f8;
--color-tsb12-mut: #a099c0;
--color-tsb12-acc: #b394ff;
--color-tsb12-acc: oklch(80% 0.14 292);
--color-tsb12-accsoft: #241c3c;
--color-tsb12-onacc: #0d0a17;
}
@media (prefers-color-scheme: dark) {
.tsb-12 {
--color-tsb12-bg: #0d0a17;
--color-tsb12-surf: #151020;
--color-tsb12-surf2: #1d1730;
--color-tsb12-line: #2b2342;
--color-tsb12-ink: #ece8f8;
--color-tsb12-mut: #a099c0;
--color-tsb12-acc: #b394ff;
--color-tsb12-acc: oklch(80% 0.14 292);
--color-tsb12-accsoft: #241c3c;
--color-tsb12-onacc: #0d0a17;
}
.tsb-12:has(.tsb-12-theme-input:checked) {
--color-tsb12-bg: #f4f1fb;
--color-tsb12-surf: #ffffff;
--color-tsb12-surf2: #ece7f9;
--color-tsb12-line: #d9d1f0;
--color-tsb12-ink: #1d1830;
--color-tsb12-mut: #625b80;
--color-tsb12-acc: #6b3fd4;
--color-tsb12-acc: oklch(52% 0.2 292);
--color-tsb12-accsoft: #e6dcfb;
--color-tsb12-onacc: #ffffff;
}
}
@media (prefers-color-scheme: light) {
.tsb-12:has(.tsb-12-theme-input:checked) {
--color-tsb12-bg: #0d0a17;
--color-tsb12-surf: #151020;
--color-tsb12-surf2: #1d1730;
--color-tsb12-line: #2b2342;
--color-tsb12-ink: #ece8f8;
--color-tsb12-mut: #a099c0;
--color-tsb12-acc: #b394ff;
--color-tsb12-acc: oklch(80% 0.14 292);
--color-tsb12-accsoft: #241c3c;
--color-tsb12-onacc: #0d0a17;
}
}
```
## JavaScript
```js
(function () {
document.querySelectorAll('.tsb-12').forEach(function (root) {
if (root.dataset.bound) return; root.dataset.bound = '1';
var app = root.querySelector('#tsb-12-app'),
rail = root.querySelector('#tsb-12-rail'),
content = root.querySelector('#tsb-12-content'),
scrim = root.querySelector('[data-tsb-12-scrim]'),
trigger = root.querySelector('#tsb-12-open'),
supportsInert = 'inert' in HTMLElement.prototype,
// One source of truth for the mode: the markup keys off data-mode, never its own
// copy of the breakpoint.
mq = window.matchMedia('(min-width: 44rem)'),
open = false, returnTo = null;
function modal() { return !mq.matches; }
function onKey(e) { if (e.key === 'Escape' && open) setOpen(false); }
function paint() {
app.setAttribute('data-mode', modal() ? 'modal' : 'persistent');
app.toggleAttribute('data-open', open);
rail.hidden = modal() && !open;
scrim.hidden = !(modal() && open);
rail.setAttribute('role', modal() ? 'dialog' : 'complementary');
if (modal()) rail.setAttribute('aria-modal', 'true'); else rail.removeAttribute('aria-modal');
trigger.setAttribute('aria-expanded', String(modal() && open));
var block = modal() && open;
if (supportsInert) content.inert = block;
else content.setAttribute('aria-hidden', String(block));
}
function setOpen(next) {
open = next;
if (next) { returnTo = document.activeElement; document.addEventListener('keydown', onKey); }
else document.removeEventListener('keydown', onKey);
paint();
if (next) requestAnimationFrame(function () { rail.querySelector('[data-tsb-12-close]').focus(); });
else if (returnTo) { returnTo.focus({ preventScroll: true }); returnTo = null; }
}
function onModeChange() { if (open && !modal()) setOpen(false); else paint(); }
mq.addEventListener('change', onModeChange);
trigger.addEventListener('click', function () { setOpen(true); });
scrim.addEventListener('click', function () { setOpen(false); });
root.querySelectorAll('[data-tsb-12-close]').forEach(function (b) { b.addEventListener('click', function () { setOpen(false); }); });
paint();
// matchMedia can read stale at parse time, so settle the mode after first layout too.
requestAnimationFrame(paint);
root.__tsbCleanup = function () {
document.removeEventListener('keydown', onKey);
mq.removeEventListener('change', onModeChange);
};
});
})();
```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 Responsive Sidebar Breakpoint Behavior
Source: https://codefronts.com/navigation/tailwind-css-sidebars/tailwind-responsive-sidebar-breakpoint-behavior/
The same sidebar is a persistent rail on a laptop and a modal drawer on a phone — which means its ROLE changes with the viewport, not just its width. The port's discipline is the interesting part: the breakpoint is declared once, in matchMedia, and the class list keys off the resulting data-mode attribute rather than repeating the number as a Tailwind variant.
## HTML
```html
<section class="tsb-12 block relative w-full min-h-svh bg-tsb12-bg text-tsb12-ink font-tsb12-sans leading-[1.55]" aria-label="Orbit banking">
<div class="absolute top-3.5 right-3.5 z-60">
<input class="tsb-12-theme-input peer absolute inset-0 w-full h-full m-0 opacity-0 cursor-pointer" type="checkbox" id="tsb-12-theme">
<label class="grid place-items-center w-11 h-11 rounded-[0.9rem] border border-tsb12-line bg-tsb12-surf text-tsb12-ink cursor-pointer shadow-[0_0.3rem_0.8rem_oklch(20%_0.04_292/0.14)] peer-focus-visible:outline-2 peer-focus-visible:outline-tsb12-acc peer-focus-visible:outline-offset-2" for="tsb-12-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="group relative overflow-hidden flex min-w-0 min-h-svh bg-tsb12-surf" id="tsb-12-app" data-mode="persistent">
<div class="flex-1 flex flex-col min-w-0 transition-opacity duration-200 inert:opacity-50 motion-reduce:transition-none" id="tsb-12-content">
<div class="flex items-center gap-2 py-2.5 pl-3.5 pr-[4.2rem] border-b border-tsb12-line">
<button class="grid place-items-center w-11 h-11 rounded-[0.8rem] border-0 bg-tsb12-surf2 text-tsb12-ink cursor-pointer [&>svg]:w-4.5 [&>svg]:h-4.5 group-data-[mode=persistent]:hidden focus-visible:outline-2 focus-visible:outline-tsb12-acc focus-visible:outline-offset-2" type="button" id="tsb-12-open" aria-expanded="false" aria-controls="tsb-12-rail">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 7h16M4 12h16M4 17h16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<span class="absolute w-px h-px overflow-hidden whitespace-nowrap [clip-path:inset(50%)]">Open navigation</span>
</button>
<p class="mr-auto m-0 font-tsb12-display text-[1.15rem] font-bold tracking-[-0.02em] text-tsb12-acc">Orbit</p>
<a class="inline-flex items-center min-h-11 px-2.5 text-[0.86rem] text-tsb12-mut no-underline hover:text-tsb12-ink focus-visible:outline-2 focus-visible:outline-tsb12-acc focus-visible:outline-offset-2" href="#tsb-12-content">Help</a>
</div>
<div class="grid grid-cols-2 gap-2.5 px-4 pt-4 pb-2">
<div class="min-w-0 px-4 py-3.5 rounded-2xl bg-tsb12-surf2"><p class="m-0 mb-1 text-[0.72rem] tracking-[0.06em] uppercase text-tsb12-mut">Current account</p><p class="m-0 font-tsb12-display text-[1.25rem] font-bold tabular-nums">€4,182.60</p></div>
<div class="min-w-0 px-4 py-3.5 rounded-2xl bg-tsb12-surf2"><p class="m-0 mb-1 text-[0.72rem] tracking-[0.06em] uppercase text-tsb12-mut">Savings</p><p class="m-0 font-tsb12-display text-[1.25rem] font-bold tabular-nums">€16,400.00</p></div>
</div>
<ul class="list-none m-0 grid gap-1.5 px-4 pt-2 pb-6 [&>li]:flex [&>li]:gap-2.5 [&>li]:min-w-0 [&>li]:px-3 [&>li]:py-2.5 [&>li]:rounded-[0.7rem] [&>li]:bg-tsb12-surf2 [&>li]:text-[0.88rem] [&>li>span]:min-w-0 [&>li>span]:overflow-hidden [&>li>span]:text-ellipsis [&>li>span]:whitespace-nowrap [&>li>em]:ml-auto [&>li>em]:not-italic [&>li>em]:tabular-nums [&>li>em]:text-tsb12-mut">
<li><span>Meridian Ltd</span><em>−€42.00</em></li>
<li><span>Fern Studio</span><em>+€1,250.00</em></li>
<li><span>Basalt Hosting</span><em>−€18.00</em></li>
<li><span>Kestrel Rail</span><em>−€64.40</em></li>
<li><span>Slack Water Co</span><em>+€320.00</em></li>
</ul>
</div>
<div class="absolute inset-0 z-20 bg-[oklch(18%_0.04_292/0.45)] [&[hidden]]:hidden" data-tsb-12-scrim hidden></div>
<aside class="flex-none flex flex-col min-w-0 w-56 order-[-1] bg-tsb12-surf2 border-r border-tsb12-line group-data-[mode=modal]:absolute group-data-[mode=modal]:inset-y-0 group-data-[mode=modal]:left-0 group-data-[mode=modal]:z-30 group-data-[mode=modal]:w-[min(15rem,82%)] group-data-[mode=modal]:-translate-x-full group-data-[mode=modal]:shadow-[0_0_2rem_oklch(18%_0.04_292/0.4)] group-data-[mode=modal]:transition-transform group-data-[mode=modal]:duration-[220ms] group-data-[mode=modal]:ease-[cubic-bezier(0.22,1,0.36,1)] group-data-[mode=modal]:group-data-open:translate-x-0 motion-reduce:group-data-[mode=modal]:duration-[1ms] [&[hidden]]:hidden" id="tsb-12-rail" aria-labelledby="tsb-12-rail-h">
<div class="flex items-center gap-2 p-2.5">
<p class="mr-auto m-0 text-[0.74rem] tracking-[0.1em] uppercase text-tsb12-mut" id="tsb-12-rail-h">Accounts</p>
<button class="grid place-items-center w-11 h-11 rounded-[0.8rem] border-0 bg-tsb12-surf text-tsb12-ink cursor-pointer [&>svg]:w-4 [&>svg]:h-4 group-data-[mode=persistent]:hidden focus-visible:outline-2 focus-visible:outline-tsb12-acc focus-visible:outline-offset-2" type="button" data-tsb-12-close>
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M6 6l12 12M18 6L6 18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<span class="absolute w-px h-px overflow-hidden whitespace-nowrap [clip-path:inset(50%)]">Close navigation</span>
</button>
</div>
<nav class="flex-1 min-h-0 overflow-y-auto px-2 pt-1 pb-2.5 [overscroll-behavior:contain]" aria-label="Orbit accounts">
<ul class="list-none m-0 p-0 grid gap-0.5">
<li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.7rem] text-[0.9rem] text-tsb12-ink no-underline hover:bg-tsb12-surf focus-visible:outline-2 focus-visible:outline-tsb12-acc focus-visible:outline-offset-2 aria-[current=page]:bg-tsb12-acc aria-[current=page]:text-tsb12-onacc aria-[current=page]:font-semibold" href="#tsb-12-content" aria-current="page">Overview</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.7rem] text-[0.9rem] text-tsb12-ink no-underline hover:bg-tsb12-surf focus-visible:outline-2 focus-visible:outline-tsb12-acc focus-visible:outline-offset-2" href="#tsb-12-content">Payments</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.7rem] text-[0.9rem] text-tsb12-ink no-underline hover:bg-tsb12-surf focus-visible:outline-2 focus-visible:outline-tsb12-acc focus-visible:outline-offset-2" href="#tsb-12-content">Standing orders</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.7rem] text-[0.9rem] text-tsb12-ink no-underline hover:bg-tsb12-surf focus-visible:outline-2 focus-visible:outline-tsb12-acc focus-visible:outline-offset-2" href="#tsb-12-content">Statements</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.7rem] text-[0.9rem] text-tsb12-ink no-underline hover:bg-tsb12-surf focus-visible:outline-2 focus-visible:outline-tsb12-acc focus-visible:outline-offset-2" href="#tsb-12-content">Team settings</a></li>
</ul>
</nav>
</aside>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-tsb12-bg: #f4f1fb;
--color-tsb12-bg: oklch(96% 0.014 292);
--color-tsb12-surf: #ffffff;
--color-tsb12-surf: oklch(100% 0 0);
--color-tsb12-surf2: #ece7f9;
--color-tsb12-surf2: oklch(93.5% 0.022 292);
--color-tsb12-line: #d9d1f0;
--color-tsb12-line: oklch(87% 0.035 292);
--color-tsb12-ink: #1d1830;
--color-tsb12-ink: oklch(22% 0.04 292);
--color-tsb12-mut: #625b80;
--color-tsb12-mut: oklch(48% 0.05 292);
--color-tsb12-acc: #6b3fd4;
--color-tsb12-acc: oklch(52% 0.2 292);
--color-tsb12-accsoft: #e6dcfb;
--color-tsb12-accsoft: oklch(92% 0.04 292);
--color-tsb12-onacc: #ffffff;
--color-tsb12-onacc: oklch(100% 0 0);
--font-tsb12-sans: system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-tsb12-display: 'Plus Jakarta Sans', 'SF Pro Display', system-ui, sans-serif;
}
/* Root rule: the full-bleed contract. The mode breakpoint is deliberately NOT here — it
lives once, in the script's matchMedia, and the markup keys off data-mode. */
.tsb-12 {
width: 100%;
min-height: 100svh;
}
.tsb-12 *,
.tsb-12 *::before,
.tsb-12 *::after {
box-sizing: border-box;
}
/* Token flip for the pure-CSS theme toggle, plus the two mandatory mirror blocks. */
.tsb-12:has(.tsb-12-theme-input:checked) {
--color-tsb12-bg: #0d0a17;
--color-tsb12-surf: #151020;
--color-tsb12-surf2: #1d1730;
--color-tsb12-line: #2b2342;
--color-tsb12-ink: #ece8f8;
--color-tsb12-mut: #a099c0;
--color-tsb12-acc: #b394ff;
--color-tsb12-acc: oklch(80% 0.14 292);
--color-tsb12-accsoft: #241c3c;
--color-tsb12-onacc: #0d0a17;
}
@media (prefers-color-scheme: dark) {
.tsb-12 {
--color-tsb12-bg: #0d0a17;
--color-tsb12-surf: #151020;
--color-tsb12-surf2: #1d1730;
--color-tsb12-line: #2b2342;
--color-tsb12-ink: #ece8f8;
--color-tsb12-mut: #a099c0;
--color-tsb12-acc: #b394ff;
--color-tsb12-acc: oklch(80% 0.14 292);
--color-tsb12-accsoft: #241c3c;
--color-tsb12-onacc: #0d0a17;
}
.tsb-12:has(.tsb-12-theme-input:checked) {
--color-tsb12-bg: #f4f1fb;
--color-tsb12-surf: #ffffff;
--color-tsb12-surf2: #ece7f9;
--color-tsb12-line: #d9d1f0;
--color-tsb12-ink: #1d1830;
--color-tsb12-mut: #625b80;
--color-tsb12-acc: #6b3fd4;
--color-tsb12-acc: oklch(52% 0.2 292);
--color-tsb12-accsoft: #e6dcfb;
--color-tsb12-onacc: #ffffff;
}
}
@media (prefers-color-scheme: light) {
.tsb-12:has(.tsb-12-theme-input:checked) {
--color-tsb12-bg: #0d0a17;
--color-tsb12-surf: #151020;
--color-tsb12-surf2: #1d1730;
--color-tsb12-line: #2b2342;
--color-tsb12-ink: #ece8f8;
--color-tsb12-mut: #a099c0;
--color-tsb12-acc: #b394ff;
--color-tsb12-acc: oklch(80% 0.14 292);
--color-tsb12-accsoft: #241c3c;
--color-tsb12-onacc: #0d0a17;
}
}
```
## JavaScript
```js
(function () {
document.querySelectorAll('.tsb-12').forEach(function (root) {
if (root.dataset.bound) return; root.dataset.bound = '1';
var app = root.querySelector('#tsb-12-app'),
rail = root.querySelector('#tsb-12-rail'),
content = root.querySelector('#tsb-12-content'),
scrim = root.querySelector('[data-tsb-12-scrim]'),
trigger = root.querySelector('#tsb-12-open'),
supportsInert = 'inert' in HTMLElement.prototype,
// One source of truth for the mode: the markup keys off data-mode, never its own
// copy of the breakpoint.
mq = window.matchMedia('(min-width: 44rem)'),
open = false, returnTo = null;
function modal() { return !mq.matches; }
function onKey(e) { if (e.key === 'Escape' && open) setOpen(false); }
function paint() {
app.setAttribute('data-mode', modal() ? 'modal' : 'persistent');
app.toggleAttribute('data-open', open);
rail.hidden = modal() && !open;
scrim.hidden = !(modal() && open);
rail.setAttribute('role', modal() ? 'dialog' : 'complementary');
if (modal()) rail.setAttribute('aria-modal', 'true'); else rail.removeAttribute('aria-modal');
trigger.setAttribute('aria-expanded', String(modal() && open));
var block = modal() && open;
if (supportsInert) content.inert = block;
else content.setAttribute('aria-hidden', String(block));
}
function setOpen(next) {
open = next;
if (next) { returnTo = document.activeElement; document.addEventListener('keydown', onKey); }
else document.removeEventListener('keydown', onKey);
paint();
if (next) requestAnimationFrame(function () { rail.querySelector('[data-tsb-12-close]').focus(); });
else if (returnTo) { returnTo.focus({ preventScroll: true }); returnTo = null; }
}
function onModeChange() { if (open && !modal()) setOpen(false); else paint(); }
mq.addEventListener('change', onModeChange);
trigger.addEventListener('click', function () { setOpen(true); });
scrim.addEventListener('click', function () { setOpen(false); });
root.querySelectorAll('[data-tsb-12-close]').forEach(function (b) { b.addEventListener('click', function () { setOpen(false); }); });
paint();
// matchMedia can read stale at parse time, so settle the mode after first layout too.
requestAnimationFrame(paint);
root.__tsbCleanup = function () {
document.removeEventListener('keydown', onKey);
mq.removeEventListener('change', onModeChange);
};
});
})();
```How this works
One breakpoint, one owner. The script holds matchMedia('(min-width: 44rem)') and writes data-mode="modal" or "persistent". Every utility reads that attribute:
<aside class="flex-none w-56 order-[-1]
group-data-[mode=modal]:absolute
group-data-[mode=modal]:inset-y-0
group-data-[mode=modal]:-translate-x-full
group-data-[mode=modal]:group-data-open:translate-x-0">Writing r44: variants instead would put the number in two places, and the day someone edits one of them the drawer and its semantics disagree. This is the one case in the collection where a data attribute is preferable to a responsive variant.
The role really does change. In persistent mode the rail is role="complementary", always visible, no trigger. In modal mode it is role="dialog" aria-modal="true", hidden until opened, with the background inert and focus moved in. Same markup, two different promises.
Crossing the breakpoint while open has to be handled. If the viewport widens mid-drawer, the script closes it before repainting — otherwise you get a persistent rail with a scrim over the page and focus trapped in a dialog that no longer exists.
matchMedia can read stale at parse time, so the mode is settled once more inside requestAnimationFrame after first layout.
Make it yours
- Move the breakpoint in ONE place: the
matchMediastring. Nothing in the markup mentions it. - Change the drawer width with
group-data-[mode=modal]:w-[min(15rem,82%)]; the persistent width is the plainw-56. - Add the full focus trap from demo 02 if the modal mode needs it —
inertplus focus return is the floor, a trap is the belt. - Retheme through
--color-tsb12-acc. - Swap the left drawer for a right one:
left-0→right-0, flip the translate sign, drop theorder-[-1]. - Prefer a pure-CSS version? Only if the role never changes. A sidebar that becomes a dialog cannot be done without script, and pretending otherwise is the bug this demo documents.
Gotchas — read before shipping
- Never duplicate the breakpoint in both CSS and JS. The stylesheet keying off
data-modeis what keeps one source of truth. - A rail that becomes a dialog must gain
role="dialog"andaria-modaland LOSE them again; leavingaria-modal="true"on a persistent rail makes the rest of the page unreachable to screen readers. - Handle the resize-while-open case explicitly or you strand a scrim and a focus trap on a desktop layout.
aria-expandedon the trigger must be false in persistent mode — it controls nothing there, and the button is hidden.- Two stacked data variants (
group-data-[mode=modal]:group-data-open:) are order-sensitive to read but not to write; keep the mode first so the class list groups by mode. order-[-1]changes visual order only. Tab order still follows the DOM, which is why the rail comes after the content in the markup on purpose.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 111+ | 16.4+ | 128+ | 111+ |
Tailwind v4's generated sheet sets the floor. matchMedia with addEventListener('change') is Baseline since 2020, and inert since 2023; the demo feature-detects inert and falls back to aria-hidden.