Tailwind + JSMIT licensed

Tailwind Off Canvas Sidebar Drawer with Focus Return

The full contract in one demo, and the collection's closing argument for the port: focus in, focus trapped, background inert, Esc closes, focus returns — all in script, because all of it is behaviour — while every pixel of the drawer's two states lives in one class list on one element. Demos 02, 03 and 19 are the same lesson at increasing completeness.

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-19 block relative w-full min-h-svh bg-tsb19-bg text-tsb19-ink font-tsb19-sans leading-[1.55]" aria-label="Fern Daily">
  <div class="absolute top-3.5 right-3.5 z-60">
    <input class="tsb-19-theme-input peer absolute inset-0 w-full h-full m-0 opacity-0 cursor-pointer" type="checkbox" id="tsb-19-theme">
    <label class="grid place-items-center w-11 h-11 rounded-[0.5rem] border border-tsb19-line bg-tsb19-surf text-tsb19-ink cursor-pointer shadow-[0_0.4rem_1rem_oklch(0%_0_0/0.25)] peer-focus-visible:outline-2 peer-focus-visible:outline-tsb19-acc peer-focus-visible:outline-offset-2" for="tsb-19-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 h-svh bg-tsb19-surf" id="tsb-19-screen">
    <div class="flex flex-col h-full transition-opacity duration-200 inert:opacity-40 motion-reduce:transition-none" id="tsb-19-page">
      <div class="flex items-center gap-2.5 py-2.5 pl-2.5 pr-[4.2rem] border-b border-tsb19-line">
        <button class="grid place-items-center w-11 h-11 rounded-[0.5rem] border-0 bg-tsb19-surf2 text-tsb19-ink cursor-pointer [&>svg]:w-5 [&>svg]:h-5 focus-visible:outline-2 focus-visible:outline-tsb19-acc focus-visible:outline-offset-2" type="button" id="tsb-19-open" aria-expanded="false" aria-controls="tsb-19-drawer">
          <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 7h16M4 12h16M4 17h16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
          <span class="absolute w-px h-px overflow-hidden whitespace-nowrap [clip-path:inset(50%)]">Open sections</span>
        </button>
        <p class="m-0 font-tsb19-display text-[1.15rem] font-bold tracking-[0.04em] uppercase text-tsb19-acc">Fern Daily</p>
      </div>
      <article class="flex-1 min-h-0 overflow-y-auto px-5 pt-4.5 pb-10 [overscroll-behavior:contain] r46:px-9 r46:pt-8 r46:pb-12">
        <p class="m-0 mb-1 font-tsb19-display text-[0.74rem] tracking-[0.14em] uppercase text-tsb19-acc">Infrastructure</p>
        <h2 class="m-0 mb-1.5 max-w-[34rem] font-tsb19-display text-[clamp(1.5rem,4vw,2.2rem)] leading-[1.08]">Regions added in the third quarter</h2>
        <p class="m-0 mb-4 text-[0.82rem] text-tsb19-mut">Alex Chen &middot; 6 min read</p>
        <p class="m-0 mb-3 max-w-[38rem] text-[0.94rem] text-tsb19-mut text-pretty">Three new regions came online this quarter, each with an independent control plane and its own certificate authority.</p>
        <p class="m-0 mb-3 max-w-[38rem] text-[0.94rem] text-tsb19-mut text-pretty">Promotion remains a pointer move, so rollback stays instant even across regions. The verification step is the slow part, and it is deliberately manual.</p>
        <p class="m-0 mb-3 max-w-[38rem] text-[0.94rem] text-tsb19-mut text-pretty">Failover drills now run monthly per region, with an observer from a second team. The runbook lives in the handbook under Operations.</p>
        <a class="inline-flex items-center min-h-11 text-[0.9rem] font-semibold text-tsb19-acc focus-visible:outline-2 focus-visible:outline-tsb19-acc focus-visible:outline-offset-2" href="#tsb-19-screen">Read the full report</a>
      </article>
    </div>

    <div class="absolute inset-0 z-20 bg-[oklch(12%_0.01_60/0.5)] [&[hidden]]:hidden" data-tsb-19-scrim hidden></div>

    <div class="absolute inset-y-0 left-0 z-30 flex flex-col w-[min(15rem,80%)] bg-tsb19-surf border-r border-tsb19-line shadow-[0.6rem_0_2rem_oklch(0%_0_0/0.35)] -translate-x-full transition-transform duration-[260ms] ease-[cubic-bezier(0.22,1,0.36,1)] group-data-open:translate-x-0 motion-reduce:duration-[1ms] [&[hidden]]:hidden" id="tsb-19-drawer" role="dialog" aria-modal="true" aria-labelledby="tsb-19-dh" hidden>
      <div class="flex items-center gap-1.5 px-2.5 py-2 border-b border-tsb19-line">
        <p class="mr-auto m-0 font-tsb19-display text-[0.78rem] tracking-[0.14em] uppercase text-tsb19-mut" id="tsb-19-dh">Sections</p>
        <button class="grid place-items-center w-11 h-11 rounded-[0.5rem] border-0 bg-tsb19-surf2 text-tsb19-ink cursor-pointer [&>svg]:w-4 [&>svg]:h-4 focus-visible:outline-2 focus-visible:outline-tsb19-acc focus-visible:outline-offset-2" type="button" data-tsb-19-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 sections</span>
        </button>
      </div>
      <nav class="flex-1 min-h-0 overflow-y-auto p-1 [overscroll-behavior:contain]" aria-label="Fern Daily sections">
        <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.4rem] text-[0.89rem] text-tsb19-ink no-underline hover:bg-tsb19-surf2 focus-visible:outline-2 focus-visible:outline-tsb19-acc focus-visible:-outline-offset-2 aria-[current=page]:bg-tsb19-accsoft aria-[current=page]:text-tsb19-acc aria-[current=page]:font-semibold" href="#tsb-19-screen" aria-current="page">Infrastructure</a></li>
          <li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.4rem] text-[0.89rem] text-tsb19-ink no-underline hover:bg-tsb19-surf2 focus-visible:outline-2 focus-visible:outline-tsb19-acc focus-visible:-outline-offset-2" href="#tsb-19-screen">Security</a></li>
          <li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.4rem] text-[0.89rem] text-tsb19-ink no-underline hover:bg-tsb19-surf2 focus-visible:outline-2 focus-visible:outline-tsb19-acc focus-visible:-outline-offset-2" href="#tsb-19-screen">Billing</a></li>
          <li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.4rem] text-[0.89rem] text-tsb19-ink no-underline hover:bg-tsb19-surf2 focus-visible:outline-2 focus-visible:outline-tsb19-acc focus-visible:-outline-offset-2" href="#tsb-19-screen">Field notes</a></li>
          <li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.4rem] text-[0.89rem] text-tsb19-ink no-underline hover:bg-tsb19-surf2 focus-visible:outline-2 focus-visible:outline-tsb19-acc focus-visible:-outline-offset-2" href="#tsb-19-screen">Audit log</a></li>
          <li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.4rem] text-[0.89rem] text-tsb19-ink no-underline hover:bg-tsb19-surf2 focus-visible:outline-2 focus-visible:outline-tsb19-acc focus-visible:-outline-offset-2" href="#tsb-19-screen">Newsletters</a></li>
          <li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.4rem] text-[0.89rem] text-tsb19-ink no-underline hover:bg-tsb19-surf2 focus-visible:outline-2 focus-visible:outline-tsb19-acc focus-visible:-outline-offset-2" href="#tsb-19-screen">Archive</a></li>
        </ul>
      </nav>
    </div>
  </div>
