28 Tailwind Hamburger Menus
17 / 28

All 28 in 28 Tailwind Hamburger Menus

  1. 01Tailwind Hamburger Menu with Clip-Path Circle Expansion
  2. 02Tailwind Keyboard Navigable Hamburger Menu with ARIA Attributes
  3. 03Tailwind Animated 3-Line to X Hamburger Icon Toggle
  4. 04Tailwind Slide-In Off-Canvas Drawer with Dimmed Backdrop Overlay
  5. 05Pure Tailwind Checkbox Hack Hamburger Navigation (No JavaScript)
  6. 06Tailwind Responsive Hamburger Menu with Integrated Search Bar & Social Links
  7. 07Tailwind Glassmorphism Backdrop Blur Hamburger Navigation
  8. 08Tailwind Push Page Navigation Menu (Off-Canvas Content Shift)
  9. 09Pure Tailwind Hamburger Menu Without JavaScript
  10. 10Tailwind Hamburger Menu Morphing to Close Icon Animation
  11. 11Tailwind Full Screen Mobile Navigation Overlay
  12. 12Tailwind Slide Out Sidebar Navigation / Off Canvas Menu
  13. 13Tailwind Responsive Navbar Mobile Hamburger Toggle Breakpoints
  14. 14Tailwind Minimalist Floating Hamburger Button
  15. 15Tailwind CSS Responsive Hamburger Menu Component
  16. 16Tailwind Rebuild of the Bootstrap 5 Navbar Hamburger Toggle
  17. 17Tailwind WCAG Accessible Hamburger Menu Example
  18. 18Pure Tailwind Dropdown Slide Down Mobile Hamburger Menu
  19. 19Simple 3-Line Tailwind Hamburger Icon Hover Effect
  20. 20Tailwind Full Screen Blur Overlay Hamburger Menu
  21. 21Tailwind Mobile Bottom Navigation Bar with Floating Hamburger Toggle
  22. 22Pure Tailwind Multi-Level Accordion Hamburger Dropdown Menu
  23. 23Flexbox Navbar Right-Aligned Tailwind Hamburger Icon
  24. 24Radial Ripple Expand Tailwind Hamburger Menu
  25. 25Sticky Tailwind Navbar Shrinking Hamburger Icon on Scroll
  26. 26Neumorphic Tailwind Hamburger Toggle Button
  27. 27Brutalist Design Bordered Tailwind Hamburger Menu
  28. 28Tailwind 3D Flip Canvas Mobile Hamburger Navigation
Tailwind + JSMIT licensed

Tailwind WCAG Accessible Hamburger Menu Example

The audit-proof build: a drawer implemented as a true modal dialog — role=dialog, aria-modal, focus moved in, trapped, restored, background inert, Escape wired, 44px targets, visible rings, reduced-motion path. Every requirement is tied to its WCAG success criterion, and in utilities the two that are usually missed — target size and focus visibility — are impossible to overlook because they are typed on every control.

Live Demo
Plain CSSTailwind v4
Try it

Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time

Untitled UI React · Affiliate link

Get Untitled UI Pro

The code

