02 / 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 Sidebar Focus Trap and Restore on Close
A modal sidebar makes three promises to a keyboard user: focus moves in, focus stays in, focus comes back. The trap is hand-wired script — it has to be — but everything visual moves into the class list: the drawer states its closed and open transforms on itself and reads the open state through group-data-open:, so the whole [data-open] .drawer rule disappears.
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-02 block relative w-full min-h-svh bg-tsb02-bg text-tsb02-ink font-tsb02-sans leading-[1.55]" aria-label="Meridian sprint board">
<div class="absolute top-3.5 right-3.5 z-60">
<input class="tsb-02-theme-input peer absolute inset-0 w-full h-full m-0 opacity-0 cursor-pointer" type="checkbox" id="tsb-02-theme">
<label class="grid place-items-center w-11 h-11 rounded-[0.7rem] border border-tsb02-line bg-tsb02-surf text-tsb02-ink cursor-pointer shadow-[0_0.15rem_0.5rem_oklch(20%_0.04_265/0.1)] peer-focus-visible:outline-2 peer-focus-visible:outline-tsb02-acc peer-focus-visible:outline-offset-2" for="tsb-02-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 grid grid-rows-[auto_minmax(0,1fr)] min-h-svh bg-tsb02-bg">
<div class="flex flex-wrap items-center gap-2 py-2.5 pl-4 pr-[4.2rem] bg-tsb02-deep text-tsb02-ondeep" id="tsb-02-bg">
<p class="mr-auto m-0 font-bold tracking-[-0.01em]">Meridian</p>
<a class="inline-flex items-center min-h-11 px-2.5 rounded-lg text-[0.9rem] text-tsb02-ondeep/80 no-underline hover:text-tsb02-ondeep focus-visible:outline-2 focus-visible:outline-white focus-visible:outline-offset-2" href="#tsb-02-main">Sprint 24</a>
<a class="inline-flex items-center min-h-11 px-2.5 rounded-lg text-[0.9rem] text-tsb02-ondeep/80 no-underline hover:text-tsb02-ondeep focus-visible:outline-2 focus-visible:outline-white focus-visible:outline-offset-2" href="#tsb-02-main">Backlog</a>
<button class="order-3 inline-flex items-center gap-2 min-h-11 px-3.5 rounded-[0.6rem] border-0 bg-tsb02-acc text-tsb02-onacc font-tsb02-sans text-[0.9rem] font-semibold cursor-pointer [&>svg]:w-4 [&>svg]:h-4 focus-visible:outline-2 focus-visible:outline-white focus-visible:outline-offset-2" type="button" id="tsb-02-open" aria-expanded="false" aria-controls="tsb-02-drawer">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 7h16M4 12h16M4 17h16" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>
Project menu
</button>
</div>
<main class="grid grid-cols-[minmax(0,1fr)] content-start gap-3.5 p-4.5 min-w-0 sm:grid-cols-2 sm:p-6.5 lg:grid-cols-4 lg:p-8" id="tsb-02-main" aria-label="Sprint board">
<article class="min-w-0 p-4 rounded-[0.8rem] bg-tsb02-surf border border-tsb02-line"><p class="m-0 mb-1.5 text-[0.7rem] tracking-[0.1em] uppercase text-tsb02-acc">In review</p><h3 class="m-0 mb-1.5 text-[0.98rem] leading-[1.25]">Rate limit on token exchange</h3><p class="m-0 text-[0.8rem] text-tsb02-mut">Priya Sharma · 3 files</p></article>
<article class="min-w-0 p-4 rounded-[0.8rem] bg-tsb02-surf border border-tsb02-line"><p class="m-0 mb-1.5 text-[0.7rem] tracking-[0.1em] uppercase text-tsb02-acc">In progress</p><h3 class="m-0 mb-1.5 text-[0.98rem] leading-[1.25]">Audit log retention window</h3><p class="m-0 text-[0.8rem] text-tsb02-mut">Jordan Lee · 8 files</p></article>
<article class="min-w-0 p-4 rounded-[0.8rem] bg-tsb02-surf border border-tsb02-line"><p class="m-0 mb-1.5 text-[0.7rem] tracking-[0.1em] uppercase text-tsb02-acc">Blocked</p><h3 class="m-0 mb-1.5 text-[0.98rem] leading-[1.25]">Billing webhook replay</h3><p class="m-0 text-[0.8rem] text-tsb02-mut">Sam Rivera · 1 file</p></article>
<article class="min-w-0 p-4 rounded-[0.8rem] bg-tsb02-surf border border-tsb02-line"><p class="m-0 mb-1.5 text-[0.7rem] tracking-[0.1em] uppercase text-tsb02-acc">Shipped</p><h3 class="m-0 mb-1.5 text-[0.98rem] leading-[1.25]">Session revoke endpoint</h3><p class="m-0 text-[0.8rem] text-tsb02-mut">Ada Okafor · 5 files</p></article>
</main>
<div class="absolute inset-0 z-20 bg-[oklch(18%_0.03_265/0.5)] [&[hidden]]:hidden" data-tsb-02-scrim hidden></div>
<div class="absolute inset-y-0 left-0 z-30 flex flex-col w-[min(19rem,86%)] bg-tsb02-surf border-r border-tsb02-line shadow-[0_1.5rem_3rem_oklch(18%_0.04_265/0.22)] -translate-x-full transition-transform duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] group-data-open:translate-x-0 motion-reduce:duration-[1ms] [&[hidden]]:hidden" id="tsb-02-drawer" role="dialog" aria-modal="true" aria-labelledby="tsb-02-dh" hidden>
<header class="flex items-center gap-2 px-3.5 py-3 border-b border-tsb02-line">
<h3 class="mr-auto m-0 text-base font-bold" id="tsb-02-dh">Meridian project</h3>
<button class="grid place-items-center w-11 h-11 rounded-[0.6rem] border border-tsb02-line bg-transparent text-tsb02-ink cursor-pointer [&>svg]:w-4 [&>svg]:h-4 focus-visible:outline-2 focus-visible:outline-tsb02-acc focus-visible:outline-offset-2" type="button" data-tsb-02-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 project menu</span>
</button>
</header>
<nav class="flex-1 min-h-0 overflow-y-auto p-2 [overscroll-behavior:contain]" aria-label="Meridian project">
<ul class="list-none m-0 p-0">
<li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.55rem] text-[0.93rem] text-tsb02-ink no-underline hover:bg-tsb02-accsoft focus-visible:outline-2 focus-visible:outline-tsb02-acc focus-visible:outline-offset-2 aria-[current=page]:bg-tsb02-accsoft aria-[current=page]:text-tsb02-acc aria-[current=page]:font-semibold" href="#tsb-02-main" aria-current="page">Sprint board</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.55rem] text-[0.93rem] text-tsb02-ink no-underline hover:bg-tsb02-accsoft focus-visible:outline-2 focus-visible:outline-tsb02-acc focus-visible:outline-offset-2" href="#tsb-02-main">Backlog</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.55rem] text-[0.93rem] text-tsb02-ink no-underline hover:bg-tsb02-accsoft focus-visible:outline-2 focus-visible:outline-tsb02-acc focus-visible:outline-offset-2" href="#tsb-02-main">Deployments</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.55rem] text-[0.93rem] text-tsb02-ink no-underline hover:bg-tsb02-accsoft focus-visible:outline-2 focus-visible:outline-tsb02-acc focus-visible:outline-offset-2" href="#tsb-02-main">Audit log</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.55rem] text-[0.93rem] text-tsb02-ink no-underline hover:bg-tsb02-accsoft focus-visible:outline-2 focus-visible:outline-tsb02-acc focus-visible:outline-offset-2" href="#tsb-02-main">Team settings</a></li>
</ul>
</nav>
<footer class="px-3.5 py-2.5 border-t border-tsb02-line">
<button class="w-full min-h-11 rounded-[0.6rem] border border-tsb02-line bg-transparent text-tsb02-ink font-tsb02-sans text-[0.9rem] font-semibold cursor-pointer focus-visible:outline-2 focus-visible:outline-tsb02-acc focus-visible:outline-offset-2" type="button" data-tsb-02-close>Done</button>
</footer>
</div>
</div>
</section><section class="tsb-02 block relative w-full min-h-svh bg-tsb02-bg text-tsb02-ink font-tsb02-sans leading-[1.55]" aria-label="Meridian sprint board">
<div class="absolute top-3.5 right-3.5 z-60">
<input class="tsb-02-theme-input peer absolute inset-0 w-full h-full m-0 opacity-0 cursor-pointer" type="checkbox" id="tsb-02-theme">
<label class="grid place-items-center w-11 h-11 rounded-[0.7rem] border border-tsb02-line bg-tsb02-surf text-tsb02-ink cursor-pointer shadow-[0_0.15rem_0.5rem_oklch(20%_0.04_265/0.1)] peer-focus-visible:outline-2 peer-focus-visible:outline-tsb02-acc peer-focus-visible:outline-offset-2" for="tsb-02-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 grid grid-rows-[auto_minmax(0,1fr)] min-h-svh bg-tsb02-bg">
<div class="flex flex-wrap items-center gap-2 py-2.5 pl-4 pr-[4.2rem] bg-tsb02-deep text-tsb02-ondeep" id="tsb-02-bg">
<p class="mr-auto m-0 font-bold tracking-[-0.01em]">Meridian</p>
<a class="inline-flex items-center min-h-11 px-2.5 rounded-lg text-[0.9rem] text-tsb02-ondeep/80 no-underline hover:text-tsb02-ondeep focus-visible:outline-2 focus-visible:outline-white focus-visible:outline-offset-2" href="#tsb-02-main">Sprint 24</a>
<a class="inline-flex items-center min-h-11 px-2.5 rounded-lg text-[0.9rem] text-tsb02-ondeep/80 no-underline hover:text-tsb02-ondeep focus-visible:outline-2 focus-visible:outline-white focus-visible:outline-offset-2" href="#tsb-02-main">Backlog</a>
<button class="order-3 inline-flex items-center gap-2 min-h-11 px-3.5 rounded-[0.6rem] border-0 bg-tsb02-acc text-tsb02-onacc font-tsb02-sans text-[0.9rem] font-semibold cursor-pointer [&>svg]:w-4 [&>svg]:h-4 focus-visible:outline-2 focus-visible:outline-white focus-visible:outline-offset-2" type="button" id="tsb-02-open" aria-expanded="false" aria-controls="tsb-02-drawer">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 7h16M4 12h16M4 17h16" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>
Project menu
</button>
</div>
<main class="grid grid-cols-[minmax(0,1fr)] content-start gap-3.5 p-4.5 min-w-0 sm:grid-cols-2 sm:p-6.5 lg:grid-cols-4 lg:p-8" id="tsb-02-main" aria-label="Sprint board">
<article class="min-w-0 p-4 rounded-[0.8rem] bg-tsb02-surf border border-tsb02-line"><p class="m-0 mb-1.5 text-[0.7rem] tracking-[0.1em] uppercase text-tsb02-acc">In review</p><h3 class="m-0 mb-1.5 text-[0.98rem] leading-[1.25]">Rate limit on token exchange</h3><p class="m-0 text-[0.8rem] text-tsb02-mut">Priya Sharma · 3 files</p></article>
<article class="min-w-0 p-4 rounded-[0.8rem] bg-tsb02-surf border border-tsb02-line"><p class="m-0 mb-1.5 text-[0.7rem] tracking-[0.1em] uppercase text-tsb02-acc">In progress</p><h3 class="m-0 mb-1.5 text-[0.98rem] leading-[1.25]">Audit log retention window</h3><p class="m-0 text-[0.8rem] text-tsb02-mut">Jordan Lee · 8 files</p></article>
<article class="min-w-0 p-4 rounded-[0.8rem] bg-tsb02-surf border border-tsb02-line"><p class="m-0 mb-1.5 text-[0.7rem] tracking-[0.1em] uppercase text-tsb02-acc">Blocked</p><h3 class="m-0 mb-1.5 text-[0.98rem] leading-[1.25]">Billing webhook replay</h3><p class="m-0 text-[0.8rem] text-tsb02-mut">Sam Rivera · 1 file</p></article>
<article class="min-w-0 p-4 rounded-[0.8rem] bg-tsb02-surf border border-tsb02-line"><p class="m-0 mb-1.5 text-[0.7rem] tracking-[0.1em] uppercase text-tsb02-acc">Shipped</p><h3 class="m-0 mb-1.5 text-[0.98rem] leading-[1.25]">Session revoke endpoint</h3><p class="m-0 text-[0.8rem] text-tsb02-mut">Ada Okafor · 5 files</p></article>
</main>
<div class="absolute inset-0 z-20 bg-[oklch(18%_0.03_265/0.5)] [&[hidden]]:hidden" data-tsb-02-scrim hidden></div>
<div class="absolute inset-y-0 left-0 z-30 flex flex-col w-[min(19rem,86%)] bg-tsb02-surf border-r border-tsb02-line shadow-[0_1.5rem_3rem_oklch(18%_0.04_265/0.22)] -translate-x-full transition-transform duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] group-data-open:translate-x-0 motion-reduce:duration-[1ms] [&[hidden]]:hidden" id="tsb-02-drawer" role="dialog" aria-modal="true" aria-labelledby="tsb-02-dh" hidden>
<header class="flex items-center gap-2 px-3.5 py-3 border-b border-tsb02-line">
<h3 class="mr-auto m-0 text-base font-bold" id="tsb-02-dh">Meridian project</h3>
<button class="grid place-items-center w-11 h-11 rounded-[0.6rem] border border-tsb02-line bg-transparent text-tsb02-ink cursor-pointer [&>svg]:w-4 [&>svg]:h-4 focus-visible:outline-2 focus-visible:outline-tsb02-acc focus-visible:outline-offset-2" type="button" data-tsb-02-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 project menu</span>
</button>
</header>
<nav class="flex-1 min-h-0 overflow-y-auto p-2 [overscroll-behavior:contain]" aria-label="Meridian project">
<ul class="list-none m-0 p-0">
<li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.55rem] text-[0.93rem] text-tsb02-ink no-underline hover:bg-tsb02-accsoft focus-visible:outline-2 focus-visible:outline-tsb02-acc focus-visible:outline-offset-2 aria-[current=page]:bg-tsb02-accsoft aria-[current=page]:text-tsb02-acc aria-[current=page]:font-semibold" href="#tsb-02-main" aria-current="page">Sprint board</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.55rem] text-[0.93rem] text-tsb02-ink no-underline hover:bg-tsb02-accsoft focus-visible:outline-2 focus-visible:outline-tsb02-acc focus-visible:outline-offset-2" href="#tsb-02-main">Backlog</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.55rem] text-[0.93rem] text-tsb02-ink no-underline hover:bg-tsb02-accsoft focus-visible:outline-2 focus-visible:outline-tsb02-acc focus-visible:outline-offset-2" href="#tsb-02-main">Deployments</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.55rem] text-[0.93rem] text-tsb02-ink no-underline hover:bg-tsb02-accsoft focus-visible:outline-2 focus-visible:outline-tsb02-acc focus-visible:outline-offset-2" href="#tsb-02-main">Audit log</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.55rem] text-[0.93rem] text-tsb02-ink no-underline hover:bg-tsb02-accsoft focus-visible:outline-2 focus-visible:outline-tsb02-acc focus-visible:outline-offset-2" href="#tsb-02-main">Team settings</a></li>
</ul>
</nav>
<footer class="px-3.5 py-2.5 border-t border-tsb02-line">
<button class="w-full min-h-11 rounded-[0.6rem] border border-tsb02-line bg-transparent text-tsb02-ink font-tsb02-sans text-[0.9rem] font-semibold cursor-pointer focus-visible:outline-2 focus-visible:outline-tsb02-acc focus-visible:outline-offset-2" type="button" data-tsb-02-close>Done</button>
</footer>
</div>
</div>
</section>@import "tailwindcss";
@theme {
--color-tsb02-bg: #f2f4f9;
--color-tsb02-bg: oklch(96% 0.008 265);
--color-tsb02-surf: #ffffff;
--color-tsb02-surf: oklch(100% 0 0);
--color-tsb02-deep: #101a34;
--color-tsb02-deep: oklch(21% 0.06 265);
--color-tsb02-line: #d5dbe8;
--color-tsb02-line: oklch(88% 0.015 265);
--color-tsb02-ink: #0d1428;
--color-tsb02-ink: oklch(17% 0.04 265);
--color-tsb02-mut: #5c6684;
--color-tsb02-mut: oklch(48% 0.03 265);
--color-tsb02-acc: #2f4de0;
--color-tsb02-acc: oklch(50% 0.2 267);
--color-tsb02-accsoft: #e5e9fd;
--color-tsb02-accsoft: oklch(94% 0.03 267);
--color-tsb02-ondeep: #eaeefb;
--color-tsb02-ondeep: oklch(94% 0.015 265);
--color-tsb02-onacc: #ffffff;
--color-tsb02-onacc: oklch(100% 0 0);
--font-tsb02-sans: 'Space Grotesk', system-ui, 'Segoe UI', sans-serif;
}
/* Root rule: the full-bleed contract, stated in CSS as well as in the markup's
w-full min-h-svh. It scopes the demo, and it holds the stage at full height in the
moment before the browser build has compiled its utilities. */
.tsb-02 {
width: 100%;
min-height: 100svh;
}
.tsb-02 *,
.tsb-02 *::before,
.tsb-02 *::after {
box-sizing: border-box;
}
/* Token flip for the pure-CSS theme toggle, with the two mandatory mirror blocks.
Not expressible as utilities: it sets variables for a subtree, not a property on
an element. --color-tsb02-onacc flips too, because dark accent needs dark ink on it. */
.tsb-02:has(.tsb-02-theme-input:checked) {
--color-tsb02-bg: #070b16;
--color-tsb02-surf: #101728;
--color-tsb02-deep: #060a14;
--color-tsb02-line: #212b45;
--color-tsb02-ink: #e9edf9;
--color-tsb02-mut: #93a0c0;
--color-tsb02-acc: #93a8ff;
--color-tsb02-acc: oklch(80% 0.12 268);
--color-tsb02-accsoft: #1a2344;
--color-tsb02-ondeep: #e9edf9;
--color-tsb02-onacc: #070b16;
}
@media (prefers-color-scheme: dark) {
.tsb-02 {
--color-tsb02-bg: #070b16;
--color-tsb02-surf: #101728;
--color-tsb02-deep: #060a14;
--color-tsb02-line: #212b45;
--color-tsb02-ink: #e9edf9;
--color-tsb02-mut: #93a0c0;
--color-tsb02-acc: #93a8ff;
--color-tsb02-acc: oklch(80% 0.12 268);
--color-tsb02-accsoft: #1a2344;
--color-tsb02-ondeep: #e9edf9;
--color-tsb02-onacc: #070b16;
}
.tsb-02:has(.tsb-02-theme-input:checked) {
--color-tsb02-bg: #f2f4f9;
--color-tsb02-surf: #ffffff;
--color-tsb02-deep: #101a34;
--color-tsb02-line: #d5dbe8;
--color-tsb02-ink: #0d1428;
--color-tsb02-mut: #5c6684;
--color-tsb02-acc: #2f4de0;
--color-tsb02-acc: oklch(50% 0.2 267);
--color-tsb02-accsoft: #e5e9fd;
--color-tsb02-ondeep: #eaeefb;
--color-tsb02-onacc: #ffffff;
}
}
@media (prefers-color-scheme: light) {
.tsb-02:has(.tsb-02-theme-input:checked) {
--color-tsb02-bg: #070b16;
--color-tsb02-surf: #101728;
--color-tsb02-deep: #060a14;
--color-tsb02-line: #212b45;
--color-tsb02-ink: #e9edf9;
--color-tsb02-mut: #93a0c0;
--color-tsb02-acc: #93a8ff;
--color-tsb02-acc: oklch(80% 0.12 268);
--color-tsb02-accsoft: #1a2344;
--color-tsb02-ondeep: #e9edf9;
--color-tsb02-onacc: #070b16;
}
}@import "tailwindcss";
@theme {
--color-tsb02-bg: #f2f4f9;
--color-tsb02-bg: oklch(96% 0.008 265);
--color-tsb02-surf: #ffffff;
--color-tsb02-surf: oklch(100% 0 0);
--color-tsb02-deep: #101a34;
--color-tsb02-deep: oklch(21% 0.06 265);
--color-tsb02-line: #d5dbe8;
--color-tsb02-line: oklch(88% 0.015 265);
--color-tsb02-ink: #0d1428;
--color-tsb02-ink: oklch(17% 0.04 265);
--color-tsb02-mut: #5c6684;
--color-tsb02-mut: oklch(48% 0.03 265);
--color-tsb02-acc: #2f4de0;
--color-tsb02-acc: oklch(50% 0.2 267);
--color-tsb02-accsoft: #e5e9fd;
--color-tsb02-accsoft: oklch(94% 0.03 267);
--color-tsb02-ondeep: #eaeefb;
--color-tsb02-ondeep: oklch(94% 0.015 265);
--color-tsb02-onacc: #ffffff;
--color-tsb02-onacc: oklch(100% 0 0);
--font-tsb02-sans: 'Space Grotesk', system-ui, 'Segoe UI', sans-serif;
}
/* Root rule: the full-bleed contract, stated in CSS as well as in the markup's
w-full min-h-svh. It scopes the demo, and it holds the stage at full height in the
moment before the browser build has compiled its utilities. */
.tsb-02 {
width: 100%;
min-height: 100svh;
}
.tsb-02 *,
.tsb-02 *::before,
.tsb-02 *::after {
box-sizing: border-box;
}
/* Token flip for the pure-CSS theme toggle, with the two mandatory mirror blocks.
Not expressible as utilities: it sets variables for a subtree, not a property on
an element. --color-tsb02-onacc flips too, because dark accent needs dark ink on it. */
.tsb-02:has(.tsb-02-theme-input:checked) {
--color-tsb02-bg: #070b16;
--color-tsb02-surf: #101728;
--color-tsb02-deep: #060a14;
--color-tsb02-line: #212b45;
--color-tsb02-ink: #e9edf9;
--color-tsb02-mut: #93a0c0;
--color-tsb02-acc: #93a8ff;
--color-tsb02-acc: oklch(80% 0.12 268);
--color-tsb02-accsoft: #1a2344;
--color-tsb02-ondeep: #e9edf9;
--color-tsb02-onacc: #070b16;
}
@media (prefers-color-scheme: dark) {
.tsb-02 {
--color-tsb02-bg: #070b16;
--color-tsb02-surf: #101728;
--color-tsb02-deep: #060a14;
--color-tsb02-line: #212b45;
--color-tsb02-ink: #e9edf9;
--color-tsb02-mut: #93a0c0;
--color-tsb02-acc: #93a8ff;
--color-tsb02-acc: oklch(80% 0.12 268);
--color-tsb02-accsoft: #1a2344;
--color-tsb02-ondeep: #e9edf9;
--color-tsb02-onacc: #070b16;
}
.tsb-02:has(.tsb-02-theme-input:checked) {
--color-tsb02-bg: #f2f4f9;
--color-tsb02-surf: #ffffff;
--color-tsb02-deep: #101a34;
--color-tsb02-line: #d5dbe8;
--color-tsb02-ink: #0d1428;
--color-tsb02-mut: #5c6684;
--color-tsb02-acc: #2f4de0;
--color-tsb02-acc: oklch(50% 0.2 267);
--color-tsb02-accsoft: #e5e9fd;
--color-tsb02-ondeep: #eaeefb;
--color-tsb02-onacc: #ffffff;
}
}
@media (prefers-color-scheme: light) {
.tsb-02:has(.tsb-02-theme-input:checked) {
--color-tsb02-bg: #070b16;
--color-tsb02-surf: #101728;
--color-tsb02-deep: #060a14;
--color-tsb02-line: #212b45;
--color-tsb02-ink: #e9edf9;
--color-tsb02-mut: #93a0c0;
--color-tsb02-acc: #93a8ff;
--color-tsb02-acc: oklch(80% 0.12 268);
--color-tsb02-accsoft: #1a2344;
--color-tsb02-ondeep: #e9edf9;
--color-tsb02-onacc: #070b16;
}
}(function () {
document.querySelectorAll('.tsb-02').forEach(function (root) {
if (root.dataset.bound) return; root.dataset.bound = '1';
var stage = root.querySelector('.group'),
drawer = root.querySelector('#tsb-02-drawer'),
scrim = root.querySelector('[data-tsb-02-scrim]'),
trigger = root.querySelector('#tsb-02-open'),
SEL = 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])',
returnTo = null;
function focusables() {
return Array.prototype.slice.call(drawer.querySelectorAll(SEL))
.filter(function (el) { return el.offsetParent !== null; });
}
function onKey(e) {
if (e.key === 'Escape') { close(); return; }
if (e.key !== 'Tab') return;
var f = focusables(); if (!f.length) return;
var first = f[0], last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}
function open() {
returnTo = document.activeElement;
drawer.hidden = false; scrim.hidden = false;
stage.setAttribute('data-open', '');
trigger.setAttribute('aria-expanded', 'true');
requestAnimationFrame(function () { drawer.querySelector('[data-tsb-02-close]').focus(); });
document.addEventListener('keydown', onKey);
}
function close() {
document.removeEventListener('keydown', onKey);
stage.removeAttribute('data-open');
trigger.setAttribute('aria-expanded', 'false');
drawer.hidden = true; scrim.hidden = true;
(returnTo || trigger).focus({ preventScroll: true });
returnTo = null;
}
trigger.addEventListener('click', open);
scrim.addEventListener('click', close);
root.querySelectorAll('[data-tsb-02-close]').forEach(function (b) { b.addEventListener('click', close); });
root.__tsbCleanup = function () { document.removeEventListener('keydown', onKey); };
});
})();(function () {
document.querySelectorAll('.tsb-02').forEach(function (root) {
if (root.dataset.bound) return; root.dataset.bound = '1';
var stage = root.querySelector('.group'),
drawer = root.querySelector('#tsb-02-drawer'),
scrim = root.querySelector('[data-tsb-02-scrim]'),
trigger = root.querySelector('#tsb-02-open'),
SEL = 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])',
returnTo = null;
function focusables() {
return Array.prototype.slice.call(drawer.querySelectorAll(SEL))
.filter(function (el) { return el.offsetParent !== null; });
}
function onKey(e) {
if (e.key === 'Escape') { close(); return; }
if (e.key !== 'Tab') return;
var f = focusables(); if (!f.length) return;
var first = f[0], last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}
function open() {
returnTo = document.activeElement;
drawer.hidden = false; scrim.hidden = false;
stage.setAttribute('data-open', '');
trigger.setAttribute('aria-expanded', 'true');
requestAnimationFrame(function () { drawer.querySelector('[data-tsb-02-close]').focus(); });
document.addEventListener('keydown', onKey);
}
function close() {
document.removeEventListener('keydown', onKey);
stage.removeAttribute('data-open');
trigger.setAttribute('aria-expanded', 'false');
drawer.hidden = true; scrim.hidden = true;
(returnTo || trigger).focus({ preventScroll: true });
returnTo = null;
}
trigger.addEventListener('click', open);
scrim.addEventListener('click', close);
root.querySelectorAll('[data-tsb-02-close]').forEach(function (b) { b.addEventListener('click', close); });
root.__tsbCleanup = function () { document.removeEventListener('keydown', onKey); };
});
})();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-tsb02-bg: #f2f4f9;
--color-tsb02-bg: oklch(96% 0.008 265);
--color-tsb02-surf: #ffffff;
--color-tsb02-surf: oklch(100% 0 0);
--color-tsb02-deep: #101a34;
--color-tsb02-deep: oklch(21% 0.06 265);
--color-tsb02-line: #d5dbe8;
--color-tsb02-line: oklch(88% 0.015 265);
--color-tsb02-ink: #0d1428;
--color-tsb02-ink: oklch(17% 0.04 265);
--color-tsb02-mut: #5c6684;
--color-tsb02-mut: oklch(48% 0.03 265);
--color-tsb02-acc: #2f4de0;
--color-tsb02-acc: oklch(50% 0.2 267);
--color-tsb02-accsoft: #e5e9fd;
--color-tsb02-accsoft: oklch(94% 0.03 267);
--color-tsb02-ondeep: #eaeefb;
--color-tsb02-ondeep: oklch(94% 0.015 265);
--color-tsb02-onacc: #ffffff;
--color-tsb02-onacc: oklch(100% 0 0);
--font-tsb02-sans: 'Space Grotesk', system-ui, 'Segoe UI', sans-serif;
}
/* Root rule: the full-bleed contract, stated in CSS as well as in the markup's
w-full min-h-svh. It scopes the demo, and it holds the stage at full height in the
moment before the browser build has compiled its utilities. */
.tsb-02 {
width: 100%;
min-height: 100svh;
}
.tsb-02 *,
.tsb-02 *::before,
.tsb-02 *::after {
box-sizing: border-box;
}
/* Token flip for the pure-CSS theme toggle, with the two mandatory mirror blocks.
Not expressible as utilities: it sets variables for a subtree, not a property on
an element. --color-tsb02-onacc flips too, because dark accent needs dark ink on it. */
.tsb-02:has(.tsb-02-theme-input:checked) {
--color-tsb02-bg: #070b16;
--color-tsb02-surf: #101728;
--color-tsb02-deep: #060a14;
--color-tsb02-line: #212b45;
--color-tsb02-ink: #e9edf9;
--color-tsb02-mut: #93a0c0;
--color-tsb02-acc: #93a8ff;
--color-tsb02-acc: oklch(80% 0.12 268);
--color-tsb02-accsoft: #1a2344;
--color-tsb02-ondeep: #e9edf9;
--color-tsb02-onacc: #070b16;
}
@media (prefers-color-scheme: dark) {
.tsb-02 {
--color-tsb02-bg: #070b16;
--color-tsb02-surf: #101728;
--color-tsb02-deep: #060a14;
--color-tsb02-line: #212b45;
--color-tsb02-ink: #e9edf9;
--color-tsb02-mut: #93a0c0;
--color-tsb02-acc: #93a8ff;
--color-tsb02-acc: oklch(80% 0.12 268);
--color-tsb02-accsoft: #1a2344;
--color-tsb02-ondeep: #e9edf9;
--color-tsb02-onacc: #070b16;
}
.tsb-02:has(.tsb-02-theme-input:checked) {
--color-tsb02-bg: #f2f4f9;
--color-tsb02-surf: #ffffff;
--color-tsb02-deep: #101a34;
--color-tsb02-line: #d5dbe8;
--color-tsb02-ink: #0d1428;
--color-tsb02-mut: #5c6684;
--color-tsb02-acc: #2f4de0;
--color-tsb02-acc: oklch(50% 0.2 267);
--color-tsb02-accsoft: #e5e9fd;
--color-tsb02-ondeep: #eaeefb;
--color-tsb02-onacc: #ffffff;
}
}
@media (prefers-color-scheme: light) {
.tsb-02:has(.tsb-02-theme-input:checked) {
--color-tsb02-bg: #070b16;
--color-tsb02-surf: #101728;
--color-tsb02-deep: #060a14;
--color-tsb02-line: #212b45;
--color-tsb02-ink: #e9edf9;
--color-tsb02-mut: #93a0c0;
--color-tsb02-acc: #93a8ff;
--color-tsb02-acc: oklch(80% 0.12 268);
--color-tsb02-accsoft: #1a2344;
--color-tsb02-ondeep: #e9edf9;
--color-tsb02-onacc: #070b16;
}
}@import "tailwindcss";
@theme {
--color-tsb02-bg: #f2f4f9;
--color-tsb02-bg: oklch(96% 0.008 265);
--color-tsb02-surf: #ffffff;
--color-tsb02-surf: oklch(100% 0 0);
--color-tsb02-deep: #101a34;
--color-tsb02-deep: oklch(21% 0.06 265);
--color-tsb02-line: #d5dbe8;
--color-tsb02-line: oklch(88% 0.015 265);
--color-tsb02-ink: #0d1428;
--color-tsb02-ink: oklch(17% 0.04 265);
--color-tsb02-mut: #5c6684;
--color-tsb02-mut: oklch(48% 0.03 265);
--color-tsb02-acc: #2f4de0;
--color-tsb02-acc: oklch(50% 0.2 267);
--color-tsb02-accsoft: #e5e9fd;
--color-tsb02-accsoft: oklch(94% 0.03 267);
--color-tsb02-ondeep: #eaeefb;
--color-tsb02-ondeep: oklch(94% 0.015 265);
--color-tsb02-onacc: #ffffff;
--color-tsb02-onacc: oklch(100% 0 0);
--font-tsb02-sans: 'Space Grotesk', system-ui, 'Segoe UI', sans-serif;
}
/* Root rule: the full-bleed contract, stated in CSS as well as in the markup's
w-full min-h-svh. It scopes the demo, and it holds the stage at full height in the
moment before the browser build has compiled its utilities. */
.tsb-02 {
width: 100%;
min-height: 100svh;
}
.tsb-02 *,
.tsb-02 *::before,
.tsb-02 *::after {
box-sizing: border-box;
}
/* Token flip for the pure-CSS theme toggle, with the two mandatory mirror blocks.
Not expressible as utilities: it sets variables for a subtree, not a property on
an element. --color-tsb02-onacc flips too, because dark accent needs dark ink on it. */
.tsb-02:has(.tsb-02-theme-input:checked) {
--color-tsb02-bg: #070b16;
--color-tsb02-surf: #101728;
--color-tsb02-deep: #060a14;
--color-tsb02-line: #212b45;
--color-tsb02-ink: #e9edf9;
--color-tsb02-mut: #93a0c0;
--color-tsb02-acc: #93a8ff;
--color-tsb02-acc: oklch(80% 0.12 268);
--color-tsb02-accsoft: #1a2344;
--color-tsb02-ondeep: #e9edf9;
--color-tsb02-onacc: #070b16;
}
@media (prefers-color-scheme: dark) {
.tsb-02 {
--color-tsb02-bg: #070b16;
--color-tsb02-surf: #101728;
--color-tsb02-deep: #060a14;
--color-tsb02-line: #212b45;
--color-tsb02-ink: #e9edf9;
--color-tsb02-mut: #93a0c0;
--color-tsb02-acc: #93a8ff;
--color-tsb02-acc: oklch(80% 0.12 268);
--color-tsb02-accsoft: #1a2344;
--color-tsb02-ondeep: #e9edf9;
--color-tsb02-onacc: #070b16;
}
.tsb-02:has(.tsb-02-theme-input:checked) {
--color-tsb02-bg: #f2f4f9;
--color-tsb02-surf: #ffffff;
--color-tsb02-deep: #101a34;
--color-tsb02-line: #d5dbe8;
--color-tsb02-ink: #0d1428;
--color-tsb02-mut: #5c6684;
--color-tsb02-acc: #2f4de0;
--color-tsb02-acc: oklch(50% 0.2 267);
--color-tsb02-accsoft: #e5e9fd;
--color-tsb02-ondeep: #eaeefb;
--color-tsb02-onacc: #ffffff;
}
}
@media (prefers-color-scheme: light) {
.tsb-02:has(.tsb-02-theme-input:checked) {
--color-tsb02-bg: #070b16;
--color-tsb02-surf: #101728;
--color-tsb02-deep: #060a14;
--color-tsb02-line: #212b45;
--color-tsb02-ink: #e9edf9;
--color-tsb02-mut: #93a0c0;
--color-tsb02-acc: #93a8ff;
--color-tsb02-acc: oklch(80% 0.12 268);
--color-tsb02-accsoft: #1a2344;
--color-tsb02-ondeep: #e9edf9;
--color-tsb02-onacc: #070b16;
}
}<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=Space+Grotesk:wght@400;500;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=Space+Grotesk:wght@400;500;600;700&display=swap">What changed in the port
Which parts became utilities, which stayed CSS, and why.
- The
[data-open] .drawerrule collapses intogroupon the app shell plus-translate-x-full group-data-open:translate-x-0on the drawer — the closed and open positions are one line apart instead of two screens apart. group-data-open:is the boolean attribute form: no brackets, no value, matchingsetAttribute('data-open',''). Reach forgroup-data-[state=open]:only when the attribute carries a value (demo 05 does).[&[hidden]]:hiddenis the port of.drawer[hidden] { display:none }, and it is load-bearing:flexon the drawer would otherwise beat the UA rule.overscroll-behaviorhas no utility, correctly —[overscroll-behavior:contain]on the drawer nav is the idiomatic answer.- The
@media (prefers-reduced-motion)block becomes onemotion-reduce:duration-[1ms]on the drawer: still a state change, just not a slide. - The script is unchanged in substance and now touches nothing but attributes —
data-open,aria-expanded,hiddenand focus. It sets no class and no inline style, so the visual design is entirely re-editable from the markup.
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 Sidebar Focus Trap and Restore on Close
Source: https://codefronts.com/navigation/tailwind-css-sidebars/tailwind-sidebar-focus-trap-and-restore-on-close/
A modal sidebar makes three promises to a keyboard user: focus moves in, focus stays in, focus comes back. The trap is hand-wired script — it has to be — but everything visual moves into the class list: the drawer states its closed and open transforms on itself and reads the open state through group-data-open:, so the whole [data-open] .drawer rule disappears.
## HTML
```html
<section class="tsb-02 block relative w-full min-h-svh bg-tsb02-bg text-tsb02-ink font-tsb02-sans leading-[1.55]" aria-label="Meridian sprint board">
<div class="absolute top-3.5 right-3.5 z-60">
<input class="tsb-02-theme-input peer absolute inset-0 w-full h-full m-0 opacity-0 cursor-pointer" type="checkbox" id="tsb-02-theme">
<label class="grid place-items-center w-11 h-11 rounded-[0.7rem] border border-tsb02-line bg-tsb02-surf text-tsb02-ink cursor-pointer shadow-[0_0.15rem_0.5rem_oklch(20%_0.04_265/0.1)] peer-focus-visible:outline-2 peer-focus-visible:outline-tsb02-acc peer-focus-visible:outline-offset-2" for="tsb-02-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 grid grid-rows-[auto_minmax(0,1fr)] min-h-svh bg-tsb02-bg">
<div class="flex flex-wrap items-center gap-2 py-2.5 pl-4 pr-[4.2rem] bg-tsb02-deep text-tsb02-ondeep" id="tsb-02-bg">
<p class="mr-auto m-0 font-bold tracking-[-0.01em]">Meridian</p>
<a class="inline-flex items-center min-h-11 px-2.5 rounded-lg text-[0.9rem] text-tsb02-ondeep/80 no-underline hover:text-tsb02-ondeep focus-visible:outline-2 focus-visible:outline-white focus-visible:outline-offset-2" href="#tsb-02-main">Sprint 24</a>
<a class="inline-flex items-center min-h-11 px-2.5 rounded-lg text-[0.9rem] text-tsb02-ondeep/80 no-underline hover:text-tsb02-ondeep focus-visible:outline-2 focus-visible:outline-white focus-visible:outline-offset-2" href="#tsb-02-main">Backlog</a>
<button class="order-3 inline-flex items-center gap-2 min-h-11 px-3.5 rounded-[0.6rem] border-0 bg-tsb02-acc text-tsb02-onacc font-tsb02-sans text-[0.9rem] font-semibold cursor-pointer [&>svg]:w-4 [&>svg]:h-4 focus-visible:outline-2 focus-visible:outline-white focus-visible:outline-offset-2" type="button" id="tsb-02-open" aria-expanded="false" aria-controls="tsb-02-drawer">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 7h16M4 12h16M4 17h16" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>
Project menu
</button>
</div>
<main class="grid grid-cols-[minmax(0,1fr)] content-start gap-3.5 p-4.5 min-w-0 sm:grid-cols-2 sm:p-6.5 lg:grid-cols-4 lg:p-8" id="tsb-02-main" aria-label="Sprint board">
<article class="min-w-0 p-4 rounded-[0.8rem] bg-tsb02-surf border border-tsb02-line"><p class="m-0 mb-1.5 text-[0.7rem] tracking-[0.1em] uppercase text-tsb02-acc">In review</p><h3 class="m-0 mb-1.5 text-[0.98rem] leading-[1.25]">Rate limit on token exchange</h3><p class="m-0 text-[0.8rem] text-tsb02-mut">Priya Sharma · 3 files</p></article>
<article class="min-w-0 p-4 rounded-[0.8rem] bg-tsb02-surf border border-tsb02-line"><p class="m-0 mb-1.5 text-[0.7rem] tracking-[0.1em] uppercase text-tsb02-acc">In progress</p><h3 class="m-0 mb-1.5 text-[0.98rem] leading-[1.25]">Audit log retention window</h3><p class="m-0 text-[0.8rem] text-tsb02-mut">Jordan Lee · 8 files</p></article>
<article class="min-w-0 p-4 rounded-[0.8rem] bg-tsb02-surf border border-tsb02-line"><p class="m-0 mb-1.5 text-[0.7rem] tracking-[0.1em] uppercase text-tsb02-acc">Blocked</p><h3 class="m-0 mb-1.5 text-[0.98rem] leading-[1.25]">Billing webhook replay</h3><p class="m-0 text-[0.8rem] text-tsb02-mut">Sam Rivera · 1 file</p></article>
<article class="min-w-0 p-4 rounded-[0.8rem] bg-tsb02-surf border border-tsb02-line"><p class="m-0 mb-1.5 text-[0.7rem] tracking-[0.1em] uppercase text-tsb02-acc">Shipped</p><h3 class="m-0 mb-1.5 text-[0.98rem] leading-[1.25]">Session revoke endpoint</h3><p class="m-0 text-[0.8rem] text-tsb02-mut">Ada Okafor · 5 files</p></article>
</main>
<div class="absolute inset-0 z-20 bg-[oklch(18%_0.03_265/0.5)] [&[hidden]]:hidden" data-tsb-02-scrim hidden></div>
<div class="absolute inset-y-0 left-0 z-30 flex flex-col w-[min(19rem,86%)] bg-tsb02-surf border-r border-tsb02-line shadow-[0_1.5rem_3rem_oklch(18%_0.04_265/0.22)] -translate-x-full transition-transform duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] group-data-open:translate-x-0 motion-reduce:duration-[1ms] [&[hidden]]:hidden" id="tsb-02-drawer" role="dialog" aria-modal="true" aria-labelledby="tsb-02-dh" hidden>
<header class="flex items-center gap-2 px-3.5 py-3 border-b border-tsb02-line">
<h3 class="mr-auto m-0 text-base font-bold" id="tsb-02-dh">Meridian project</h3>
<button class="grid place-items-center w-11 h-11 rounded-[0.6rem] border border-tsb02-line bg-transparent text-tsb02-ink cursor-pointer [&>svg]:w-4 [&>svg]:h-4 focus-visible:outline-2 focus-visible:outline-tsb02-acc focus-visible:outline-offset-2" type="button" data-tsb-02-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 project menu</span>
</button>
</header>
<nav class="flex-1 min-h-0 overflow-y-auto p-2 [overscroll-behavior:contain]" aria-label="Meridian project">
<ul class="list-none m-0 p-0">
<li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.55rem] text-[0.93rem] text-tsb02-ink no-underline hover:bg-tsb02-accsoft focus-visible:outline-2 focus-visible:outline-tsb02-acc focus-visible:outline-offset-2 aria-[current=page]:bg-tsb02-accsoft aria-[current=page]:text-tsb02-acc aria-[current=page]:font-semibold" href="#tsb-02-main" aria-current="page">Sprint board</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.55rem] text-[0.93rem] text-tsb02-ink no-underline hover:bg-tsb02-accsoft focus-visible:outline-2 focus-visible:outline-tsb02-acc focus-visible:outline-offset-2" href="#tsb-02-main">Backlog</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.55rem] text-[0.93rem] text-tsb02-ink no-underline hover:bg-tsb02-accsoft focus-visible:outline-2 focus-visible:outline-tsb02-acc focus-visible:outline-offset-2" href="#tsb-02-main">Deployments</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.55rem] text-[0.93rem] text-tsb02-ink no-underline hover:bg-tsb02-accsoft focus-visible:outline-2 focus-visible:outline-tsb02-acc focus-visible:outline-offset-2" href="#tsb-02-main">Audit log</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.55rem] text-[0.93rem] text-tsb02-ink no-underline hover:bg-tsb02-accsoft focus-visible:outline-2 focus-visible:outline-tsb02-acc focus-visible:outline-offset-2" href="#tsb-02-main">Team settings</a></li>
</ul>
</nav>
<footer class="px-3.5 py-2.5 border-t border-tsb02-line">
<button class="w-full min-h-11 rounded-[0.6rem] border border-tsb02-line bg-transparent text-tsb02-ink font-tsb02-sans text-[0.9rem] font-semibold cursor-pointer focus-visible:outline-2 focus-visible:outline-tsb02-acc focus-visible:outline-offset-2" type="button" data-tsb-02-close>Done</button>
</footer>
</div>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-tsb02-bg: #f2f4f9;
--color-tsb02-bg: oklch(96% 0.008 265);
--color-tsb02-surf: #ffffff;
--color-tsb02-surf: oklch(100% 0 0);
--color-tsb02-deep: #101a34;
--color-tsb02-deep: oklch(21% 0.06 265);
--color-tsb02-line: #d5dbe8;
--color-tsb02-line: oklch(88% 0.015 265);
--color-tsb02-ink: #0d1428;
--color-tsb02-ink: oklch(17% 0.04 265);
--color-tsb02-mut: #5c6684;
--color-tsb02-mut: oklch(48% 0.03 265);
--color-tsb02-acc: #2f4de0;
--color-tsb02-acc: oklch(50% 0.2 267);
--color-tsb02-accsoft: #e5e9fd;
--color-tsb02-accsoft: oklch(94% 0.03 267);
--color-tsb02-ondeep: #eaeefb;
--color-tsb02-ondeep: oklch(94% 0.015 265);
--color-tsb02-onacc: #ffffff;
--color-tsb02-onacc: oklch(100% 0 0);
--font-tsb02-sans: 'Space Grotesk', system-ui, 'Segoe UI', sans-serif;
}
/* Root rule: the full-bleed contract, stated in CSS as well as in the markup's
w-full min-h-svh. It scopes the demo, and it holds the stage at full height in the
moment before the browser build has compiled its utilities. */
.tsb-02 {
width: 100%;
min-height: 100svh;
}
.tsb-02 *,
.tsb-02 *::before,
.tsb-02 *::after {
box-sizing: border-box;
}
/* Token flip for the pure-CSS theme toggle, with the two mandatory mirror blocks.
Not expressible as utilities: it sets variables for a subtree, not a property on
an element. --color-tsb02-onacc flips too, because dark accent needs dark ink on it. */
.tsb-02:has(.tsb-02-theme-input:checked) {
--color-tsb02-bg: #070b16;
--color-tsb02-surf: #101728;
--color-tsb02-deep: #060a14;
--color-tsb02-line: #212b45;
--color-tsb02-ink: #e9edf9;
--color-tsb02-mut: #93a0c0;
--color-tsb02-acc: #93a8ff;
--color-tsb02-acc: oklch(80% 0.12 268);
--color-tsb02-accsoft: #1a2344;
--color-tsb02-ondeep: #e9edf9;
--color-tsb02-onacc: #070b16;
}
@media (prefers-color-scheme: dark) {
.tsb-02 {
--color-tsb02-bg: #070b16;
--color-tsb02-surf: #101728;
--color-tsb02-deep: #060a14;
--color-tsb02-line: #212b45;
--color-tsb02-ink: #e9edf9;
--color-tsb02-mut: #93a0c0;
--color-tsb02-acc: #93a8ff;
--color-tsb02-acc: oklch(80% 0.12 268);
--color-tsb02-accsoft: #1a2344;
--color-tsb02-ondeep: #e9edf9;
--color-tsb02-onacc: #070b16;
}
.tsb-02:has(.tsb-02-theme-input:checked) {
--color-tsb02-bg: #f2f4f9;
--color-tsb02-surf: #ffffff;
--color-tsb02-deep: #101a34;
--color-tsb02-line: #d5dbe8;
--color-tsb02-ink: #0d1428;
--color-tsb02-mut: #5c6684;
--color-tsb02-acc: #2f4de0;
--color-tsb02-acc: oklch(50% 0.2 267);
--color-tsb02-accsoft: #e5e9fd;
--color-tsb02-ondeep: #eaeefb;
--color-tsb02-onacc: #ffffff;
}
}
@media (prefers-color-scheme: light) {
.tsb-02:has(.tsb-02-theme-input:checked) {
--color-tsb02-bg: #070b16;
--color-tsb02-surf: #101728;
--color-tsb02-deep: #060a14;
--color-tsb02-line: #212b45;
--color-tsb02-ink: #e9edf9;
--color-tsb02-mut: #93a0c0;
--color-tsb02-acc: #93a8ff;
--color-tsb02-acc: oklch(80% 0.12 268);
--color-tsb02-accsoft: #1a2344;
--color-tsb02-ondeep: #e9edf9;
--color-tsb02-onacc: #070b16;
}
}
```
## JavaScript
```js
(function () {
document.querySelectorAll('.tsb-02').forEach(function (root) {
if (root.dataset.bound) return; root.dataset.bound = '1';
var stage = root.querySelector('.group'),
drawer = root.querySelector('#tsb-02-drawer'),
scrim = root.querySelector('[data-tsb-02-scrim]'),
trigger = root.querySelector('#tsb-02-open'),
SEL = 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])',
returnTo = null;
function focusables() {
return Array.prototype.slice.call(drawer.querySelectorAll(SEL))
.filter(function (el) { return el.offsetParent !== null; });
}
function onKey(e) {
if (e.key === 'Escape') { close(); return; }
if (e.key !== 'Tab') return;
var f = focusables(); if (!f.length) return;
var first = f[0], last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}
function open() {
returnTo = document.activeElement;
drawer.hidden = false; scrim.hidden = false;
stage.setAttribute('data-open', '');
trigger.setAttribute('aria-expanded', 'true');
requestAnimationFrame(function () { drawer.querySelector('[data-tsb-02-close]').focus(); });
document.addEventListener('keydown', onKey);
}
function close() {
document.removeEventListener('keydown', onKey);
stage.removeAttribute('data-open');
trigger.setAttribute('aria-expanded', 'false');
drawer.hidden = true; scrim.hidden = true;
(returnTo || trigger).focus({ preventScroll: true });
returnTo = null;
}
trigger.addEventListener('click', open);
scrim.addEventListener('click', close);
root.querySelectorAll('[data-tsb-02-close]').forEach(function (b) { b.addEventListener('click', close); });
root.__tsbCleanup = function () { document.removeEventListener('keydown', onKey); };
});
})();
```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 Sidebar Focus Trap and Restore on Close
Source: https://codefronts.com/navigation/tailwind-css-sidebars/tailwind-sidebar-focus-trap-and-restore-on-close/
A modal sidebar makes three promises to a keyboard user: focus moves in, focus stays in, focus comes back. The trap is hand-wired script — it has to be — but everything visual moves into the class list: the drawer states its closed and open transforms on itself and reads the open state through group-data-open:, so the whole [data-open] .drawer rule disappears.
## HTML
```html
<section class="tsb-02 block relative w-full min-h-svh bg-tsb02-bg text-tsb02-ink font-tsb02-sans leading-[1.55]" aria-label="Meridian sprint board">
<div class="absolute top-3.5 right-3.5 z-60">
<input class="tsb-02-theme-input peer absolute inset-0 w-full h-full m-0 opacity-0 cursor-pointer" type="checkbox" id="tsb-02-theme">
<label class="grid place-items-center w-11 h-11 rounded-[0.7rem] border border-tsb02-line bg-tsb02-surf text-tsb02-ink cursor-pointer shadow-[0_0.15rem_0.5rem_oklch(20%_0.04_265/0.1)] peer-focus-visible:outline-2 peer-focus-visible:outline-tsb02-acc peer-focus-visible:outline-offset-2" for="tsb-02-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 grid grid-rows-[auto_minmax(0,1fr)] min-h-svh bg-tsb02-bg">
<div class="flex flex-wrap items-center gap-2 py-2.5 pl-4 pr-[4.2rem] bg-tsb02-deep text-tsb02-ondeep" id="tsb-02-bg">
<p class="mr-auto m-0 font-bold tracking-[-0.01em]">Meridian</p>
<a class="inline-flex items-center min-h-11 px-2.5 rounded-lg text-[0.9rem] text-tsb02-ondeep/80 no-underline hover:text-tsb02-ondeep focus-visible:outline-2 focus-visible:outline-white focus-visible:outline-offset-2" href="#tsb-02-main">Sprint 24</a>
<a class="inline-flex items-center min-h-11 px-2.5 rounded-lg text-[0.9rem] text-tsb02-ondeep/80 no-underline hover:text-tsb02-ondeep focus-visible:outline-2 focus-visible:outline-white focus-visible:outline-offset-2" href="#tsb-02-main">Backlog</a>
<button class="order-3 inline-flex items-center gap-2 min-h-11 px-3.5 rounded-[0.6rem] border-0 bg-tsb02-acc text-tsb02-onacc font-tsb02-sans text-[0.9rem] font-semibold cursor-pointer [&>svg]:w-4 [&>svg]:h-4 focus-visible:outline-2 focus-visible:outline-white focus-visible:outline-offset-2" type="button" id="tsb-02-open" aria-expanded="false" aria-controls="tsb-02-drawer">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 7h16M4 12h16M4 17h16" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>
Project menu
</button>
</div>
<main class="grid grid-cols-[minmax(0,1fr)] content-start gap-3.5 p-4.5 min-w-0 sm:grid-cols-2 sm:p-6.5 lg:grid-cols-4 lg:p-8" id="tsb-02-main" aria-label="Sprint board">
<article class="min-w-0 p-4 rounded-[0.8rem] bg-tsb02-surf border border-tsb02-line"><p class="m-0 mb-1.5 text-[0.7rem] tracking-[0.1em] uppercase text-tsb02-acc">In review</p><h3 class="m-0 mb-1.5 text-[0.98rem] leading-[1.25]">Rate limit on token exchange</h3><p class="m-0 text-[0.8rem] text-tsb02-mut">Priya Sharma · 3 files</p></article>
<article class="min-w-0 p-4 rounded-[0.8rem] bg-tsb02-surf border border-tsb02-line"><p class="m-0 mb-1.5 text-[0.7rem] tracking-[0.1em] uppercase text-tsb02-acc">In progress</p><h3 class="m-0 mb-1.5 text-[0.98rem] leading-[1.25]">Audit log retention window</h3><p class="m-0 text-[0.8rem] text-tsb02-mut">Jordan Lee · 8 files</p></article>
<article class="min-w-0 p-4 rounded-[0.8rem] bg-tsb02-surf border border-tsb02-line"><p class="m-0 mb-1.5 text-[0.7rem] tracking-[0.1em] uppercase text-tsb02-acc">Blocked</p><h3 class="m-0 mb-1.5 text-[0.98rem] leading-[1.25]">Billing webhook replay</h3><p class="m-0 text-[0.8rem] text-tsb02-mut">Sam Rivera · 1 file</p></article>
<article class="min-w-0 p-4 rounded-[0.8rem] bg-tsb02-surf border border-tsb02-line"><p class="m-0 mb-1.5 text-[0.7rem] tracking-[0.1em] uppercase text-tsb02-acc">Shipped</p><h3 class="m-0 mb-1.5 text-[0.98rem] leading-[1.25]">Session revoke endpoint</h3><p class="m-0 text-[0.8rem] text-tsb02-mut">Ada Okafor · 5 files</p></article>
</main>
<div class="absolute inset-0 z-20 bg-[oklch(18%_0.03_265/0.5)] [&[hidden]]:hidden" data-tsb-02-scrim hidden></div>
<div class="absolute inset-y-0 left-0 z-30 flex flex-col w-[min(19rem,86%)] bg-tsb02-surf border-r border-tsb02-line shadow-[0_1.5rem_3rem_oklch(18%_0.04_265/0.22)] -translate-x-full transition-transform duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] group-data-open:translate-x-0 motion-reduce:duration-[1ms] [&[hidden]]:hidden" id="tsb-02-drawer" role="dialog" aria-modal="true" aria-labelledby="tsb-02-dh" hidden>
<header class="flex items-center gap-2 px-3.5 py-3 border-b border-tsb02-line">
<h3 class="mr-auto m-0 text-base font-bold" id="tsb-02-dh">Meridian project</h3>
<button class="grid place-items-center w-11 h-11 rounded-[0.6rem] border border-tsb02-line bg-transparent text-tsb02-ink cursor-pointer [&>svg]:w-4 [&>svg]:h-4 focus-visible:outline-2 focus-visible:outline-tsb02-acc focus-visible:outline-offset-2" type="button" data-tsb-02-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 project menu</span>
</button>
</header>
<nav class="flex-1 min-h-0 overflow-y-auto p-2 [overscroll-behavior:contain]" aria-label="Meridian project">
<ul class="list-none m-0 p-0">
<li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.55rem] text-[0.93rem] text-tsb02-ink no-underline hover:bg-tsb02-accsoft focus-visible:outline-2 focus-visible:outline-tsb02-acc focus-visible:outline-offset-2 aria-[current=page]:bg-tsb02-accsoft aria-[current=page]:text-tsb02-acc aria-[current=page]:font-semibold" href="#tsb-02-main" aria-current="page">Sprint board</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.55rem] text-[0.93rem] text-tsb02-ink no-underline hover:bg-tsb02-accsoft focus-visible:outline-2 focus-visible:outline-tsb02-acc focus-visible:outline-offset-2" href="#tsb-02-main">Backlog</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.55rem] text-[0.93rem] text-tsb02-ink no-underline hover:bg-tsb02-accsoft focus-visible:outline-2 focus-visible:outline-tsb02-acc focus-visible:outline-offset-2" href="#tsb-02-main">Deployments</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.55rem] text-[0.93rem] text-tsb02-ink no-underline hover:bg-tsb02-accsoft focus-visible:outline-2 focus-visible:outline-tsb02-acc focus-visible:outline-offset-2" href="#tsb-02-main">Audit log</a></li>
<li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.55rem] text-[0.93rem] text-tsb02-ink no-underline hover:bg-tsb02-accsoft focus-visible:outline-2 focus-visible:outline-tsb02-acc focus-visible:outline-offset-2" href="#tsb-02-main">Team settings</a></li>
</ul>
</nav>
<footer class="px-3.5 py-2.5 border-t border-tsb02-line">
<button class="w-full min-h-11 rounded-[0.6rem] border border-tsb02-line bg-transparent text-tsb02-ink font-tsb02-sans text-[0.9rem] font-semibold cursor-pointer focus-visible:outline-2 focus-visible:outline-tsb02-acc focus-visible:outline-offset-2" type="button" data-tsb-02-close>Done</button>
</footer>
</div>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-tsb02-bg: #f2f4f9;
--color-tsb02-bg: oklch(96% 0.008 265);
--color-tsb02-surf: #ffffff;
--color-tsb02-surf: oklch(100% 0 0);
--color-tsb02-deep: #101a34;
--color-tsb02-deep: oklch(21% 0.06 265);
--color-tsb02-line: #d5dbe8;
--color-tsb02-line: oklch(88% 0.015 265);
--color-tsb02-ink: #0d1428;
--color-tsb02-ink: oklch(17% 0.04 265);
--color-tsb02-mut: #5c6684;
--color-tsb02-mut: oklch(48% 0.03 265);
--color-tsb02-acc: #2f4de0;
--color-tsb02-acc: oklch(50% 0.2 267);
--color-tsb02-accsoft: #e5e9fd;
--color-tsb02-accsoft: oklch(94% 0.03 267);
--color-tsb02-ondeep: #eaeefb;
--color-tsb02-ondeep: oklch(94% 0.015 265);
--color-tsb02-onacc: #ffffff;
--color-tsb02-onacc: oklch(100% 0 0);
--font-tsb02-sans: 'Space Grotesk', system-ui, 'Segoe UI', sans-serif;
}
/* Root rule: the full-bleed contract, stated in CSS as well as in the markup's
w-full min-h-svh. It scopes the demo, and it holds the stage at full height in the
moment before the browser build has compiled its utilities. */
.tsb-02 {
width: 100%;
min-height: 100svh;
}
.tsb-02 *,
.tsb-02 *::before,
.tsb-02 *::after {
box-sizing: border-box;
}
/* Token flip for the pure-CSS theme toggle, with the two mandatory mirror blocks.
Not expressible as utilities: it sets variables for a subtree, not a property on
an element. --color-tsb02-onacc flips too, because dark accent needs dark ink on it. */
.tsb-02:has(.tsb-02-theme-input:checked) {
--color-tsb02-bg: #070b16;
--color-tsb02-surf: #101728;
--color-tsb02-deep: #060a14;
--color-tsb02-line: #212b45;
--color-tsb02-ink: #e9edf9;
--color-tsb02-mut: #93a0c0;
--color-tsb02-acc: #93a8ff;
--color-tsb02-acc: oklch(80% 0.12 268);
--color-tsb02-accsoft: #1a2344;
--color-tsb02-ondeep: #e9edf9;
--color-tsb02-onacc: #070b16;
}
@media (prefers-color-scheme: dark) {
.tsb-02 {
--color-tsb02-bg: #070b16;
--color-tsb02-surf: #101728;
--color-tsb02-deep: #060a14;
--color-tsb02-line: #212b45;
--color-tsb02-ink: #e9edf9;
--color-tsb02-mut: #93a0c0;
--color-tsb02-acc: #93a8ff;
--color-tsb02-acc: oklch(80% 0.12 268);
--color-tsb02-accsoft: #1a2344;
--color-tsb02-ondeep: #e9edf9;
--color-tsb02-onacc: #070b16;
}
.tsb-02:has(.tsb-02-theme-input:checked) {
--color-tsb02-bg: #f2f4f9;
--color-tsb02-surf: #ffffff;
--color-tsb02-deep: #101a34;
--color-tsb02-line: #d5dbe8;
--color-tsb02-ink: #0d1428;
--color-tsb02-mut: #5c6684;
--color-tsb02-acc: #2f4de0;
--color-tsb02-acc: oklch(50% 0.2 267);
--color-tsb02-accsoft: #e5e9fd;
--color-tsb02-ondeep: #eaeefb;
--color-tsb02-onacc: #ffffff;
}
}
@media (prefers-color-scheme: light) {
.tsb-02:has(.tsb-02-theme-input:checked) {
--color-tsb02-bg: #070b16;
--color-tsb02-surf: #101728;
--color-tsb02-deep: #060a14;
--color-tsb02-line: #212b45;
--color-tsb02-ink: #e9edf9;
--color-tsb02-mut: #93a0c0;
--color-tsb02-acc: #93a8ff;
--color-tsb02-acc: oklch(80% 0.12 268);
--color-tsb02-accsoft: #1a2344;
--color-tsb02-ondeep: #e9edf9;
--color-tsb02-onacc: #070b16;
}
}
```
## JavaScript
```js
(function () {
document.querySelectorAll('.tsb-02').forEach(function (root) {
if (root.dataset.bound) return; root.dataset.bound = '1';
var stage = root.querySelector('.group'),
drawer = root.querySelector('#tsb-02-drawer'),
scrim = root.querySelector('[data-tsb-02-scrim]'),
trigger = root.querySelector('#tsb-02-open'),
SEL = 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])',
returnTo = null;
function focusables() {
return Array.prototype.slice.call(drawer.querySelectorAll(SEL))
.filter(function (el) { return el.offsetParent !== null; });
}
function onKey(e) {
if (e.key === 'Escape') { close(); return; }
if (e.key !== 'Tab') return;
var f = focusables(); if (!f.length) return;
var first = f[0], last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}
function open() {
returnTo = document.activeElement;
drawer.hidden = false; scrim.hidden = false;
stage.setAttribute('data-open', '');
trigger.setAttribute('aria-expanded', 'true');
requestAnimationFrame(function () { drawer.querySelector('[data-tsb-02-close]').focus(); });
document.addEventListener('keydown', onKey);
}
function close() {
document.removeEventListener('keydown', onKey);
stage.removeAttribute('data-open');
trigger.setAttribute('aria-expanded', 'false');
drawer.hidden = true; scrim.hidden = true;
(returnTo || trigger).focus({ preventScroll: true });
returnTo = null;
}
trigger.addEventListener('click', open);
scrim.addEventListener('click', close);
root.querySelectorAll('[data-tsb-02-close]').forEach(function (b) { b.addEventListener('click', close); });
root.__tsbCleanup = function () { document.removeEventListener('keydown', onKey); };
});
})();
```How this works
What the port changes and what it deliberately does not. Focus management is behaviour, not style, so the script keeps all of it: the focusable set is queried at open time, Tab and Shift+Tab wrap between first and last, Esc closes, and the element that opened the drawer is stored and re-focused on close. No variant can express any of that.
What moves is the geometry. The script now sets one attribute:
<div class="group relative overflow-hidden" data-open>
<aside class="absolute inset-y-0 left-0 -translate-x-full
transition-transform duration-[240ms]
group-data-open:translate-x-0">and the drawer's two positions are readable side by side. Restyling the open state never means reopening the JS.
hidden and display:flex collide. A closed drawer keeps the hidden attribute so it is out of the accessibility tree entirely, but the UA rule that hides it loses to any author display utility. [&[hidden]]:hidden restores the win, and it is the one line of this pattern people always discover the hard way.
Still not enough on its own. Wrapping Tab stops sequential navigation from leaving, but a screen-reader virtual cursor can still walk the background. That takes inert, which is demo 03.
Make it yours
- Change where focus lands on open by editing the single
focus()call — the close button is the safest default. - Move the drawer to the right edge by swapping
left-0forright-0and flipping the translate sign; the focus logic is untouched. - Widen or narrow it with
w-[min(19rem,86%)], the only width declaration in the demo. - Retheme through
--color-tsb02-accand--color-tsb02-deepin@theme; the bar, the trigger, the tag text and every focus ring read them. - Promote the stage to a real viewport overlay by swapping the drawer and scrim's
absoluteforfixed— nothing else changes. - Replace the whole thing with
<dialog>andshowModal()if you do not need custom entry animation; you get the trap, the inert background and Esc free.
Gotchas — read before shipping
- A trap with no Esc and no reachable close control is a keyboard trap and fails SC 2.1.2 at Level A.
- Not restoring focus dumps the user at the top of the document — it reads as a page reload. Store the trigger at open; do not assume a known button.
- Without
[&[hidden]]:hiddentheflexutility overrides the UA[hidden]rule and the closed drawer stays on screen and focusable. - Caching the focusable list at page load breaks as soon as the drawer's contents change; query it at open time.
group-data-open:needs thegroupclass on the common ancestor of the drawer and the scrim — here the app shell, not the section root.- The class strings must be statically visible to the compiler. A transform assembled at runtime from a template variable produces no CSS at all.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 111+ | 16.4+ | 128+ | 111+ |
Tailwind v4's generated sheet sets the floor. The trap itself is keydown handling and focus() calls with no version floor, and focus({ preventScroll: true }) has been Baseline since 2019 — engines that ignore the option still focus correctly, they just may scroll.