</section>
@import "tailwindcss";

@theme {
  --color-tsb19-bg: #1c1a18;
  --color-tsb19-bg: oklch(23% 0.008 60);
  --color-tsb19-surf: #262320;
  --color-tsb19-surf: oklch(27% 0.01 60);
  --color-tsb19-surf2: #302c28;
  --color-tsb19-surf2: oklch(31% 0.012 60);
  --color-tsb19-line: #3d3833;
  --color-tsb19-line: oklch(37% 0.014 60);
  --color-tsb19-ink: #f5efe8;
  --color-tsb19-ink: oklch(95% 0.012 70);
  --color-tsb19-mut: #ab9f93;
  --color-tsb19-mut: oklch(71% 0.02 70);
  --color-tsb19-acc: #ef7a52;
  --color-tsb19-acc: oklch(70% 0.16 40);
  --color-tsb19-accsoft: #3a251c;
  --color-tsb19-accsoft: oklch(30% 0.05 40);
  --font-tsb19-sans: system-ui, -apple-system, 'Segoe UI', sans-serif;
  --font-tsb19-display: 'Archivo Narrow', 'Roboto Condensed', ui-sans-serif, system-ui, sans-serif;
  --breakpoint-r46: 46rem;
}
/* Root rule: the full-bleed contract. The screen inside it is h-svh, because two
   independent scroll panes need a bounded stage. */