<section class="thb-17 w-full min-h-svh grid justify-center content-start bg-thb17-bg font-thb17-sans text-thb17-ink px-5 py-9" aria-label="WCAG accessible hamburger menu demo">
  <div class="group relative w-[min(420px,88vw)] h-135 rounded-3xl overflow-hidden bg-thb17-bg border border-thb17-edge">
    <div id="thb17-page" class="h-full">
      <header class="flex items-center justify-between h-17 pl-5.5 pr-3.5 bg-thb17-panel border-b border-thb17-edge">
        <span class="flex items-center gap-2.5 text-[15.5px] font-bold">
          <i class="w-5 h-5 rounded-md bg-thb17-acc" aria-hidden="true"></i>CivicWorks
        </span>
        <button type="button" aria-expanded="false" aria-controls="thb17-menu" aria-label="Open navigation" class="flex items-center gap-2.5 min-h-11 min-w-11 px-3.5 rounded-[10px] border-2 border-thb17-ink bg-thb17-panel font-thb17-sans text-[13.5px] font-bold text-thb17-ink cursor-pointer transition-[background,color] duration-200 hover:bg-thb17-ink hover:text-thb17-panel focus-visible:outline-3 focus-visible:outline-thb17-focus focus-visible:outline-offset-2">
          <span class="relative block w-4.25 h-3.25" aria-hidden="true">
            <i class="absolute left-0 top-0 w-4.25 h-[2.5px] rounded-sm bg-current"></i>
            <i class="absolute left-0 top-[5.25px] w-4.25 h-[2.5px] rounded-sm bg-current"></i>
            <i class="absolute left-0 top-[10.5px] w-4.25 h-[2.5px] rounded-sm bg-current"></i>
          </span>
          <span aria-hidden="true">Menu</span>
        </button>
      </header>
      <div class="grid justify-items-start gap-3.5 p-5.5" aria-hidden="true">
        <div class="mb-1.5 px-3.25 py-2 rounded-lg bg-thb17-pass text-white text-xs font-extrabold tracking-[0.08em]">WCAG 2.2 AA</div>
        <div class="h-3 w-[84%] rounded-full bg-thb17-line"></div>
        <div class="h-3 w-[66%] rounded-full bg-thb17-line"></div>
        <div class="h-3 w-[75%] rounded-full bg-thb17-line"></div>
      </div>
    </div>
    <div aria-hidden="true" class="absolute inset-0 z-2 bg-[oklch(0.2_0.03_258/0.5)] opacity-0 invisible [transition:opacity_.3s,visibility_0s_.3s] group-data-open:opacity-100 group-data-open:visible group-data-open:[transition:opacity_.3s,visibility_0s_0s]"></div>
    <aside id="thb17-menu" role="dialog" aria-modal="true" aria-labelledby="thb17-title" tabindex="-1" class="absolute inset-y-0 right-0 z-3 flex flex-col w-[min(88%,330px)] bg-thb17-panel border-l border-thb17-edge shadow-[-20px_0_44px_-20px_oklch(0.2_0.04_258/0.5)] translate-x-full invisible [transition:translate_.38s_var(--ease-thb17),visibility_0s_.38s] group-data-open:translate-x-0 group-data-open:visible group-data-open:[transition:translate_.38s_var(--ease-thb17),visibility_0s_0s] motion-reduce:translate-x-0 motion-reduce:opacity-0 motion-reduce:[transition:opacity_.15s,visibility_0s_.15s] motion-reduce:group-data-open:opacity-100 motion-reduce:group-data-open:[transition:opacity_.15s]">
      <header class="flex items-center justify-between pt-4 pb-3 pl-5.5 pr-3.5 border-b-2 border-thb17-ink">
        <b id="thb17-title" class="text-[17px] font-bold">Navigation</b>
        <button type="button" aria-label="Close navigation" class="grid place-items-center min-h-11 min-w-11 rounded-[10px] border-2 border-thb17-ink bg-thb17-panel text-thb17-ink cursor-pointer transition-[background,color] duration-200 hover:bg-thb17-ink hover:text-thb17-panel focus-visible:outline-3 focus-visible:outline-thb17-focus focus-visible:outline-offset-2">
          <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg>
        </button>
      </header>
      <nav aria-label="Primary">
        <ul class="list-none grid gap-0.5 px-3 py-2.5">
          <li><a href="#services" aria-current="page" class="flex items-center min-h-11 px-3 rounded-[9px] text-[15.5px] font-semibold text-thb17-ink underline decoration-transparent underline-offset-[3px] transition-[background,text-decoration-color] duration-150 hover:bg-thb17-hover hover:decoration-thb17-acc focus-visible:outline-3 focus-visible:outline-thb17-focus focus-visible:-outline-offset-3 aria-current:text-thb17-acc aria-current:font-bold aria-current:bg-[color-mix(in_oklch,var(--color-thb17-acc)_10%,transparent)]">Services</a></li>
          <li><a href="#permits" class="flex items-center min-h-11 px-3 rounded-[9px] text-[15.5px] font-semibold text-thb17-ink underline decoration-transparent underline-offset-[3px] transition-[background,text-decoration-color] duration-150 hover:bg-thb17-hover hover:decoration-thb17-acc focus-visible:outline-3 focus-visible:outline-thb17-focus focus-visible:-outline-offset-3">Permits &amp; licences</a></li>
          <li><a href="#payments" class="flex items-center min-h-11 px-3 rounded-[9px] text-[15.5px] font-semibold text-thb17-ink underline decoration-transparent underline-offset-[3px] transition-[background,text-decoration-color] duration-150 hover:bg-thb17-hover hover:decoration-thb17-acc focus-visible:outline-3 focus-visible:outline-thb17-focus focus-visible:-outline-offset-3">Payments</a></li>
          <li><a href="#accessibility" class="flex items-center min-h-11 px-3 rounded-[9px] text-[15.5px] font-semibold text-thb17-ink underline decoration-transparent underline-offset-[3px] transition-[background,text-decoration-color] duration-150 hover:bg-thb17-hover hover:decoration-thb17-acc focus-visible:outline-3 focus-visible:outline-thb17-focus focus-visible:-outline-offset-3">Accessibility statement</a></li>
          <li><a href="#contact" class="flex items-center min-h-11 px-3 rounded-[9px] text-[15.5px] font-semibold text-thb17-ink underline decoration-transparent underline-offset-[3px] transition-[background,text-decoration-color] duration-150 hover:bg-thb17-hover hover:decoration-thb17-acc focus-visible:outline-3 focus-visible:outline-thb17-focus focus-visible:-outline-offset-3">Contact us</a></li>
        </ul>
      </nav>
      <p class="mt-auto px-5.5 py-4 border-t border-thb17-edge text-xs leading-[1.9] text-thb17-mut text-pretty" aria-hidden="true">
        <kbd class="px-1.5 py-0.5 rounded-[5px] border-[1.5px] border-b-[3px] border-thb17-edge bg-thb17-bg font-thb17-mono text-[10.5px] font-bold">Tab</kbd> cycles inside &middot;
        <kbd class="px-1.5 py-0.5 rounded-[5px] border-[1.5px] border-b-[3px] border-thb17-edge bg-thb17-bg font-thb17-mono text-[10.5px] font-bold">Esc</kbd> closes &amp; restores focus &middot; page behind is <code class="px-1.25 py-0.5 rounded bg-thb17-line font-thb17-mono text-[11px]">inert</code>
      </p>
    </aside>
    <p role="status" aria-live="polite" class="absolute w-px h-px overflow-hidden whitespace-nowrap [clip-path:inset(50%)]"></p>
  </div>
