Tailwind + JSMIT licensed

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.

Live Demo
Plain CSSTailwind v4
Try it

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

Cruip · Mosaic by Cruip · Affiliate link

View Mosaic dashboard

The code

<section class="tsb-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 &middot; 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 &middot; 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 &middot; 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 &middot; 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;
  }
}
(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.

1Load Tailwind
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>

Compiles in the browser — perfect for a CodePen or a quick test, not for production.

2Add the stylesheetRequiredthe @theme block is what makes the custom utilities exist
@import "tailwindcss";

@theme {
  --color-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;
  }
}
3Fonts used by this demooptional — skip if you have your own type stack
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=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] .drawer rule collapses into group on the app shell plus -translate-x-full group-data-open:translate-x-0 on 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, matching setAttribute('data-open',''). Reach for group-data-[state=open]: only when the attribute carries a value (demo 05 does).
  • [&[hidden]]:hidden is the port of .drawer[hidden] { display:none }, and it is load-bearing: flex on the drawer would otherwise beat the UA rule.
  • overscroll-behavior has no utility, correctly — [overscroll-behavior:contain] on the drawer nav is the idiomatic answer.
  • The @media (prefers-reduced-motion) block becomes one motion-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, hidden and focus. It sets no class and no inline style, so the visual design is entirely re-editable from the markup.
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS demo from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: 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 &mdash; it has to be &mdash; 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 &middot; 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 &middot; 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 &middot; 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 &middot; 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-0 for right-0 and 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-acc and --color-tsb02-deep in @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 absolute for fixed — nothing else changes.
  • Replace the whole thing with <dialog> and showModal() 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]]:hidden the flex utility 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 the group class 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

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

Techniques used in this demo

Theme tokensset in @theme, used by a utility

Search CodeFronts

Loading…