.tsb-19 {
  width: 100%;
  min-height: 100svh;
}

.tsb-19 *,
.tsb-19 *::before,
.tsb-19 *::after {
  box-sizing: border-box;
}
/* Dark-first demo: the @theme block IS the dark palette, light mirror below. */

.tsb-19:has(.tsb-19-theme-input:checked) {
  --color-tsb19-bg: #f8f4ef;
  --color-tsb19-surf: #ffffff;
  --color-tsb19-surf2: #efe9e2;
  --color-tsb19-line: #ded5cb;
  --color-tsb19-ink: #221e1a;
  --color-tsb19-mut: #6a6058;
  --color-tsb19-acc: #9e3d18;
  --color-tsb19-acc: oklch(48% 0.14 40);
  --color-tsb19-accsoft: #f6e2d8;
}

@media (prefers-color-scheme: light) {
  .tsb-19 {
    --color-tsb19-bg: #f8f4ef;
    --color-tsb19-surf: #ffffff;
    --color-tsb19-surf2: #efe9e2;
    --color-tsb19-line: #ded5cb;
    --color-tsb19-ink: #221e1a;
    --color-tsb19-mut: #6a6058;
    --color-tsb19-acc: #9e3d18;
    --color-tsb19-acc: oklch(48% 0.14 40);
    --color-tsb19-accsoft: #f6e2d8;
  }

  .tsb-19:has(.tsb-19-theme-input:checked) {
    --color-tsb19-bg: #1c1a18;
    --color-tsb19-surf: #262320;
    --color-tsb19-surf2: #302c28;
    --color-tsb19-line: #3d3833;
    --color-tsb19-ink: #f5efe8;
    --color-tsb19-mut: #ab9f93;
    --color-tsb19-acc: #ef7a52;
    --color-tsb19-acc: oklch(70% 0.16 40);
    --color-tsb19-accsoft: #3a251c;
  }
}

@media (prefers-color-scheme: dark) {
  .tsb-19:has(.tsb-19-theme-input:checked) {
    --color-tsb19-bg: #f8f4ef;
    --color-tsb19-surf: #ffffff;
    --color-tsb19-surf2: #efe9e2;
    --color-tsb19-line: #ded5cb;
    --color-tsb19-ink: #221e1a;
    --color-tsb19-mut: #6a6058;
    --color-tsb19-acc: #9e3d18;
    --color-tsb19-acc: oklch(48% 0.14 40);
    --color-tsb19-accsoft: #f6e2d8;
  }
}
(function () {
  document.querySelectorAll('.tsb-19').forEach(function (root) {
    if (root.dataset.bound) return; root.dataset.bound = '1';
    var screen = root.querySelector('#tsb-19-screen'),
        drawer = root.querySelector('#tsb-19-drawer'),
        page = root.querySelector('#tsb-19-page'),
        scrim = root.querySelector('[data-tsb-19-scrim]'),
        trigger = root.querySelector('#tsb-19-open'),
        supportsInert = 'inert' in HTMLElement.prototype,
        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 = scrim.hidden = false;
      screen.setAttribute('data-open', '');
      trigger.setAttribute('aria-expanded', 'true');
      if (supportsInert) page.inert = true; else page.setAttribute('aria-hidden', 'true');
      document.addEventListener('keydown', onKey);
      requestAnimationFrame(function () { drawer.querySelector('[data-tsb-19-close]').focus(); });
    }
    function close() {
      document.removeEventListener('keydown', onKey);
      if (supportsInert) page.inert = false; else page.removeAttribute('aria-hidden');
      screen.removeAttribute('data-open');
      trigger.setAttribute('aria-expanded', 'false');
      drawer.hidden = scrim.hidden = true;
      (returnTo || trigger).focus({ preventScroll: true });
      returnTo = null;
    }
    trigger.addEventListener('click', open);
    scrim.addEventListener('click', close);
    root.querySelectorAll('[data-tsb-19-close]').forEach(function (b) { b.addEventListener('click', close); });
    root.__tsbCleanup = function () {
      document.removeEventListener('keydown', onKey);
      if (supportsInert) page.inert = false;
    };
  });
})();

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-tsb19-bg: #1c1a18;
  --color-tsb19-bg: oklch(23% 0.008 60);
  --color-tsb19-surf: #262320;
  --color-tsb19-surf: oklch(27% 0.01 60);
  --color-tsb19-surf2: #302c28;
  --color-tsb19-surf2: oklch(31% 0.012 60);
  --color-tsb19-line: #3d3833;
  --color-tsb19-line: oklch(37% 0.014 60);
  --color-tsb19-ink: #f5efe8;
  --color-tsb19-ink: oklch(95% 0.012 70);
  --color-tsb19-mut: #ab9f93;
  --color-tsb19-mut: oklch(71% 0.02 70);
  --color-tsb19-acc: #ef7a52;
  --color-tsb19-acc: oklch(70% 0.16 40);
  --color-tsb19-accsoft: #3a251c;
  --color-tsb19-accsoft: oklch(30% 0.05 40);
  --font-tsb19-sans: system-ui, -apple-system, 'Segoe UI', sans-serif;
  --font-tsb19-display: 'Archivo Narrow', 'Roboto Condensed', ui-sans-serif, system-ui, sans-serif;
  --breakpoint-r46: 46rem;
}
/* Root rule: the full-bleed contract. The screen inside it is h-svh, because two
   independent scroll panes need a bounded stage. */