</section>
@import "tailwindcss";

@theme {
  --color-thb17-bg: #fafafc;
  --color-thb17-bg: oklch(0.98 0.004 250);
  --color-thb17-panel: #ffffff;
  --color-thb17-panel: oklch(1 0 0);
  --color-thb17-hover: #f0f1f6;
  --color-thb17-hover: oklch(0.95 0.008 255);
  --color-thb17-edge: #d7d9e2;
  --color-thb17-edge: oklch(0.87 0.01 250);
  --color-thb17-line: #e4e5ec;
  --color-thb17-line: oklch(0.9 0.008 250);
  --color-thb17-ink: #2b2e3d;
  --color-thb17-ink: oklch(0.22 0.02 255);
  --color-thb17-mut: #5f6377;
  --color-thb17-mut: oklch(0.45 0.018 255);
  --color-thb17-acc: #3b4fb8;
  --color-thb17-acc: oklch(0.48 0.16 258);
  --color-thb17-focus: #4a5ee0;
  --color-thb17-focus: oklch(0.55 0.2 262);
  --color-thb17-pass: #2f7d55;
  --color-thb17-pass: oklch(0.5 0.14 155);
  --font-thb17-sans: 'Space Grotesk', system-ui, sans-serif;
  --font-thb17-mono: 'JetBrains Mono', ui-monospace, monospace;
  --ease-thb17: cubic-bezier(0.32, 0.72, 0.28, 1);
}
/* 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. */

