Basic Dropdown
A menu button with the full keyboard model - arrows, Home/End, Escape, focus restore.
6 frameworksIntermediate
A menu split into labelled sections with separators - groups that are real semantics.
<!--
Groups are semantic, not decorative. Each section is a role="group" with
aria-labelledby pointing at its own heading, so a screen reader announces
"Editing, group" before reading its items instead of running twelve unrelated
commands together in one undifferentiated list.
Two things people get wrong here:
1. The heading is aria-hidden="true" AND referenced by aria-labelledby. Not a
contradiction: aria-labelledby reads the element's text even when it is
hidden from the tree, so the group gets its name once, as a name, rather
than twice - once as the group's label and again as a stray text node.
2. The <hr> is role="separator". Its default role IS separator, but only when
it is a direct child of the menu; inside a group container browsers vary,
so it is stated. A separator is never focusable - the arrow-key ring skips
it, which is why the flat item list below is built from the groups rather
than from the DOM order.
-->
<div class="gdd">
<button
class="gdd__trigger"
type="button"
aria-haspopup="menu"
aria-expanded="false"
aria-controls="gdd-menu"
>
Document
</button>
<div class="gdd__menu" id="gdd-menu" role="menu" aria-label="Document" hidden>
<div role="group" aria-labelledby="gdd-g1">
<p class="gdd__label" id="gdd-g1" aria-hidden="true">Editing</p>
<button class="gdd__item" type="button" role="menuitem">Rename</button>
<button class="gdd__item" type="button" role="menuitem">Duplicate</button>
</div>
<hr class="gdd__sep" role="separator" />
<div role="group" aria-labelledby="gdd-g2">
<p class="gdd__label" id="gdd-g2" aria-hidden="true">Sharing</p>
<button class="gdd__item" type="button" role="menuitem">Invite people</button>
<button class="gdd__item" type="button" role="menuitem">Copy link</button>
</div>
<hr class="gdd__sep" role="separator" />
<div role="group" aria-labelledby="gdd-g3">
<p class="gdd__label" id="gdd-g3" aria-hidden="true">Danger zone</p>
<button class="gdd__item gdd__item--danger" type="button" role="menuitem">Delete</button>
</div>
</div>
</div>
<script>
document.querySelectorAll('.gdd').forEach(function (root) {
var trigger = root.querySelector('.gdd__trigger');
var menu = root.querySelector('.gdd__menu');
// Only .gdd__item - the labels and separators are not stops on the ring.
var items = Array.prototype.slice.call(root.querySelectorAll('.gdd__item'));
function setOpen(open) {
menu.hidden = !open;
trigger.setAttribute('aria-expanded', String(open));
}
function close() {
setOpen(false);
trigger.focus();
}
trigger.addEventListener('click', function () {
var next = trigger.getAttribute('aria-expanded') !== 'true';
setOpen(next);
if (next && items[0]) items[0].focus();
});
root.addEventListener('keydown', function (event) {
if (event.key === 'Escape') {
close();
return;
}
if (event.key === 'Home') {
event.preventDefault();
items[0].focus();
return;
}
if (event.key === 'End') {
event.preventDefault();
items[items.length - 1].focus();
return;
}
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
var index = items.indexOf(document.activeElement);
if (index === -1) return;
event.preventDefault();
// Arrow keys cross group boundaries without stopping. Groups organise
// the eye; they do not partition the keyboard.
var next = event.key === 'ArrowDown' ? index + 1 : index - 1;
items[(next + items.length) % items.length].focus();
});
items.forEach(function (item) {
item.addEventListener('click', close);
});
document.addEventListener('mousedown', function (event) {
if (!root.contains(event.target)) setOpen(false);
});
});
</script>| Prop | Type | Default | Description |
|---|---|---|---|
labelrequired | string | - | Accessible label announced while loading. |
itemsrequired | MenuGroup[] | - | The array of items to render. |
danger | boolean | false | Marks the item as destructive, so it reads as a warning. |
onSelect | (id: string) => void | - | Fired with the menu item the user chose. |
Each section is a `role="group"` with `aria-labelledby` pointing at its own heading, so a screen reader announces "Editing, group" instead of running twelve unrelated commands together in one flat list. The heading is `aria-hidden` AND referenced by `aria-labelledby` - not a contradiction: labelledby reads a hidden element's text, so the group is named once, as a name, rather than also announced as a stray text node inside itself. The arrow-key ring is built by flattening the GROUPS, never by querying the DOM, which would sweep up labels and separators as focus stops. Arrows cross group boundaries without pausing: groups organise the eye, they do not partition the keyboard. Keep the section labels at `text-gray-500` or darker - uppercase 11px is already hard to read; do not also make it faint.