.tsb-19 {
  width: 100%;
  min-height: 100svh;
}

.tsb-19 *,
.tsb-19 *::before,
.tsb-19 *::after {
  box-sizing: border-box;
}
/* Dark-first demo: the @theme block IS the dark palette, light mirror below. */

.tsb-19:has(.tsb-19-theme-input:checked) {
  --color-tsb19-bg: #f8f4ef;
  --color-tsb19-surf: #ffffff;
  --color-tsb19-surf2: #efe9e2;
  --color-tsb19-line: #ded5cb;
  --color-tsb19-ink: #221e1a;
  --color-tsb19-mut: #6a6058;
  --color-tsb19-acc: #9e3d18;
  --color-tsb19-acc: oklch(48% 0.14 40);
  --color-tsb19-accsoft: #f6e2d8;
}

@media (prefers-color-scheme: light) {
  .tsb-19 {
    --color-tsb19-bg: #f8f4ef;
    --color-tsb19-surf: #ffffff;
    --color-tsb19-surf2: #efe9e2;
    --color-tsb19-line: #ded5cb;
    --color-tsb19-ink: #221e1a;
    --color-tsb19-mut: #6a6058;
    --color-tsb19-acc: #9e3d18;
    --color-tsb19-acc: oklch(48% 0.14 40);
    --color-tsb19-accsoft: #f6e2d8;
  }

  .tsb-19:has(.tsb-19-theme-input:checked) {
    --color-tsb19-bg: #1c1a18;
    --color-tsb19-surf: #262320;
    --color-tsb19-surf2: #302c28;
    --color-tsb19-line: #3d3833;
    --color-tsb19-ink: #f5efe8;
    --color-tsb19-mut: #ab9f93;
    --color-tsb19-acc: #ef7a52;
    --color-tsb19-acc: oklch(70% 0.16 40);
    --color-tsb19-accsoft: #3a251c;
  }
}

@media (prefers-color-scheme: dark) {
  .tsb-19:has(.tsb-19-theme-input:checked) {
    --color-tsb19-bg: #f8f4ef;
    --color-tsb19-surf: #ffffff;
    --color-tsb19-surf2: #efe9e2;
    --color-tsb19-line: #ded5cb;
    --color-tsb19-ink: #221e1a;
    --color-tsb19-mut: #6a6058;
    --color-tsb19-acc: #9e3d18;
    --color-tsb19-acc: oklch(48% 0.14 40);
    --color-tsb19-accsoft: #f6e2d8;
  }
}
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=Archivo+Narrow:wght@400;600;700&display=swap">

What changed in the port