.thb-17 {
  width: 100%;
  min-height: 100svh;
}
(function () {
  document.querySelectorAll('.thb-17 > .group').forEach(function (stage) {
    if (stage.dataset.bound) return; stage.dataset.bound = '1';
    var page = stage.querySelector('#thb17-page'),
        menu = stage.querySelector('#thb17-menu'),
        burger = stage.querySelector('button[aria-controls="thb17-menu"]'),
        closeBtn = stage.querySelector('button[aria-label="Close navigation"]'),
        scrim = stage.querySelector('div[aria-hidden="true"]'),
        live = stage.querySelector('[role="status"]'),
        opener = null;
    function focusables() {
      return menu.querySelectorAll('a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])');
    }
    function set(open) {
      stage.toggleAttribute('data-open', open);
      burger.setAttribute('aria-expanded', String(open));
      burger.setAttribute('aria-label', open ? 'Close navigation' : 'Open navigation');
      if (open) {
        opener = document.activeElement;
        page.setAttribute('inert', '');
        closeBtn.focus();
        live.textContent = 'Navigation opened';
      } else {
        page.removeAttribute('inert');
        (opener || burger).focus();
        live.textContent = 'Navigation closed';
      }
    }
    burger.addEventListener('click', function () { set(true); });
    closeBtn.addEventListener('click', function () { set(false); });
    scrim.addEventListener('click', function () { set(false); });
    stage.addEventListener('keydown', function (e) {
      if (!stage.hasAttribute('data-open')) return;
      if (e.key === 'Escape') { set(false); return; }
      if (e.key !== 'Tab') return;
      var f = focusables(), 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(); }
    });
  });
})();

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-thb17-bg: #fafafc;
  --color-thb17-bg: oklch(0.98 0.004 250);
  --color-thb17-panel: #ffffff;
  --color-thb17-panel: oklch(1 0 0);
  --color-thb17-hover: #f0f1f6;
  --color-thb17-hover: oklch(0.95 0.008 255);
  --color-thb17-edge: #d7d9e2;
  --color-thb17-edge: oklch(0.87 0.01 250);
  --color-thb17-line: #e4e5ec;
  --color-thb17-line: oklch(0.9 0.008 250);
  --color-thb17-ink: #2b2e3d;
  --color-thb17-ink: oklch(0.22 0.02 255);
  --color-thb17-mut: #5f6377;
  --color-thb17-mut: oklch(0.45 0.018 255);
  --color-thb17-acc: #3b4fb8;
  --color-thb17-acc: oklch(0.48 0.16 258);
  --color-thb17-focus: #4a5ee0;
  --color-thb17-focus: oklch(0.55 0.2 262);
  --color-thb17-pass: #2f7d55;
  --color-thb17-pass: oklch(0.5 0.14 155);
  --font-thb17-sans: 'Space Grotesk', system-ui, sans-serif;
  --font-thb17-mono: 'JetBrains Mono', ui-monospace, monospace;
  --ease-thb17: cubic-bezier(0.32, 0.72, 0.28, 1);
}
/* 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. */

.thb-17 {
  width: 100%;
  min-height: 100svh;
}
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;800&family=JetBrains+Mono:wght@400;500;600&display=swap">

