17 / 22
All 22 in 22 Tailwind Dropdown Menus
- 01Tailwind Slide-Down Fade Dropdown Menu
- 02Tailwind Clip-Path Curtain Reveal Dropdown
- 03Tailwind 3D Perspective Flip Dropdown
- 04Tailwind Elastic Bounce Dropdown Menu
- 05Tailwind Glassmorphism Float Panel Dropdown
- 06Tailwind Stagger Children Reveal Dropdown
- 07Tailwind Mega Menu Grid Dropdown
- 08Tailwind Underline Nav Fade Panel Dropdown
- 09Tailwind Details Summary Native Dropdown
- 10Tailwind Checkbox Hack Mobile Nav Dropdown
- 11Tailwind Nested Multi-Level Dropdown
- 12Tailwind Icon Sidebar Flyout Dropdown
- 13Tailwind Split-Screen Mega Menu Dropdown
- 14Tailwind Segmented Pill Nav Dropdown
- 15Tailwind Color Swatch Picker Dropdown
- 16Tailwind Right-Click Context Menu Dropdown
- 17Tailwind Keyboard Accessible ARIA Dropdown
- 18Tailwind Stagger Blur Entrance Dropdown
- 19Tailwind Command Palette Search Dropdown
- 20Tailwind Autocomplete Suggestion Dropdown
- 21Tailwind Tab Panel Dropdown
- 22Tailwind Custom Animated Select Dropdown
Tailwind Keyboard Accessible ARIA Dropdown
The WAI-ARIA menu button, and the port's cleanest win: the open state is aria-expanded, so aria-expanded: on the button and group-has-aria-expanded: on its panel style straight off the accessibility attribute. Style and semantics cannot drift, because there is only one source of truth.
Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time
Untitled UI React · Affiliate link
The code
<section class="tdd-17 w-full min-h-svh grid place-items-center bg-linear-[135deg,var(--color-tdd17-bg1),var(--color-tdd17-bg2)] font-tdd17-sans text-tdd17-ink px-5 py-10" aria-label="Keyboard accessible ARIA dropdown demo">
<div class="flex flex-col items-center gap-5">
<div class="flex items-center gap-3">
<span class="rounded-full bg-tdd17-badge px-2.5 py-1 text-[11px] font-bold tracking-[0.04em] text-tdd17-acc">▶ Keyboard</span>
<span class="text-[12.5px] text-tdd17-mut">Try: Enter, ↑↓, Home/End, Escape</span>
</div>
<div class="group relative">
<button type="button" id="tdd17-btn" aria-haspopup="true" aria-expanded="false" aria-controls="tdd17-menu" class="flex min-h-11 cursor-pointer items-center gap-2.5 rounded-xl border border-tdd17-edge bg-white py-2.5 pr-3.5 pl-4.5 font-tdd17-sans text-sm font-semibold text-tdd17-ink shadow-[0_1px_4px_oklch(0_0_0/0.05)] transition-[box-shadow,border-color] duration-150 hover:border-tdd17-ring hover:shadow-[0_4px_16px_oklch(0_0_0/0.1)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tdd17-acc aria-expanded:border-tdd17-acc aria-expanded:shadow-[0_0_0_3px_oklch(0.6_0.13_163/0.16)]">
<span>My Account</span>
<span class="flex size-8 items-center justify-center rounded-full bg-tdd17-acc text-xs font-bold tracking-[0.04em] text-white" aria-hidden="true">JD</span>
</button>
<ul id="tdd17-menu" role="menu" aria-labelledby="tdd17-btn" class="pointer-events-none invisible absolute top-[calc(100%+8px)] right-0 min-w-50 origin-top-right -translate-y-2 scale-97 list-none rounded-xl border border-tdd17-edge bg-white p-1.5 opacity-0 shadow-[0_12px_40px_oklch(0_0_0/0.12)] transition-[opacity,translate,scale] duration-220 ease-tdd17 group-has-aria-expanded:pointer-events-auto group-has-aria-expanded:visible group-has-aria-expanded:translate-y-0 group-has-aria-expanded:scale-100 group-has-aria-expanded:opacity-100 motion-reduce:transition-none">
<li role="none"><a href="#profile" role="menuitem" tabindex="-1" class="flex min-h-11 items-center gap-2.5 rounded-lg px-3 py-2.5 text-[13.5px] font-medium text-tdd17-ink no-underline transition-colors duration-150 hover:bg-tdd17-tint hover:text-tdd17-acc focus-visible:bg-tdd17-tint focus-visible:text-tdd17-acc focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tdd17-acc"><span aria-hidden="true">👤</span>Profile</a></li>
<li role="none"><a href="#settings" role="menuitem" tabindex="-1" class="flex min-h-11 items-center gap-2.5 rounded-lg px-3 py-2.5 text-[13.5px] font-medium text-tdd17-ink no-underline transition-colors duration-150 hover:bg-tdd17-tint hover:text-tdd17-acc focus-visible:bg-tdd17-tint focus-visible:text-tdd17-acc focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tdd17-acc"><span aria-hidden="true">⚙</span>Settings</a></li>
<li role="none"><a href="#billing" role="menuitem" tabindex="-1" class="flex min-h-11 items-center gap-2.5 rounded-lg px-3 py-2.5 text-[13.5px] font-medium text-tdd17-ink no-underline transition-colors duration-150 hover:bg-tdd17-tint hover:text-tdd17-acc focus-visible:bg-tdd17-tint focus-visible:text-tdd17-acc focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tdd17-acc"><span aria-hidden="true">💲</span>Billing</a></li>
<li role="none"><a href="#upgrade" role="menuitem" tabindex="-1" class="flex min-h-11 items-center gap-2.5 rounded-lg px-3 py-2.5 text-[13.5px] font-medium text-tdd17-ink no-underline transition-colors duration-150 hover:bg-tdd17-tint hover:text-tdd17-acc focus-visible:bg-tdd17-tint focus-visible:text-tdd17-acc focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tdd17-acc"><span aria-hidden="true">🆕</span>Upgrade</a></li>
<li role="separator" class="my-1 h-px bg-tdd17-edge"></li>
<li role="none"><a href="#signout" role="menuitem" tabindex="-1" class="flex min-h-11 items-center gap-2.5 rounded-lg px-3 py-2.5 text-[13.5px] font-medium text-tdd17-danger no-underline transition-colors duration-150 hover:bg-tdd17-dangerbg focus-visible:bg-tdd17-dangerbg focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tdd17-danger"><span aria-hidden="true">🔓</span>Sign Out</a></li>
</ul>
</div>
<p class="text-xs italic text-tdd17-mut">Styled straight off aria-expanded — no second state to keep in sync</p>
</div>
</section><section class="tdd-17 w-full min-h-svh grid place-items-center bg-linear-[135deg,var(--color-tdd17-bg1),var(--color-tdd17-bg2)] font-tdd17-sans text-tdd17-ink px-5 py-10" aria-label="Keyboard accessible ARIA dropdown demo">
<div class="flex flex-col items-center gap-5">
<div class="flex items-center gap-3">
<span class="rounded-full bg-tdd17-badge px-2.5 py-1 text-[11px] font-bold tracking-[0.04em] text-tdd17-acc">▶ Keyboard</span>
<span class="text-[12.5px] text-tdd17-mut">Try: Enter, ↑↓, Home/End, Escape</span>
</div>
<div class="group relative">
<button type="button" id="tdd17-btn" aria-haspopup="true" aria-expanded="false" aria-controls="tdd17-menu" class="flex min-h-11 cursor-pointer items-center gap-2.5 rounded-xl border border-tdd17-edge bg-white py-2.5 pr-3.5 pl-4.5 font-tdd17-sans text-sm font-semibold text-tdd17-ink shadow-[0_1px_4px_oklch(0_0_0/0.05)] transition-[box-shadow,border-color] duration-150 hover:border-tdd17-ring hover:shadow-[0_4px_16px_oklch(0_0_0/0.1)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tdd17-acc aria-expanded:border-tdd17-acc aria-expanded:shadow-[0_0_0_3px_oklch(0.6_0.13_163/0.16)]">
<span>My Account</span>
<span class="flex size-8 items-center justify-center rounded-full bg-tdd17-acc text-xs font-bold tracking-[0.04em] text-white" aria-hidden="true">JD</span>
</button>
<ul id="tdd17-menu" role="menu" aria-labelledby="tdd17-btn" class="pointer-events-none invisible absolute top-[calc(100%+8px)] right-0 min-w-50 origin-top-right -translate-y-2 scale-97 list-none rounded-xl border border-tdd17-edge bg-white p-1.5 opacity-0 shadow-[0_12px_40px_oklch(0_0_0/0.12)] transition-[opacity,translate,scale] duration-220 ease-tdd17 group-has-aria-expanded:pointer-events-auto group-has-aria-expanded:visible group-has-aria-expanded:translate-y-0 group-has-aria-expanded:scale-100 group-has-aria-expanded:opacity-100 motion-reduce:transition-none">
<li role="none"><a href="#profile" role="menuitem" tabindex="-1" class="flex min-h-11 items-center gap-2.5 rounded-lg px-3 py-2.5 text-[13.5px] font-medium text-tdd17-ink no-underline transition-colors duration-150 hover:bg-tdd17-tint hover:text-tdd17-acc focus-visible:bg-tdd17-tint focus-visible:text-tdd17-acc focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tdd17-acc"><span aria-hidden="true">👤</span>Profile</a></li>
<li role="none"><a href="#settings" role="menuitem" tabindex="-1" class="flex min-h-11 items-center gap-2.5 rounded-lg px-3 py-2.5 text-[13.5px] font-medium text-tdd17-ink no-underline transition-colors duration-150 hover:bg-tdd17-tint hover:text-tdd17-acc focus-visible:bg-tdd17-tint focus-visible:text-tdd17-acc focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tdd17-acc"><span aria-hidden="true">⚙</span>Settings</a></li>
<li role="none"><a href="#billing" role="menuitem" tabindex="-1" class="flex min-h-11 items-center gap-2.5 rounded-lg px-3 py-2.5 text-[13.5px] font-medium text-tdd17-ink no-underline transition-colors duration-150 hover:bg-tdd17-tint hover:text-tdd17-acc focus-visible:bg-tdd17-tint focus-visible:text-tdd17-acc focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tdd17-acc"><span aria-hidden="true">💲</span>Billing</a></li>
<li role="none"><a href="#upgrade" role="menuitem" tabindex="-1" class="flex min-h-11 items-center gap-2.5 rounded-lg px-3 py-2.5 text-[13.5px] font-medium text-tdd17-ink no-underline transition-colors duration-150 hover:bg-tdd17-tint hover:text-tdd17-acc focus-visible:bg-tdd17-tint focus-visible:text-tdd17-acc focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tdd17-acc"><span aria-hidden="true">🆕</span>Upgrade</a></li>
<li role="separator" class="my-1 h-px bg-tdd17-edge"></li>
<li role="none"><a href="#signout" role="menuitem" tabindex="-1" class="flex min-h-11 items-center gap-2.5 rounded-lg px-3 py-2.5 text-[13.5px] font-medium text-tdd17-danger no-underline transition-colors duration-150 hover:bg-tdd17-dangerbg focus-visible:bg-tdd17-dangerbg focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tdd17-danger"><span aria-hidden="true">🔓</span>Sign Out</a></li>
</ul>
</div>
<p class="text-xs italic text-tdd17-mut">Styled straight off aria-expanded — no second state to keep in sync</p>
</div>
</section>@import "tailwindcss";
@theme {
--color-tdd17-bg1: #f0fdf4;
--color-tdd17-bg1: oklch(0.98 0.02 150);
--color-tdd17-bg2: #ecfdf5;
--color-tdd17-bg2: oklch(0.98 0.02 165);
--color-tdd17-ink: #111827;
--color-tdd17-ink: oklch(0.21 0.02 265);
--color-tdd17-mut: #6b7280;
--color-tdd17-mut: oklch(0.55 0.02 265);
--color-tdd17-edge: #e5e7eb;
--color-tdd17-edge: oklch(0.91 0.005 265);
--color-tdd17-ring: #6ee7b7;
--color-tdd17-ring: oklch(0.85 0.13 165);
--color-tdd17-badge: #d1fae5;
--color-tdd17-badge: oklch(0.92 0.06 165);
--color-tdd17-tint: #f0fdf4;
--color-tdd17-tint: oklch(0.98 0.02 150);
--color-tdd17-acc: #059669;
--color-tdd17-acc: oklch(0.6 0.13 163);
--color-tdd17-danger: #ef4444;
--color-tdd17-danger: oklch(0.63 0.22 25);
--color-tdd17-dangerbg: #fef2f2;
--color-tdd17-dangerbg: oklch(0.97 0.01 20);
--font-tdd17-sans: 'Inter', system-ui, sans-serif;
--ease-tdd17: cubic-bezier(0.16, 1, 0.3, 1);
}
.tdd-17 {
width: 100%;
min-height: 100svh;
}
.tdd-17 ::selection {
background: var(--color-tdd17-acc);
color: #ecfdf5;
}@import "tailwindcss";
@theme {
--color-tdd17-bg1: #f0fdf4;
--color-tdd17-bg1: oklch(0.98 0.02 150);
--color-tdd17-bg2: #ecfdf5;
--color-tdd17-bg2: oklch(0.98 0.02 165);
--color-tdd17-ink: #111827;
--color-tdd17-ink: oklch(0.21 0.02 265);
--color-tdd17-mut: #6b7280;
--color-tdd17-mut: oklch(0.55 0.02 265);
--color-tdd17-edge: #e5e7eb;
--color-tdd17-edge: oklch(0.91 0.005 265);
--color-tdd17-ring: #6ee7b7;
--color-tdd17-ring: oklch(0.85 0.13 165);
--color-tdd17-badge: #d1fae5;
--color-tdd17-badge: oklch(0.92 0.06 165);
--color-tdd17-tint: #f0fdf4;
--color-tdd17-tint: oklch(0.98 0.02 150);
--color-tdd17-acc: #059669;
--color-tdd17-acc: oklch(0.6 0.13 163);
--color-tdd17-danger: #ef4444;
--color-tdd17-danger: oklch(0.63 0.22 25);
--color-tdd17-dangerbg: #fef2f2;
--color-tdd17-dangerbg: oklch(0.97 0.01 20);
--font-tdd17-sans: 'Inter', system-ui, sans-serif;
--ease-tdd17: cubic-bezier(0.16, 1, 0.3, 1);
}
.tdd-17 {
width: 100%;
min-height: 100svh;
}
.tdd-17 ::selection {
background: var(--color-tdd17-acc);
color: #ecfdf5;
}(function () {
document.querySelectorAll('.tdd-17').forEach(function (root) {
if (root.dataset.bound) return; root.dataset.bound = '1';
var btn = root.querySelector('#tdd17-btn'),
menu = root.querySelector('#tdd17-menu');
if (!btn || !menu) return;
var items = function () { return Array.from(menu.querySelectorAll('[role="menuitem"]')); };
var isOpen = function () { return btn.getAttribute('aria-expanded') === 'true'; };
function set(open, focusFirst) {
btn.setAttribute('aria-expanded', String(open));
if (open && focusFirst) { var i = items(); if (i.length) i[0].focus(); }
}
btn.addEventListener('click', function () { set(!isOpen(), true); });
btn.addEventListener('keydown', function (e) {
if ((e.key === 'Enter' || e.key === ' ') && !isOpen()) { e.preventDefault(); set(true, true); }
else if (e.key === 'Escape' && isOpen()) set(false);
});
menu.addEventListener('keydown', function (e) {
var list = items(), idx = list.indexOf(document.activeElement);
if (e.key === 'ArrowDown') { e.preventDefault(); list[(idx + 1) % list.length].focus(); }
else if (e.key === 'ArrowUp') { e.preventDefault(); list[(idx - 1 + list.length) % list.length].focus(); }
else if (e.key === 'Home') { e.preventDefault(); list[0].focus(); }
else if (e.key === 'End') { e.preventDefault(); list[list.length - 1].focus(); }
else if (e.key === 'Escape') { set(false); btn.focus(); }
else if (e.key === 'Tab') set(false);
});
document.addEventListener('mousedown', function (e) {
if (!btn.contains(e.target) && !menu.contains(e.target)) set(false);
});
});
})();(function () {
document.querySelectorAll('.tdd-17').forEach(function (root) {
if (root.dataset.bound) return; root.dataset.bound = '1';
var btn = root.querySelector('#tdd17-btn'),
menu = root.querySelector('#tdd17-menu');
if (!btn || !menu) return;
var items = function () { return Array.from(menu.querySelectorAll('[role="menuitem"]')); };
var isOpen = function () { return btn.getAttribute('aria-expanded') === 'true'; };
function set(open, focusFirst) {
btn.setAttribute('aria-expanded', String(open));
if (open && focusFirst) { var i = items(); if (i.length) i[0].focus(); }
}
btn.addEventListener('click', function () { set(!isOpen(), true); });
btn.addEventListener('keydown', function (e) {
if ((e.key === 'Enter' || e.key === ' ') && !isOpen()) { e.preventDefault(); set(true, true); }
else if (e.key === 'Escape' && isOpen()) set(false);
});
menu.addEventListener('keydown', function (e) {
var list = items(), idx = list.indexOf(document.activeElement);
if (e.key === 'ArrowDown') { e.preventDefault(); list[(idx + 1) % list.length].focus(); }
else if (e.key === 'ArrowUp') { e.preventDefault(); list[(idx - 1 + list.length) % list.length].focus(); }
else if (e.key === 'Home') { e.preventDefault(); list[0].focus(); }
else if (e.key === 'End') { e.preventDefault(); list[list.length - 1].focus(); }
else if (e.key === 'Escape') { set(false); btn.focus(); }
else if (e.key === 'Tab') set(false);
});
document.addEventListener('mousedown', function (e) {
if (!btn.contains(e.target) && !menu.contains(e.target)) set(false);
});
});
})();Setup — do this once per project
Skip this if your project already runs Tailwind v4.
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>Compiles in the browser — perfect for a CodePen or a quick test, not for production.
npm install tailwindcss @tailwindcss/vite
npm install tailwindcss @tailwindcss/vite// vite.config.js ("type": "module" in package.json)
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss()],
})// vite.config.js ("type": "module" in package.json)
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss()],
})Any v4 setup works — Vite, PostCSS or the CLI. Nothing in this demo is bundler-specific.
@import "tailwindcss";
@theme {
--color-tdd17-bg1: #f0fdf4;
--color-tdd17-bg1: oklch(0.98 0.02 150);
--color-tdd17-bg2: #ecfdf5;
--color-tdd17-bg2: oklch(0.98 0.02 165);
--color-tdd17-ink: #111827;
--color-tdd17-ink: oklch(0.21 0.02 265);
--color-tdd17-mut: #6b7280;
--color-tdd17-mut: oklch(0.55 0.02 265);
--color-tdd17-edge: #e5e7eb;
--color-tdd17-edge: oklch(0.91 0.005 265);
--color-tdd17-ring: #6ee7b7;
--color-tdd17-ring: oklch(0.85 0.13 165);
--color-tdd17-badge: #d1fae5;
--color-tdd17-badge: oklch(0.92 0.06 165);
--color-tdd17-tint: #f0fdf4;
--color-tdd17-tint: oklch(0.98 0.02 150);
--color-tdd17-acc: #059669;
--color-tdd17-acc: oklch(0.6 0.13 163);
--color-tdd17-danger: #ef4444;
--color-tdd17-danger: oklch(0.63 0.22 25);
--color-tdd17-dangerbg: #fef2f2;
--color-tdd17-dangerbg: oklch(0.97 0.01 20);
--font-tdd17-sans: 'Inter', system-ui, sans-serif;
--ease-tdd17: cubic-bezier(0.16, 1, 0.3, 1);
}
.tdd-17 {
width: 100%;
min-height: 100svh;
}
.tdd-17 ::selection {
background: var(--color-tdd17-acc);
color: #ecfdf5;
}@import "tailwindcss";
@theme {
--color-tdd17-bg1: #f0fdf4;
--color-tdd17-bg1: oklch(0.98 0.02 150);
--color-tdd17-bg2: #ecfdf5;
--color-tdd17-bg2: oklch(0.98 0.02 165);
--color-tdd17-ink: #111827;
--color-tdd17-ink: oklch(0.21 0.02 265);
--color-tdd17-mut: #6b7280;
--color-tdd17-mut: oklch(0.55 0.02 265);
--color-tdd17-edge: #e5e7eb;
--color-tdd17-edge: oklch(0.91 0.005 265);
--color-tdd17-ring: #6ee7b7;
--color-tdd17-ring: oklch(0.85 0.13 165);
--color-tdd17-badge: #d1fae5;
--color-tdd17-badge: oklch(0.92 0.06 165);
--color-tdd17-tint: #f0fdf4;
--color-tdd17-tint: oklch(0.98 0.02 150);
--color-tdd17-acc: #059669;
--color-tdd17-acc: oklch(0.6 0.13 163);
--color-tdd17-danger: #ef4444;
--color-tdd17-danger: oklch(0.63 0.22 25);
--color-tdd17-dangerbg: #fef2f2;
--color-tdd17-dangerbg: oklch(0.97 0.01 20);
--font-tdd17-sans: 'Inter', system-ui, sans-serif;
--ease-tdd17: cubic-bezier(0.16, 1, 0.3, 1);
}
.tdd-17 {
width: 100%;
min-height: 100svh;
}
.tdd-17 ::selection {
background: var(--color-tdd17-acc);
color: #ecfdf5;
}<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=Inter:wght@400;500;600&display=swap">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap">What changed in the port
Which parts became utilities, which stayed CSS, and why.
- The port's strongest argument: with
aria-expanded:andgroup-has-aria-expanded:there is exactly one state, and it is the one assistive technology reads. invisiblein the closed state is load-bearing —opacity-0alone leaves a tabbable menu nobody can see.- The script sets one attribute and moves focus. It touches no classes and no styles, so a redesign never reopens it.
min-h-11everywhere is the 44px target rule expressed in the spacing scale rather than as a comment in a stylesheet.
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 Keyboard Accessible ARIA Dropdown
Source: https://codefronts.com/navigation/tailwind-dropdown-menus/tailwind-keyboard-accessible-aria-dropdown/
The WAI-ARIA menu button, and the port's cleanest win: the open state is aria-expanded, so aria-expanded: on the button and group-has-aria-expanded: on its panel style straight off the accessibility attribute. Style and semantics cannot drift, because there is only one source of truth.
## HTML
```html
<section class="tdd-17 w-full min-h-svh grid place-items-center bg-linear-[135deg,var(--color-tdd17-bg1),var(--color-tdd17-bg2)] font-tdd17-sans text-tdd17-ink px-5 py-10" aria-label="Keyboard accessible ARIA dropdown demo">
<div class="flex flex-col items-center gap-5">
<div class="flex items-center gap-3">
<span class="rounded-full bg-tdd17-badge px-2.5 py-1 text-[11px] font-bold tracking-[0.04em] text-tdd17-acc">▶ Keyboard</span>
<span class="text-[12.5px] text-tdd17-mut">Try: Enter, ↑↓, Home/End, Escape</span>
</div>
<div class="group relative">
<button type="button" id="tdd17-btn" aria-haspopup="true" aria-expanded="false" aria-controls="tdd17-menu" class="flex min-h-11 cursor-pointer items-center gap-2.5 rounded-xl border border-tdd17-edge bg-white py-2.5 pr-3.5 pl-4.5 font-tdd17-sans text-sm font-semibold text-tdd17-ink shadow-[0_1px_4px_oklch(0_0_0/0.05)] transition-[box-shadow,border-color] duration-150 hover:border-tdd17-ring hover:shadow-[0_4px_16px_oklch(0_0_0/0.1)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tdd17-acc aria-expanded:border-tdd17-acc aria-expanded:shadow-[0_0_0_3px_oklch(0.6_0.13_163/0.16)]">
<span>My Account</span>
<span class="flex size-8 items-center justify-center rounded-full bg-tdd17-acc text-xs font-bold tracking-[0.04em] text-white" aria-hidden="true">JD</span>
</button>
<ul id="tdd17-menu" role="menu" aria-labelledby="tdd17-btn" class="pointer-events-none invisible absolute top-[calc(100%+8px)] right-0 min-w-50 origin-top-right -translate-y-2 scale-97 list-none rounded-xl border border-tdd17-edge bg-white p-1.5 opacity-0 shadow-[0_12px_40px_oklch(0_0_0/0.12)] transition-[opacity,translate,scale] duration-220 ease-tdd17 group-has-aria-expanded:pointer-events-auto group-has-aria-expanded:visible group-has-aria-expanded:translate-y-0 group-has-aria-expanded:scale-100 group-has-aria-expanded:opacity-100 motion-reduce:transition-none">
<li role="none"><a href="#profile" role="menuitem" tabindex="-1" class="flex min-h-11 items-center gap-2.5 rounded-lg px-3 py-2.5 text-[13.5px] font-medium text-tdd17-ink no-underline transition-colors duration-150 hover:bg-tdd17-tint hover:text-tdd17-acc focus-visible:bg-tdd17-tint focus-visible:text-tdd17-acc focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tdd17-acc"><span aria-hidden="true">👤</span>Profile</a></li>
<li role="none"><a href="#settings" role="menuitem" tabindex="-1" class="flex min-h-11 items-center gap-2.5 rounded-lg px-3 py-2.5 text-[13.5px] font-medium text-tdd17-ink no-underline transition-colors duration-150 hover:bg-tdd17-tint hover:text-tdd17-acc focus-visible:bg-tdd17-tint focus-visible:text-tdd17-acc focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tdd17-acc"><span aria-hidden="true">⚙</span>Settings</a></li>
<li role="none"><a href="#billing" role="menuitem" tabindex="-1" class="flex min-h-11 items-center gap-2.5 rounded-lg px-3 py-2.5 text-[13.5px] font-medium text-tdd17-ink no-underline transition-colors duration-150 hover:bg-tdd17-tint hover:text-tdd17-acc focus-visible:bg-tdd17-tint focus-visible:text-tdd17-acc focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tdd17-acc"><span aria-hidden="true">💲</span>Billing</a></li>
<li role="none"><a href="#upgrade" role="menuitem" tabindex="-1" class="flex min-h-11 items-center gap-2.5 rounded-lg px-3 py-2.5 text-[13.5px] font-medium text-tdd17-ink no-underline transition-colors duration-150 hover:bg-tdd17-tint hover:text-tdd17-acc focus-visible:bg-tdd17-tint focus-visible:text-tdd17-acc focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tdd17-acc"><span aria-hidden="true">🆕</span>Upgrade</a></li>
<li role="separator" class="my-1 h-px bg-tdd17-edge"></li>
<li role="none"><a href="#signout" role="menuitem" tabindex="-1" class="flex min-h-11 items-center gap-2.5 rounded-lg px-3 py-2.5 text-[13.5px] font-medium text-tdd17-danger no-underline transition-colors duration-150 hover:bg-tdd17-dangerbg focus-visible:bg-tdd17-dangerbg focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tdd17-danger"><span aria-hidden="true">🔓</span>Sign Out</a></li>
</ul>
</div>
<p class="text-xs italic text-tdd17-mut">Styled straight off aria-expanded — no second state to keep in sync</p>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-tdd17-bg1: #f0fdf4;
--color-tdd17-bg1: oklch(0.98 0.02 150);
--color-tdd17-bg2: #ecfdf5;
--color-tdd17-bg2: oklch(0.98 0.02 165);
--color-tdd17-ink: #111827;
--color-tdd17-ink: oklch(0.21 0.02 265);
--color-tdd17-mut: #6b7280;
--color-tdd17-mut: oklch(0.55 0.02 265);
--color-tdd17-edge: #e5e7eb;
--color-tdd17-edge: oklch(0.91 0.005 265);
--color-tdd17-ring: #6ee7b7;
--color-tdd17-ring: oklch(0.85 0.13 165);
--color-tdd17-badge: #d1fae5;
--color-tdd17-badge: oklch(0.92 0.06 165);
--color-tdd17-tint: #f0fdf4;
--color-tdd17-tint: oklch(0.98 0.02 150);
--color-tdd17-acc: #059669;
--color-tdd17-acc: oklch(0.6 0.13 163);
--color-tdd17-danger: #ef4444;
--color-tdd17-danger: oklch(0.63 0.22 25);
--color-tdd17-dangerbg: #fef2f2;
--color-tdd17-dangerbg: oklch(0.97 0.01 20);
--font-tdd17-sans: 'Inter', system-ui, sans-serif;
--ease-tdd17: cubic-bezier(0.16, 1, 0.3, 1);
}
.tdd-17 {
width: 100%;
min-height: 100svh;
}
.tdd-17 ::selection {
background: var(--color-tdd17-acc);
color: #ecfdf5;
}
```
## JavaScript
```js
(function () {
document.querySelectorAll('.tdd-17').forEach(function (root) {
if (root.dataset.bound) return; root.dataset.bound = '1';
var btn = root.querySelector('#tdd17-btn'),
menu = root.querySelector('#tdd17-menu');
if (!btn || !menu) return;
var items = function () { return Array.from(menu.querySelectorAll('[role="menuitem"]')); };
var isOpen = function () { return btn.getAttribute('aria-expanded') === 'true'; };
function set(open, focusFirst) {
btn.setAttribute('aria-expanded', String(open));
if (open && focusFirst) { var i = items(); if (i.length) i[0].focus(); }
}
btn.addEventListener('click', function () { set(!isOpen(), true); });
btn.addEventListener('keydown', function (e) {
if ((e.key === 'Enter' || e.key === ' ') && !isOpen()) { e.preventDefault(); set(true, true); }
else if (e.key === 'Escape' && isOpen()) set(false);
});
menu.addEventListener('keydown', function (e) {
var list = items(), idx = list.indexOf(document.activeElement);
if (e.key === 'ArrowDown') { e.preventDefault(); list[(idx + 1) % list.length].focus(); }
else if (e.key === 'ArrowUp') { e.preventDefault(); list[(idx - 1 + list.length) % list.length].focus(); }
else if (e.key === 'Home') { e.preventDefault(); list[0].focus(); }
else if (e.key === 'End') { e.preventDefault(); list[list.length - 1].focus(); }
else if (e.key === 'Escape') { set(false); btn.focus(); }
else if (e.key === 'Tab') set(false);
});
document.addEventListener('mousedown', function (e) {
if (!btn.contains(e.target) && !menu.contains(e.target)) set(false);
});
});
})();
```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 Keyboard Accessible ARIA Dropdown
Source: https://codefronts.com/navigation/tailwind-dropdown-menus/tailwind-keyboard-accessible-aria-dropdown/
The WAI-ARIA menu button, and the port's cleanest win: the open state is aria-expanded, so aria-expanded: on the button and group-has-aria-expanded: on its panel style straight off the accessibility attribute. Style and semantics cannot drift, because there is only one source of truth.
## HTML
```html
<section class="tdd-17 w-full min-h-svh grid place-items-center bg-linear-[135deg,var(--color-tdd17-bg1),var(--color-tdd17-bg2)] font-tdd17-sans text-tdd17-ink px-5 py-10" aria-label="Keyboard accessible ARIA dropdown demo">
<div class="flex flex-col items-center gap-5">
<div class="flex items-center gap-3">
<span class="rounded-full bg-tdd17-badge px-2.5 py-1 text-[11px] font-bold tracking-[0.04em] text-tdd17-acc">▶ Keyboard</span>
<span class="text-[12.5px] text-tdd17-mut">Try: Enter, ↑↓, Home/End, Escape</span>
</div>
<div class="group relative">
<button type="button" id="tdd17-btn" aria-haspopup="true" aria-expanded="false" aria-controls="tdd17-menu" class="flex min-h-11 cursor-pointer items-center gap-2.5 rounded-xl border border-tdd17-edge bg-white py-2.5 pr-3.5 pl-4.5 font-tdd17-sans text-sm font-semibold text-tdd17-ink shadow-[0_1px_4px_oklch(0_0_0/0.05)] transition-[box-shadow,border-color] duration-150 hover:border-tdd17-ring hover:shadow-[0_4px_16px_oklch(0_0_0/0.1)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tdd17-acc aria-expanded:border-tdd17-acc aria-expanded:shadow-[0_0_0_3px_oklch(0.6_0.13_163/0.16)]">
<span>My Account</span>
<span class="flex size-8 items-center justify-center rounded-full bg-tdd17-acc text-xs font-bold tracking-[0.04em] text-white" aria-hidden="true">JD</span>
</button>
<ul id="tdd17-menu" role="menu" aria-labelledby="tdd17-btn" class="pointer-events-none invisible absolute top-[calc(100%+8px)] right-0 min-w-50 origin-top-right -translate-y-2 scale-97 list-none rounded-xl border border-tdd17-edge bg-white p-1.5 opacity-0 shadow-[0_12px_40px_oklch(0_0_0/0.12)] transition-[opacity,translate,scale] duration-220 ease-tdd17 group-has-aria-expanded:pointer-events-auto group-has-aria-expanded:visible group-has-aria-expanded:translate-y-0 group-has-aria-expanded:scale-100 group-has-aria-expanded:opacity-100 motion-reduce:transition-none">
<li role="none"><a href="#profile" role="menuitem" tabindex="-1" class="flex min-h-11 items-center gap-2.5 rounded-lg px-3 py-2.5 text-[13.5px] font-medium text-tdd17-ink no-underline transition-colors duration-150 hover:bg-tdd17-tint hover:text-tdd17-acc focus-visible:bg-tdd17-tint focus-visible:text-tdd17-acc focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tdd17-acc"><span aria-hidden="true">👤</span>Profile</a></li>
<li role="none"><a href="#settings" role="menuitem" tabindex="-1" class="flex min-h-11 items-center gap-2.5 rounded-lg px-3 py-2.5 text-[13.5px] font-medium text-tdd17-ink no-underline transition-colors duration-150 hover:bg-tdd17-tint hover:text-tdd17-acc focus-visible:bg-tdd17-tint focus-visible:text-tdd17-acc focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tdd17-acc"><span aria-hidden="true">⚙</span>Settings</a></li>
<li role="none"><a href="#billing" role="menuitem" tabindex="-1" class="flex min-h-11 items-center gap-2.5 rounded-lg px-3 py-2.5 text-[13.5px] font-medium text-tdd17-ink no-underline transition-colors duration-150 hover:bg-tdd17-tint hover:text-tdd17-acc focus-visible:bg-tdd17-tint focus-visible:text-tdd17-acc focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tdd17-acc"><span aria-hidden="true">💲</span>Billing</a></li>
<li role="none"><a href="#upgrade" role="menuitem" tabindex="-1" class="flex min-h-11 items-center gap-2.5 rounded-lg px-3 py-2.5 text-[13.5px] font-medium text-tdd17-ink no-underline transition-colors duration-150 hover:bg-tdd17-tint hover:text-tdd17-acc focus-visible:bg-tdd17-tint focus-visible:text-tdd17-acc focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tdd17-acc"><span aria-hidden="true">🆕</span>Upgrade</a></li>
<li role="separator" class="my-1 h-px bg-tdd17-edge"></li>
<li role="none"><a href="#signout" role="menuitem" tabindex="-1" class="flex min-h-11 items-center gap-2.5 rounded-lg px-3 py-2.5 text-[13.5px] font-medium text-tdd17-danger no-underline transition-colors duration-150 hover:bg-tdd17-dangerbg focus-visible:bg-tdd17-dangerbg focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tdd17-danger"><span aria-hidden="true">🔓</span>Sign Out</a></li>
</ul>
</div>
<p class="text-xs italic text-tdd17-mut">Styled straight off aria-expanded — no second state to keep in sync</p>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-tdd17-bg1: #f0fdf4;
--color-tdd17-bg1: oklch(0.98 0.02 150);
--color-tdd17-bg2: #ecfdf5;
--color-tdd17-bg2: oklch(0.98 0.02 165);
--color-tdd17-ink: #111827;
--color-tdd17-ink: oklch(0.21 0.02 265);
--color-tdd17-mut: #6b7280;
--color-tdd17-mut: oklch(0.55 0.02 265);
--color-tdd17-edge: #e5e7eb;
--color-tdd17-edge: oklch(0.91 0.005 265);
--color-tdd17-ring: #6ee7b7;
--color-tdd17-ring: oklch(0.85 0.13 165);
--color-tdd17-badge: #d1fae5;
--color-tdd17-badge: oklch(0.92 0.06 165);
--color-tdd17-tint: #f0fdf4;
--color-tdd17-tint: oklch(0.98 0.02 150);
--color-tdd17-acc: #059669;
--color-tdd17-acc: oklch(0.6 0.13 163);
--color-tdd17-danger: #ef4444;
--color-tdd17-danger: oklch(0.63 0.22 25);
--color-tdd17-dangerbg: #fef2f2;
--color-tdd17-dangerbg: oklch(0.97 0.01 20);
--font-tdd17-sans: 'Inter', system-ui, sans-serif;
--ease-tdd17: cubic-bezier(0.16, 1, 0.3, 1);
}
.tdd-17 {
width: 100%;
min-height: 100svh;
}
.tdd-17 ::selection {
background: var(--color-tdd17-acc);
color: #ecfdf5;
}
```
## JavaScript
```js
(function () {
document.querySelectorAll('.tdd-17').forEach(function (root) {
if (root.dataset.bound) return; root.dataset.bound = '1';
var btn = root.querySelector('#tdd17-btn'),
menu = root.querySelector('#tdd17-menu');
if (!btn || !menu) return;
var items = function () { return Array.from(menu.querySelectorAll('[role="menuitem"]')); };
var isOpen = function () { return btn.getAttribute('aria-expanded') === 'true'; };
function set(open, focusFirst) {
btn.setAttribute('aria-expanded', String(open));
if (open && focusFirst) { var i = items(); if (i.length) i[0].focus(); }
}
btn.addEventListener('click', function () { set(!isOpen(), true); });
btn.addEventListener('keydown', function (e) {
if ((e.key === 'Enter' || e.key === ' ') && !isOpen()) { e.preventDefault(); set(true, true); }
else if (e.key === 'Escape' && isOpen()) set(false);
});
menu.addEventListener('keydown', function (e) {
var list = items(), idx = list.indexOf(document.activeElement);
if (e.key === 'ArrowDown') { e.preventDefault(); list[(idx + 1) % list.length].focus(); }
else if (e.key === 'ArrowUp') { e.preventDefault(); list[(idx - 1 + list.length) % list.length].focus(); }
else if (e.key === 'Home') { e.preventDefault(); list[0].focus(); }
else if (e.key === 'End') { e.preventDefault(); list[list.length - 1].focus(); }
else if (e.key === 'Escape') { set(false); btn.focus(); }
else if (e.key === 'Tab') set(false);
});
document.addEventListener('mousedown', function (e) {
if (!btn.contains(e.target) && !menu.contains(e.target)) set(false);
});
});
})();
```How this works
One attribute, two readers. Screen readers already read aria-expanded; now CSS does too:
<div class="group">
<button aria-expanded="false" aria-controls="tdd17-menu">…</button>
<ul class="opacity-0 group-has-aria-expanded:opacity-100">There is no second state to keep in sync, which removes an entire class of bugs where the menu looks open and announces closed.
Keyboard behaviour is the point. Enter/Space opens and focuses the first item, Arrow keys move with wrap, Home/End jump, Escape closes and returns focus to the button, Tab closes and moves on naturally.
The panel is unfocusable while closed via invisible, not just transparent — a transparent menu that Tab can enter is a WCAG 2.4.7 failure.
Make it yours
- Delete the Arrow handlers and it is still a compliant disclosure — arrows are enhancement, Tab is the baseline.
- Fade instead of pop: swap the
scale-97/scale-100pair for a translate. - Retheme with
--color-tdd17-acc; the avatar, ring and hover tint all read it. - Typeahead: buffer keystrokes in the existing keydown handler and focus the first matching item.
Gotchas — read before shipping
group-has-aria-expanded:needsgroupon the common ancestor of the button and the panel, not on the section.aria-expandedbelongs on the button, never on the panel — it describes the control's state.- Never
role="menu"/menuitemon ordinary site navigation; it promises application keyboard semantics. This demo is an account menu, which is the case where the role is correct. - If you move focus into the panel on open, you must return it on Escape or it is a focus-order failure.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 111+ | 16.4+ | 128+ | 111+ |
:has() — what group-has-* compiles to — is Baseline 2023; the ARIA pattern itself works everywhere.