Which parts became utilities, which stayed CSS, and why.

  • The closing demo of the collection, and the summary of the whole port: behaviour stays in script, state is one attribute, and every visual consequence is a variant on the element that shows it.
  • group-data-open:translate-x-0 against a resting -translate-x-full is the third appearance of this pair (02, 12, 19). Repetition is the argument: the drawer's geometry stopped being a stylesheet concern.
  • inert:opacity-40 and [&[hidden]]:hidden are also third appearances. The collection deliberately reuses the same four utilities for the same four problems, so a reader can pattern-match rather than re-learn.
  • Two min-h-0 overflow-y-auto [overscroll-behavior:contain] panes in one demo, which is demo 08's lesson applied inside a drawer: containment matters most where the stage is small.
  • The reduced-motion fallback is one utility (motion-reduce:duration-[1ms]) where the CSS version needs a media-query block, and it sits on the element that moves.
  • The script is a direct port, restructured into the collection's IIFE + data-bound + __tsbCleanup convention so it survives being pasted twice and drops into a React effect unchanged.
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 Off Canvas Sidebar Drawer with Focus Return
Source: https://codefronts.com/navigation/tailwind-css-sidebars/tailwind-off-canvas-sidebar-drawer-with-focus-return/

The full contract in one demo, and the collection's closing argument for the port: focus in, focus trapped, background inert, Esc closes, focus returns &mdash; all in script, because all of it is behaviour &mdash; while every pixel of the drawer's two states lives in one class list on one element. Demos 02, 03 and 19 are the same lesson at increasing completeness.
## HTML
```html
<section class="tsb-19 block relative w-full min-h-svh bg-tsb19-bg text-tsb19-ink font-tsb19-sans leading-[1.55]" aria-label="Fern Daily">
  <div class="absolute top-3.5 right-3.5 z-60">
    <input class="tsb-19-theme-input peer absolute inset-0 w-full h-full m-0 opacity-0 cursor-pointer" type="checkbox" id="tsb-19-theme">
    <label class="grid place-items-center w-11 h-11 rounded-[0.5rem] border border-tsb19-line bg-tsb19-surf text-tsb19-ink cursor-pointer shadow-[0_0.4rem_1rem_oklch(0%_0_0/0.25)] peer-focus-visible:outline-2 peer-focus-visible:outline-tsb19-acc peer-focus-visible:outline-offset-2" for="tsb-19-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 h-svh bg-tsb19-surf" id="tsb-19-screen">
    <div class="flex flex-col h-full transition-opacity duration-200 inert:opacity-40 motion-reduce:transition-none" id="tsb-19-page">
      <div class="flex items-center gap-2.5 py-2.5 pl-2.5 pr-[4.2rem] border-b border-tsb19-line">
        <button class="grid place-items-center w-11 h-11 rounded-[0.5rem] border-0 bg-tsb19-surf2 text-tsb19-ink cursor-pointer [&>svg]:w-5 [&>svg]:h-5 focus-visible:outline-2 focus-visible:outline-tsb19-acc focus-visible:outline-offset-2" type="button" id="tsb-19-open" aria-expanded="false" aria-controls="tsb-19-drawer">
          <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 7h16M4 12h16M4 17h16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
          <span class="absolute w-px h-px overflow-hidden whitespace-nowrap [clip-path:inset(50%)]">Open sections</span>
        </button>
        <p class="m-0 font-tsb19-display text-[1.15rem] font-bold tracking-[0.04em] uppercase text-tsb19-acc">Fern Daily</p>
      </div>
      <article class="flex-1 min-h-0 overflow-y-auto px-5 pt-4.5 pb-10 [overscroll-behavior:contain] r46:px-9 r46:pt-8 r46:pb-12">
        <p class="m-0 mb-1 font-tsb19-display text-[0.74rem] tracking-[0.14em] uppercase text-tsb19-acc">Infrastructure</p>
        <h2 class="m-0 mb-1.5 max-w-[34rem] font-tsb19-display text-[clamp(1.5rem,4vw,2.2rem)] leading-[1.08]">Regions added in the third quarter</h2>
        <p class="m-0 mb-4 text-[0.82rem] text-tsb19-mut">Alex Chen &middot; 6 min read</p>
        <p class="m-0 mb-3 max-w-[38rem] text-[0.94rem] text-tsb19-mut text-pretty">Three new regions came online this quarter, each with an independent control plane and its own certificate authority.</p>
        <p class="m-0 mb-3 max-w-[38rem] text-[0.94rem] text-tsb19-mut text-pretty">Promotion remains a pointer move, so rollback stays instant even across regions. The verification step is the slow part, and it is deliberately manual.</p>
        <p class="m-0 mb-3 max-w-[38rem] text-[0.94rem] text-tsb19-mut text-pretty">Failover drills now run monthly per region, with an observer from a second team. The runbook lives in the handbook under Operations.</p>
        <a class="inline-flex items-center min-h-11 text-[0.9rem] font-semibold text-tsb19-acc focus-visible:outline-2 focus-visible:outline-tsb19-acc focus-visible:outline-offset-2" href="#tsb-19-screen">Read the full report</a>
      </article>
    </div>

    <div class="absolute inset-0 z-20 bg-[oklch(12%_0.01_60/0.5)] [&[hidden]]:hidden" data-tsb-19-scrim hidden></div>

    <div class="absolute inset-y-0 left-0 z-30 flex flex-col w-[min(15rem,80%)] bg-tsb19-surf border-r border-tsb19-line shadow-[0.6rem_0_2rem_oklch(0%_0_0/0.35)] -translate-x-full transition-transform duration-[260ms] ease-[cubic-bezier(0.22,1,0.36,1)] group-data-open:translate-x-0 motion-reduce:duration-[1ms] [&[hidden]]:hidden" id="tsb-19-drawer" role="dialog" aria-modal="true" aria-labelledby="tsb-19-dh" hidden>
      <div class="flex items-center gap-1.5 px-2.5 py-2 border-b border-tsb19-line">
        <p class="mr-auto m-0 font-tsb19-display text-[0.78rem] tracking-[0.14em] uppercase text-tsb19-mut" id="tsb-19-dh">Sections</p>
        <button class="grid place-items-center w-11 h-11 rounded-[0.5rem] border-0 bg-tsb19-surf2 text-tsb19-ink cursor-pointer [&>svg]:w-4 [&>svg]:h-4 focus-visible:outline-2 focus-visible:outline-tsb19-acc focus-visible:outline-offset-2" type="button" data-tsb-19-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 sections</span>
        </button>
      </div>
      <nav class="flex-1 min-h-0 overflow-y-auto p-1 [overscroll-behavior:contain]" aria-label="Fern Daily sections">
        <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.4rem] text-[0.89rem] text-tsb19-ink no-underline hover:bg-tsb19-surf2 focus-visible:outline-2 focus-visible:outline-tsb19-acc focus-visible:-outline-offset-2 aria-[current=page]:bg-tsb19-accsoft aria-[current=page]:text-tsb19-acc aria-[current=page]:font-semibold" href="#tsb-19-screen" aria-current="page">Infrastructure</a></li>
          <li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.4rem] text-[0.89rem] text-tsb19-ink no-underline hover:bg-tsb19-surf2 focus-visible:outline-2 focus-visible:outline-tsb19-acc focus-visible:-outline-offset-2" href="#tsb-19-screen">Security</a></li>
          <li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.4rem] text-[0.89rem] text-tsb19-ink no-underline hover:bg-tsb19-surf2 focus-visible:outline-2 focus-visible:outline-tsb19-acc focus-visible:-outline-offset-2" href="#tsb-19-screen">Billing</a></li>
          <li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.4rem] text-[0.89rem] text-tsb19-ink no-underline hover:bg-tsb19-surf2 focus-visible:outline-2 focus-visible:outline-tsb19-acc focus-visible:-outline-offset-2" href="#tsb-19-screen">Field notes</a></li>
          <li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.4rem] text-[0.89rem] text-tsb19-ink no-underline hover:bg-tsb19-surf2 focus-visible:outline-2 focus-visible:outline-tsb19-acc focus-visible:-outline-offset-2" href="#tsb-19-screen">Audit log</a></li>
          <li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.4rem] text-[0.89rem] text-tsb19-ink no-underline hover:bg-tsb19-surf2 focus-visible:outline-2 focus-visible:outline-tsb19-acc focus-visible:-outline-offset-2" href="#tsb-19-screen">Newsletters</a></li>
          <li><a class="flex items-center min-w-0 min-h-11 px-2.5 py-2 rounded-[0.4rem] text-[0.89rem] text-tsb19-ink no-underline hover:bg-tsb19-surf2 focus-visible:outline-2 focus-visible:outline-tsb19-acc focus-visible:-outline-offset-2" href="#tsb-19-screen">Archive</a></li>
        </ul>
      </nav>
    </div>
  </div>
</section>
```
## CSS
```css
@import "tailwindcss";

@theme {
  --color-tsb19-bg: #1c1a18;
  --color-tsb19-bg: oklch(23% 0.008 60);
  --color-tsb19-surf: #262320;
  --color-tsb19-surf: oklch(27% 0.01 60);
  --color-tsb19-surf2: #302c28;
  --color-tsb19-surf2: oklch(31% 0.012 60);
  --color-tsb19-line: #3d3833;
  --color-tsb19-line: oklch(37% 0.014 60);
  --color-tsb19-ink: #f5efe8;
  --color-tsb19-ink: oklch(95% 0.012 70);
  --color-tsb19-mut: #ab9f93;
  --color-tsb19-mut: oklch(71% 0.02 70);
  --color-tsb19-acc: #ef7a52;
  --color-tsb19-acc: oklch(70% 0.16 40);
  --color-tsb19-accsoft: #3a251c;
  --color-tsb19-accsoft: oklch(30% 0.05 40);
  --font-tsb19-sans: system-ui, -apple-system, 'Segoe UI', sans-serif;
  --font-tsb19-display: 'Archivo Narrow', 'Roboto Condensed', ui-sans-serif, system-ui, sans-serif;
  --breakpoint-r46: 46rem;
}
/* Root rule: the full-bleed contract. The screen inside it is h-svh, because two
   independent scroll panes need a bounded stage. */

.tsb-19 {
  width: 100%;
  min-height: 100svh;
}

.tsb-19 *,
.tsb-19 *::before,
.tsb-19 *::after {
  box-sizing: border-box;
}
/* Dark-first demo: the @theme block IS the dark palette, light mirror below. */

.tsb-19:has(.tsb-19-theme-input:checked) {
  --color-tsb19-bg: #f8f4ef;
  --color-tsb19-surf: #ffffff;
  --color-tsb19-surf2: #efe9e2;
  --color-tsb19-line: #ded5cb;
  --color-tsb19-ink: #221e1a;
  --color-tsb19-mut: #6a6058;
  --color-tsb19-acc: #9e3d18;
  --color-tsb19-acc: oklch(48% 0.14 40);
  --color-tsb19-accsoft: #f6e2d8;
}

@media (prefers-color-scheme: light) {
  .tsb-19 {
    --color-tsb19-bg: #f8f4ef;
    --color-tsb19-surf: #ffffff;
    --color-tsb19-surf2: #efe9e2;
    --color-tsb19-line: #ded5cb;
    --color-tsb19-ink: #221e1a;
    --color-tsb19-mut: #6a6058;
    --color-tsb19-acc: #9e3d18;
    --color-tsb19-acc: oklch(48% 0.14 40);
    --color-tsb19-accsoft: #f6e2d8;
  }

  .tsb-19:has(.tsb-19-theme-input:checked) {
    --color-tsb19-bg: #1c1a18;
    --color-tsb19-surf: #262320;
    --color-tsb19-surf2: #302c28;
    --color-tsb19-line: #3d3833;
    --color-tsb19-ink: #f5efe8;
    --color-tsb19-mut: #ab9f93;
    --color-tsb19-acc: #ef7a52;
    --color-tsb19-acc: oklch(70% 0.16 40);
    --color-tsb19-accsoft: #3a251c;
  }
}

@media (prefers-color-scheme: dark) {
  .tsb-19:has(.tsb-19-theme-input:checked) {
    --color-tsb19-bg: #f8f4ef;
    --color-tsb19-surf: #ffffff;
    --color-tsb19-surf2: #efe9e2;
    --color-tsb19-line: #ded5cb;
    --color-tsb19-ink: #221e1a;
    --color-tsb19-mut: #6a6058;
    --color-tsb19-acc: #9e3d18;
    --color-tsb19-acc: oklch(48% 0.14 40);
    --color-tsb19-accsoft: #f6e2d8;
  }
}
```