What changed in the port

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

  • The --target:44px token becomes min-h-11 min-w-11 typed on every control. More repetition, and strictly better for an audit: target size is now a property of each button rather than a variable someone can forget to apply.
  • Same for focus: five separate focus-visible:outline-3 declarations instead of one grouped rule. The verbosity is the safety — a new control cannot inherit a ring it never declared.
  • The whole @media (prefers-reduced-motion) block becomes motion-reduce: variants on the drawer, converting the slide to a fade in place.
  • role=&quot;dialog&quot;, aria-modal, inert, the focus trap and the live region are unchanged. Utilities restyle a dialog; they do not implement modality, and nothing about the port makes the script smaller.
  • The visually-hidden live region uses the same clip recipe as the checkbox demos, written inline so the &ldquo;never hidden&rdquo; rule stays visible.
  • text-decoration-color transitions become decoration-transparent + hover:decoration-thb17-acc, keeping link affordance independent of colour alone (1.4.1).
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 WCAG Accessible Hamburger Menu Example
Source: https://codefronts.com/navigation/tailwind-hamburger-menus/tailwind-wcag-accessible-hamburger-menu-example/

The audit-proof build: a drawer implemented as a true modal dialog &mdash; role=dialog, aria-modal, focus moved in, trapped, restored, background inert, Escape wired, 44px targets, visible rings, reduced-motion path. Every requirement is tied to its WCAG success criterion, and in utilities the two that are usually missed &mdash; target size and focus visibility &mdash; are impossible to overlook because they are typed on every control.
## HTML
```html
<section class="thb-17 w-full min-h-svh grid justify-center content-start bg-thb17-bg font-thb17-sans text-thb17-ink px-5 py-9" aria-label="WCAG accessible hamburger menu demo">
  <div class="group relative w-[min(420px,88vw)] h-135 rounded-3xl overflow-hidden bg-thb17-bg border border-thb17-edge">
    <div id="thb17-page" class="h-full">
      <header class="flex items-center justify-between h-17 pl-5.5 pr-3.5 bg-thb17-panel border-b border-thb17-edge">
        <span class="flex items-center gap-2.5 text-[15.5px] font-bold">
          <i class="w-5 h-5 rounded-md bg-thb17-acc" aria-hidden="true"></i>CivicWorks
        </span>
        <button type="button" aria-expanded="false" aria-controls="thb17-menu" aria-label="Open navigation" class="flex items-center gap-2.5 min-h-11 min-w-11 px-3.5 rounded-[10px] border-2 border-thb17-ink bg-thb17-panel font-thb17-sans text-[13.5px] font-bold text-thb17-ink cursor-pointer transition-[background,color] duration-200 hover:bg-thb17-ink hover:text-thb17-panel focus-visible:outline-3 focus-visible:outline-thb17-focus focus-visible:outline-offset-2">
          <span class="relative block w-4.25 h-3.25" aria-hidden="true">
            <i class="absolute left-0 top-0 w-4.25 h-[2.5px] rounded-sm bg-current"></i>
            <i class="absolute left-0 top-[5.25px] w-4.25 h-[2.5px] rounded-sm bg-current"></i>
            <i class="absolute left-0 top-[10.5px] w-4.25 h-[2.5px] rounded-sm bg-current"></i>
          </span>
          <span aria-hidden="true">Menu</span>
        </button>
      </header>
      <div class="grid justify-items-start gap-3.5 p-5.5" aria-hidden="true">
        <div class="mb-1.5 px-3.25 py-2 rounded-lg bg-thb17-pass text-white text-xs font-extrabold tracking-[0.08em]">WCAG 2.2 AA</div>
        <div class="h-3 w-[84%] rounded-full bg-thb17-line"></div>
        <div class="h-3 w-[66%] rounded-full bg-thb17-line"></div>
        <div class="h-3 w-[75%] rounded-full bg-thb17-line"></div>
      </div>
    </div>
    <div aria-hidden="true" class="absolute inset-0 z-2 bg-[oklch(0.2_0.03_258/0.5)] opacity-0 invisible [transition:opacity_.3s,visibility_0s_.3s] group-data-open:opacity-100 group-data-open:visible group-data-open:[transition:opacity_.3s,visibility_0s_0s]"></div>
    <aside id="thb17-menu" role="dialog" aria-modal="true" aria-labelledby="thb17-title" tabindex="-1" class="absolute inset-y-0 right-0 z-3 flex flex-col w-[min(88%,330px)] bg-thb17-panel border-l border-thb17-edge shadow-[-20px_0_44px_-20px_oklch(0.2_0.04_258/0.5)] translate-x-full invisible [transition:translate_.38s_var(--ease-thb17),visibility_0s_.38s] group-data-open:translate-x-0 group-data-open:visible group-data-open:[transition:translate_.38s_var(--ease-thb17),visibility_0s_0s] motion-reduce:translate-x-0 motion-reduce:opacity-0 motion-reduce:[transition:opacity_.15s,visibility_0s_.15s] motion-reduce:group-data-open:opacity-100 motion-reduce:group-data-open:[transition:opacity_.15s]">
      <header class="flex items-center justify-between pt-4 pb-3 pl-5.5 pr-3.5 border-b-2 border-thb17-ink">
        <b id="thb17-title" class="text-[17px] font-bold">Navigation</b>
        <button type="button" aria-label="Close navigation" class="grid place-items-center min-h-11 min-w-11 rounded-[10px] border-2 border-thb17-ink bg-thb17-panel text-thb17-ink cursor-pointer transition-[background,color] duration-200 hover:bg-thb17-ink hover:text-thb17-panel focus-visible:outline-3 focus-visible:outline-thb17-focus focus-visible:outline-offset-2">
          <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg>
        </button>
      </header>
      <nav aria-label="Primary">
        <ul class="list-none grid gap-0.5 px-3 py-2.5">
          <li><a href="#services" aria-current="page" class="flex items-center min-h-11 px-3 rounded-[9px] text-[15.5px] font-semibold text-thb17-ink underline decoration-transparent underline-offset-[3px] transition-[background,text-decoration-color] duration-150 hover:bg-thb17-hover hover:decoration-thb17-acc focus-visible:outline-3 focus-visible:outline-thb17-focus focus-visible:-outline-offset-3 aria-current:text-thb17-acc aria-current:font-bold aria-current:bg-[color-mix(in_oklch,var(--color-thb17-acc)_10%,transparent)]">Services</a></li>
          <li><a href="#permits" class="flex items-center min-h-11 px-3 rounded-[9px] text-[15.5px] font-semibold text-thb17-ink underline decoration-transparent underline-offset-[3px] transition-[background,text-decoration-color] duration-150 hover:bg-thb17-hover hover:decoration-thb17-acc focus-visible:outline-3 focus-visible:outline-thb17-focus focus-visible:-outline-offset-3">Permits &amp; licences</a></li>
          <li><a href="#payments" class="flex items-center min-h-11 px-3 rounded-[9px] text-[15.5px] font-semibold text-thb17-ink underline decoration-transparent underline-offset-[3px] transition-[background,text-decoration-color] duration-150 hover:bg-thb17-hover hover:decoration-thb17-acc focus-visible:outline-3 focus-visible:outline-thb17-focus focus-visible:-outline-offset-3">Payments</a></li>
          <li><a href="#accessibility" class="flex items-center min-h-11 px-3 rounded-[9px] text-[15.5px] font-semibold text-thb17-ink underline decoration-transparent underline-offset-[3px] transition-[background,text-decoration-color] duration-150 hover:bg-thb17-hover hover:decoration-thb17-acc focus-visible:outline-3 focus-visible:outline-thb17-focus focus-visible:-outline-offset-3">Accessibility statement</a></li>
          <li><a href="#contact" class="flex items-center min-h-11 px-3 rounded-[9px] text-[15.5px] font-semibold text-thb17-ink underline decoration-transparent underline-offset-[3px] transition-[background,text-decoration-color] duration-150 hover:bg-thb17-hover hover:decoration-thb17-acc focus-visible:outline-3 focus-visible:outline-thb17-focus focus-visible:-outline-offset-3">Contact us</a></li>
        </ul>
      </nav>
      <p class="mt-auto px-5.5 py-4 border-t border-thb17-edge text-xs leading-[1.9] text-thb17-mut text-pretty" aria-hidden="true">
        <kbd class="px-1.5 py-0.5 rounded-[5px] border-[1.5px] border-b-[3px] border-thb17-edge bg-thb17-bg font-thb17-mono text-[10.5px] font-bold">Tab</kbd> cycles inside &middot;
        <kbd class="px-1.5 py-0.5 rounded-[5px] border-[1.5px] border-b-[3px] border-thb17-edge bg-thb17-bg font-thb17-mono text-[10.5px] font-bold">Esc</kbd> closes &amp; restores focus &middot; page behind is <code class="px-1.25 py-0.5 rounded bg-thb17-line font-thb17-mono text-[11px]">inert</code>
      </p>
    </aside>
    <p role="status" aria-live="polite" class="absolute w-px h-px overflow-hidden whitespace-nowrap [clip-path:inset(50%)]"></p>
  </div>
</section>
```
## CSS
```css
@import "tailwindcss";

@theme {
  --color-thb17-bg: #fafafc;
  --color-thb17-bg: oklch(0.98 0.004 250);
  --color-thb17-panel: #ffffff;
  --color-thb17-panel: oklch(1 0 0);
  --color-thb17-hover: #f0f1f6;
  --color-thb17-hover: oklch(0.95 0.008 255);
  --color-thb17-edge: #d7d9e2;
  --color-thb17-edge: oklch(0.87 0.01 250);
  --color-thb17-line: #e4e5ec;
  --color-thb17-line: oklch(0.9 0.008 250);
  --color-thb17-ink: #2b2e3d;
  --color-thb17-ink: oklch(0.22 0.02 255);
  --color-thb17-mut: #5f6377;
  --color-thb17-mut: oklch(0.45 0.018 255);
  --color-thb17-acc: #3b4fb8;
  --color-thb17-acc: oklch(0.48 0.16 258);
  --color-thb17-focus: #4a5ee0;
  --color-thb17-focus: oklch(0.55 0.2 262);
  --color-thb17-pass: #2f7d55;
  --color-thb17-pass: oklch(0.5 0.14 155);
  --font-thb17-sans: 'Space Grotesk', system-ui, sans-serif;
  --font-thb17-mono: 'JetBrains Mono', ui-monospace, monospace;
  --ease-thb17: cubic-bezier(0.32, 0.72, 0.28, 1);
}
/* 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. */

.thb-17 {
  width: 100%;
  min-height: 100svh;
}
```

## JavaScript
```js
(function () {
  document.querySelectorAll('.thb-17 > .group').forEach(function (stage) {
    if (stage.dataset.bound) return; stage.dataset.bound = '1';
    var page = stage.querySelector('#thb17-page'),
        menu = stage.querySelector('#thb17-menu'),
        burger = stage.querySelector('button[aria-controls="thb17-menu"]'),
        closeBtn = stage.querySelector('button[aria-label="Close navigation"]'),
        scrim = stage.querySelector('div[aria-hidden="true"]'),
        live = stage.querySelector('[role="status"]'),
        opener = null;
    function focusables() {
      return menu.querySelectorAll('a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])');
    }
    function set(open) {
      stage.toggleAttribute('data-open', open);
      burger.setAttribute('aria-expanded', String(open));
      burger.setAttribute('aria-label', open ? 'Close navigation' : 'Open navigation');
      if (open) {
        opener = document.activeElement;
        page.setAttribute('inert', '');
        closeBtn.focus();
        live.textContent = 'Navigation opened';
      } else {
        page.removeAttribute('inert');
        (opener || burger).focus();
        live.textContent = 'Navigation closed';
      }
    }
    burger.addEventListener('click', function () { set(true); });
    closeBtn.addEventListener('click', function () { set(false); });
    scrim.addEventListener('click', function () { set(false); });
    stage.addEventListener('keydown', function (e) {
      if (!stage.hasAttribute('data-open')) return;
      if (e.key === 'Escape') { set(false); return; }
      if (e.key !== 'Tab') return;
      var f = focusables(), 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(); }
    });
  });
})();
```