## JavaScript
```js
(function () {
  document.querySelectorAll('.tsb-19').forEach(function (root) {
    if (root.dataset.bound) return; root.dataset.bound = '1';
    var screen = root.querySelector('#tsb-19-screen'),
        drawer = root.querySelector('#tsb-19-drawer'),
        page = root.querySelector('#tsb-19-page'),
        scrim = root.querySelector('[data-tsb-19-scrim]'),
        trigger = root.querySelector('#tsb-19-open'),
        supportsInert = 'inert' in HTMLElement.prototype,
        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 = scrim.hidden = false;
      screen.setAttribute('data-open', '');
      trigger.setAttribute('aria-expanded', 'true');
      if (supportsInert) page.inert = true; else page.setAttribute('aria-hidden', 'true');
      document.addEventListener('keydown', onKey);
      requestAnimationFrame(function () { drawer.querySelector('[data-tsb-19-close]').focus(); });
    }
    function close() {
      document.removeEventListener('keydown', onKey);
      if (supportsInert) page.inert = false; else page.removeAttribute('aria-hidden');
      screen.removeAttribute('data-open');
      trigger.setAttribute('aria-expanded', 'false');
      drawer.hidden = scrim.hidden = true;
      (returnTo || trigger).focus({ preventScroll: true });
      returnTo = null;
    }
    trigger.addEventListener('click', open);
    scrim.addEventListener('click', close);
    root.querySelectorAll('[data-tsb-19-close]').forEach(function (b) { b.addEventListener('click', close); });
    root.__tsbCleanup = function () {
      document.removeEventListener('keydown', onKey);
      if (supportsInert) page.inert = false;
    };
  });
})();
```