How this works

A fullscreen or overlay menu IS a modal dialog, and modality is a three-part promise the script keeps:

<aside role="dialog" aria-modal="true" aria-labelledby="thb17-title">

// 1. focus moves IN on open            (2.4.3 Focus Order)
closeBtn.focus();
// 2. the page behind stops existing     (1.3.2 / 2.4.3)
page.setAttribute('inert', '');
// 3. focus RETURNS to the opener        (2.4.3 again)
(opener || burger).focus();

inert is the modern trap: one attribute makes the background unfocusable AND invisible to assistive tech — what needed fifty lines of focus-sentinel code in 2020. The belt-and-braces Tab loop stays for the edge cases inert does not cover.

The checklist, and where each line lives now. 4.1.2: a real <button> with aria-expanded, aria-controls and a name that flips. 2.1.1/2.1.2: Escape closes, Tab cycles. 2.4.7/1.4.11: outline-3 rings at 3:1 against both surfaces. 2.5.8: min-h-11 on every target. 1.4.3: link text at 15.5px/600 measures 8.9:1 on this surface. 2.3.3: motion-reduce: turns the slide into a fade.

Why the repetition is the right call here. Utilities make the accessible properties of a control local to that control. In a shared stylesheet a new menu item inherits its ring and its target size by accident; here you cannot add a control without stating them, which is the failure mode this demo exists to prevent.