How this works

Five promises, one attribute. The script sets data-open on the screen and manages focus; the markup reacts:

<div class="group relative overflow-hidden h-svh" data-open>
  <div class="flex flex-col h-full inert:opacity-40" id="tsb-19-page">…
  <div class="absolute inset-y-0 left-0 -translate-x-full
       group-data-open:translate-x-0" role="dialog" aria-modal="true">

Every visual consequence of “open” is readable from the elements. Every behavioural consequence — which element gets focus, which keys are bound, what happens on close — stays in twenty lines of vanilla JS.

Trap AND inert, not one or the other. The Tab wrap keeps sequential focus inside; inert takes the page out of the accessibility tree so a virtual cursor cannot wander either. Demo 02 has the trap, demo 03 has the inert, and this one is the shape you should actually ship.

Focus return is the promise that gets dropped. The trigger is stored at open and re-focused on close with preventScroll, so the reader lands exactly where they were in the article.

Two scroll panes, contained. The article and the drawer list scroll independently, and neither chains into the other — on a phone-sized stage that difference is immediately felt.

Make it yours

  • Right-edge drawer: left-0 → right-0, flip the translate sign, mirror the shadow.
  • Width: the single w-[min(15rem,80%)].
  • Add a swipe-to-close with a pointer handler on the drawer; the state is already one attribute.
  • Retheme through --color-tsb19-acc; the demo is dark-first with the light mirror in the stylesheet.
  • Promote the stage to a real viewport overlay by swapping absolute for fixed on the drawer and scrim and deleting h-svh from the screen.
  • Replace the whole thing with <dialog> + showModal() if you can accept the browser's entry animation: trap, inert and Esc come free.

Gotchas — read before shipping

  • A trap with no Esc and no reachable close button is a keyboard trap and fails SC 2.1.2 at Level A.
  • The drawer must be a SIBLING of the inert page, never inside it, or inert switches off the drawer too.
  • Without [&[hidden]]:hidden, flex beats the UA [hidden] rule and a closed drawer stays visible and focusable.
  • Query the focusable list at open time; a drawer whose contents change breaks a cached list.
  • min-h-0 on both scroll panes is required, exactly as in demo 08 — without it the stage grows and the page scrolls instead.
  • Restore focus with preventScroll: true or the article jumps as focus lands.

Browser support

ChromeSafariFirefoxEdge
111+16.4+128+111+

Tailwind v4's generated sheet sets the floor; inert is Baseline 2023 and the demo feature-detects it, falling back to aria-hidden. The trap and focus return have no version floor.

Techniques used in this demo

Theme tokensset in @theme, used by a utility

Search CodeFronts

Loading…