Make it yours

  • The drawer slides from the right; the ARIA layer is geometry-independent, so restyle to fullscreen or bottom-sheet without touching the script.
  • The focusables query in the script is the trap's definition of &ldquo;inside&rdquo;. Extend it if you add tabindex=&quot;0&quot; widgets to the menu.
  • To downgrade to demo 02's lighter disclosure (menu does NOT cover content): drop role=dialog, aria-modal, inert and the trap; keep everything else. Rule of thumb: covers content = dialog, pushes content = disclosure.
  • Target size: min-h-11 is 44px. Enterprise desktop apps may drop to min-h-8 visual with padding keeping the 24px WCAG 2.2 floor.
  • Retheme through --color-thb17-acc and --color-thb17-focus — keep the focus token distinct from the accent so rings stay visible on tinted surfaces.

Gotchas — read before shipping

  • role=&quot;dialog&quot; without moving focus into it is WORSE than no role: aria-modal=&quot;true&quot; hides the rest of the page from screen readers while the user's focus is still out there. The role and the focus move ship together, always.
  • Never inert an ancestor of the dialog — inert is inherited, and an inert dialog is a lockout. Page content and dialog must be sibling subtrees, as they are here.
  • aria-hidden=&quot;true&quot; on the background is the legacy substitute and does NOT block keyboard focus. Prefer inert, which does both.
  • Restoring focus needs the OPENER element captured at open time, not document.activeElement at close time (that is the close button).
  • Do not consolidate the repeated focus and target utilities into a shared class &ldquo;for cleanliness&rdquo;. The repetition is what makes a new control's accessibility explicit.
  • outline-offset with a negative value (-outline-offset-3) is right for rings inside a row, but check contrast against the row's hover background, not just the panel.

Browser support

ChromeSafariFirefoxEdge
111+16.4+128+111+

inert is Chrome 102, Safari 15.5 and Firefox 112 — Baseline 2023 — and the Tab-loop fallback covers anything older. Test with NVDA + Firefox and VoiceOver + Safari; that pair catches most real issues.

Techniques used in this demo

HTML & JSno Tailwind involved

Search CodeFronts

Loading…