UI Components Medium
Keyboard Navigable Menu
Menu component fully navigable by keyboard using arrow keys, Home, End and type-ahead search following WAI-ARIA menu pattern.
Open in Lab
MCP
vanilla-js
Targets: JS HTML
Code
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: #0a0a0a;
color: #e5e5e5;
min-height: 100vh;
display: flex;
justify-content: center;
padding: 2rem 1rem;
}
@media (min-width: 480px) {
body {
padding: 3rem 1.5rem;
}
}
.demo-wrapper {
width: 100%;
max-width: 720px;
}
.demo-title {
font-size: clamp(1.25rem, 5vw, 1.75rem);
font-weight: 700;
color: #f5f5f5;
margin-bottom: 0.375rem;
}
.demo-subtitle {
font-size: 0.9rem;
color: #888;
margin-bottom: 2.5rem;
line-height: 1.5;
}
/* โโ Menubar โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
.menubar-container {
position: relative;
margin-bottom: 2rem;
overflow-x: auto;
/* smooth scrolling on iOS */
-webkit-overflow-scrolling: touch;
/* hide scrollbar but keep functionality */
scrollbar-width: none;
}
.menubar-container::-webkit-scrollbar {
display: none;
}
.menubar {
display: flex;
list-style: none;
background: #161616;
border: 1px solid #262626;
border-radius: 10px;
padding: 4px;
gap: 2px;
width: max-content;
min-width: 100%;
}
.menubar-item {
position: relative;
}
.menubar-button {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
background: transparent;
border: none;
border-radius: 7px;
color: #b0b0b0;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, color 0.15s;
white-space: nowrap;
}
.menubar-button:hover {
background: #222;
color: #e5e5e5;
}
.menubar-button:focus-visible {
outline: 2px solid #6366f1;
outline-offset: -2px;
background: #1e1e2e;
color: #f5f5f5;
}
.menubar-button[aria-expanded="true"] {
background: #1e1e2e;
color: #f5f5f5;
}
.menubar-button[aria-expanded="true"] .chevron {
transform: rotate(180deg);
}
.chevron {
transition: transform 0.2s ease;
flex-shrink: 0;
}
/* โโ Submenu โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
.submenu {
position: absolute;
top: calc(100% + 6px);
left: 0;
list-style: none;
background: #161616;
border: 1px solid #2a2a2a;
border-radius: 10px;
padding: 4px;
min-width: 200px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
opacity: 0;
visibility: hidden;
transform: translateY(-4px);
transition: opacity 0.15s, transform 0.15s, visibility 0.15s;
z-index: 100;
}
.submenu.open {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.submenu-item {
padding: 8px 14px;
border-radius: 7px;
font-size: 0.85rem;
color: #c0c0c0;
cursor: pointer;
transition: background 0.12s, color 0.12s;
white-space: nowrap;
}
.submenu-item:hover {
background: #222;
color: #f5f5f5;
}
.submenu-item:focus-visible,
.submenu-item.focused {
outline: none;
background: #2d2d5e;
color: #f5f5f5;
}
.submenu-separator {
height: 1px;
background: #262626;
margin: 4px 8px;
}
/* โโ Action Log โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
.action-log {
background: #111;
border: 1px solid #222;
border-radius: 10px;
padding: 16px 20px;
min-height: 60px;
margin-bottom: 2.5rem;
font-size: 0.85rem;
color: #888;
}
.action-log .log-entry {
color: #a5b4fc;
padding: 2px 0;
}
.action-log .log-entry::before {
content: "\25B8 ";
color: #6366f1;
}
.log-placeholder {
color: #555;
font-style: italic;
}
/* โโ Keyboard Guide โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
.keyboard-guide {
background: #111;
border: 1px solid #222;
border-radius: 12px;
padding: 16px;
}
@media (min-width: 480px) {
.keyboard-guide {
padding: 24px;
}
}
.guide-title {
font-size: 1rem;
font-weight: 600;
color: #d4d4d4;
margin-bottom: 16px;
}
.guide-grid {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
}
@media (min-width: 500px) {
.guide-grid {
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 12px;
}
}
.guide-item {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
font-size: 0.85rem;
color: #999;
}
.guide-item span {
flex: 1;
min-width: 0;
}
kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 26px;
height: 26px;
padding: 0 7px;
background: #1a1a1a;
border: 1px solid #333;
border-bottom-width: 2px;
border-radius: 5px;
font-family: inherit;
font-size: 0.75rem;
font-weight: 600;
color: #ccc;
line-height: 1;
}(() => {
const menubar = document.getElementById("menubar");
const actionLog = document.getElementById("actionLog");
const menubarButtons = [...menubar.querySelectorAll(".menubar-button")];
let currentMenubarIndex = 0;
let typeAheadBuffer = "";
let typeAheadTimer = null;
/* โโ Helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
function getSubmenu(button) {
return button.parentElement.querySelector(".submenu");
}
function getMenuItems(submenu) {
return [...submenu.querySelectorAll('[role="menuitem"]')];
}
function openSubmenu(button) {
// Close all others first
menubarButtons.forEach((btn) => closeSubmenu(btn));
const submenu = getSubmenu(button);
button.setAttribute("aria-expanded", "true");
submenu.classList.add("open");
}
function closeSubmenu(button) {
const submenu = getSubmenu(button);
button.setAttribute("aria-expanded", "false");
submenu.classList.remove("open");
// Remove focused class from all items
getMenuItems(submenu).forEach((item) => item.classList.remove("focused"));
}
function closeAllSubmenus() {
menubarButtons.forEach((btn) => closeSubmenu(btn));
}
function isSubmenuOpen(button) {
return button.getAttribute("aria-expanded") === "true";
}
function focusMenubarItem(index) {
menubarButtons[currentMenubarIndex].setAttribute("tabindex", "-1");
currentMenubarIndex = index;
menubarButtons[currentMenubarIndex].setAttribute("tabindex", "0");
menubarButtons[currentMenubarIndex].focus();
}
function focusSubmenuItem(submenu, index) {
const items = getMenuItems(submenu);
items.forEach((item) => item.classList.remove("focused"));
if (index >= 0 && index < items.length) {
items[index].classList.add("focused");
items[index].focus();
}
}
function getFocusedSubmenuIndex(submenu) {
const items = getMenuItems(submenu);
return items.findIndex((item) => item.classList.contains("focused"));
}
function logAction(text) {
const placeholder = actionLog.querySelector(".log-placeholder");
if (placeholder) placeholder.remove();
const entry = document.createElement("p");
entry.className = "log-entry";
entry.textContent = text;
actionLog.prepend(entry);
// Keep only last 5 entries
const entries = actionLog.querySelectorAll(".log-entry");
if (entries.length > 5) {
entries[entries.length - 1].remove();
}
}
/* โโ Menubar keyboard navigation โโโโโโโโโโโโโโโโโโโ */
menubarButtons.forEach((button, index) => {
// Click to open/close
button.addEventListener("click", () => {
if (isSubmenuOpen(button)) {
closeSubmenu(button);
} else {
openSubmenu(button);
const submenu = getSubmenu(button);
focusSubmenuItem(submenu, 0);
}
focusMenubarItem(index);
});
// Keyboard on menubar buttons
button.addEventListener("keydown", (e) => {
switch (e.key) {
case "ArrowRight": {
e.preventDefault();
const wasOpen = isSubmenuOpen(button);
closeAllSubmenus();
const next = (index + 1) % menubarButtons.length;
focusMenubarItem(next);
if (wasOpen) {
openSubmenu(menubarButtons[next]);
focusSubmenuItem(getSubmenu(menubarButtons[next]), 0);
}
break;
}
case "ArrowLeft": {
e.preventDefault();
const wasOpen = isSubmenuOpen(button);
closeAllSubmenus();
const prev = (index - 1 + menubarButtons.length) % menubarButtons.length;
focusMenubarItem(prev);
if (wasOpen) {
openSubmenu(menubarButtons[prev]);
focusSubmenuItem(getSubmenu(menubarButtons[prev]), 0);
}
break;
}
case "ArrowDown":
case "Enter":
case " ": {
e.preventDefault();
openSubmenu(button);
const submenu = getSubmenu(button);
focusSubmenuItem(submenu, 0);
break;
}
case "Escape": {
e.preventDefault();
closeAllSubmenus();
break;
}
case "Home": {
e.preventDefault();
closeAllSubmenus();
focusMenubarItem(0);
break;
}
case "End": {
e.preventDefault();
closeAllSubmenus();
focusMenubarItem(menubarButtons.length - 1);
break;
}
}
});
});
/* โโ Submenu keyboard navigation โโโโโโโโโโโโโโโโโโโ */
menubarButtons.forEach((button, menuIndex) => {
const submenu = getSubmenu(button);
const items = getMenuItems(submenu);
items.forEach((item) => {
item.addEventListener("keydown", (e) => {
const currentIndex = getFocusedSubmenuIndex(submenu);
const allItems = getMenuItems(submenu);
switch (e.key) {
case "ArrowDown": {
e.preventDefault();
let next = currentIndex + 1;
// Skip separators
while (next < allItems.length && allItems[next].getAttribute("role") === "separator") {
next++;
}
if (next < allItems.length) {
focusSubmenuItem(submenu, next);
}
break;
}
case "ArrowUp": {
e.preventDefault();
let prev = currentIndex - 1;
while (prev >= 0 && allItems[prev].getAttribute("role") === "separator") {
prev--;
}
if (prev >= 0) {
focusSubmenuItem(submenu, prev);
}
break;
}
case "ArrowRight": {
e.preventDefault();
closeSubmenu(button);
const nextMenu = (menuIndex + 1) % menubarButtons.length;
focusMenubarItem(nextMenu);
openSubmenu(menubarButtons[nextMenu]);
focusSubmenuItem(getSubmenu(menubarButtons[nextMenu]), 0);
break;
}
case "ArrowLeft": {
e.preventDefault();
closeSubmenu(button);
const prevMenu = (menuIndex - 1 + menubarButtons.length) % menubarButtons.length;
focusMenubarItem(prevMenu);
openSubmenu(menubarButtons[prevMenu]);
focusSubmenuItem(getSubmenu(menubarButtons[prevMenu]), 0);
break;
}
case "Home": {
e.preventDefault();
focusSubmenuItem(submenu, 0);
break;
}
case "End": {
e.preventDefault();
focusSubmenuItem(submenu, allItems.length - 1);
break;
}
case "Enter":
case " ": {
e.preventDefault();
logAction(`Activated: ${item.textContent.trim()}`);
closeSubmenu(button);
focusMenubarItem(menuIndex);
break;
}
case "Escape": {
e.preventDefault();
closeSubmenu(button);
focusMenubarItem(menuIndex);
break;
}
case "Tab": {
closeAllSubmenus();
break;
}
default: {
// Type-ahead: single character search
if (e.key.length === 1 && /[a-zA-Z]/.test(e.key)) {
e.preventDefault();
typeAheadBuffer += e.key.toLowerCase();
clearTimeout(typeAheadTimer);
typeAheadTimer = setTimeout(() => {
typeAheadBuffer = "";
}, 500);
const match = allItems.findIndex((el, i) => {
if (el.getAttribute("role") === "separator") return false;
return el.textContent.trim().toLowerCase().startsWith(typeAheadBuffer);
});
if (match !== -1) {
focusSubmenuItem(submenu, match);
}
}
break;
}
}
});
// Click on submenu item
item.addEventListener("click", () => {
if (item.getAttribute("role") === "separator") return;
logAction(`Activated: ${item.textContent.trim()}`);
closeSubmenu(button);
focusMenubarItem(menuIndex);
});
});
});
/* โโ Click outside to close โโโโโโโโโโโโโโโโโโโโโโโโ */
document.addEventListener("click", (e) => {
if (!menubar.contains(e.target)) {
closeAllSubmenus();
}
});
})();<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Keyboard Navigable Menu</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="demo-wrapper">
<h1 class="demo-title">Keyboard Navigable Menu</h1>
<p class="demo-subtitle">Full WAI-ARIA menubar pattern with arrow keys, Home/End, Escape, and type-ahead search</p>
<nav class="menubar-container" aria-label="Application menu">
<ul role="menubar" class="menubar" id="menubar">
<li role="none" class="menubar-item">
<button role="menuitem" aria-haspopup="true" aria-expanded="false" class="menubar-button" tabindex="0">
File
<svg class="chevron" aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m6 9 6 6 6-6"/></svg>
</button>
<ul role="menu" class="submenu" aria-label="File">
<li role="menuitem" class="submenu-item" tabindex="-1">New File</li>
<li role="menuitem" class="submenu-item" tabindex="-1">New Window</li>
<li role="menuitem" class="submenu-item" tabindex="-1">Open File…</li>
<li role="separator" class="submenu-separator"></li>
<li role="menuitem" class="submenu-item" tabindex="-1">Save</li>
<li role="menuitem" class="submenu-item" tabindex="-1">Save As…</li>
</ul>
</li>
<li role="none" class="menubar-item">
<button role="menuitem" aria-haspopup="true" aria-expanded="false" class="menubar-button" tabindex="-1">
Edit
<svg class="chevron" aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m6 9 6 6 6-6"/></svg>
</button>
<ul role="menu" class="submenu" aria-label="Edit">
<li role="menuitem" class="submenu-item" tabindex="-1">Undo</li>
<li role="menuitem" class="submenu-item" tabindex="-1">Redo</li>
<li role="separator" class="submenu-separator"></li>
<li role="menuitem" class="submenu-item" tabindex="-1">Cut</li>
<li role="menuitem" class="submenu-item" tabindex="-1">Copy</li>
<li role="menuitem" class="submenu-item" tabindex="-1">Paste</li>
<li role="menuitem" class="submenu-item" tabindex="-1">Delete</li>
</ul>
</li>
<li role="none" class="menubar-item">
<button role="menuitem" aria-haspopup="true" aria-expanded="false" class="menubar-button" tabindex="-1">
View
<svg class="chevron" aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m6 9 6 6 6-6"/></svg>
</button>
<ul role="menu" class="submenu" aria-label="View">
<li role="menuitem" class="submenu-item" tabindex="-1">Zoom In</li>
<li role="menuitem" class="submenu-item" tabindex="-1">Zoom Out</li>
<li role="menuitem" class="submenu-item" tabindex="-1">Reset Zoom</li>
<li role="separator" class="submenu-separator"></li>
<li role="menuitem" class="submenu-item" tabindex="-1">Toggle Sidebar</li>
<li role="menuitem" class="submenu-item" tabindex="-1">Toggle Terminal</li>
</ul>
</li>
<li role="none" class="menubar-item">
<button role="menuitem" aria-haspopup="true" aria-expanded="false" class="menubar-button" tabindex="-1">
Help
<svg class="chevron" aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m6 9 6 6 6-6"/></svg>
</button>
<ul role="menu" class="submenu" aria-label="Help">
<li role="menuitem" class="submenu-item" tabindex="-1">Documentation</li>
<li role="menuitem" class="submenu-item" tabindex="-1">Release Notes</li>
<li role="menuitem" class="submenu-item" tabindex="-1">Report Issue</li>
<li role="separator" class="submenu-separator"></li>
<li role="menuitem" class="submenu-item" tabindex="-1">About</li>
</ul>
</li>
</ul>
</nav>
<div class="action-log" id="actionLog" aria-live="polite">
<p class="log-placeholder">Select a menu item to see it logged here…</p>
</div>
<section class="keyboard-guide">
<h2 class="guide-title">Keyboard Guide</h2>
<div class="guide-grid">
<div class="guide-item">
<kbd>←</kbd> <kbd>→</kbd>
<span>Move between menubar items</span>
</div>
<div class="guide-item">
<kbd>↓</kbd> <kbd>↑</kbd>
<span>Navigate within a dropdown</span>
</div>
<div class="guide-item">
<kbd>Enter</kbd> / <kbd>Space</kbd>
<span>Open dropdown or activate item</span>
</div>
<div class="guide-item">
<kbd>Escape</kbd>
<span>Close dropdown, return to menubar</span>
</div>
<div class="guide-item">
<kbd>Home</kbd> / <kbd>End</kbd>
<span>Jump to first / last item</span>
</div>
<div class="guide-item">
<kbd>A</kbd>–<kbd>Z</kbd>
<span>Type-ahead: jump to matching item</span>
</div>
</div>
</section>
</div>
<script src="script.js"></script>
</body>
</html>A fully accessible horizontal menubar with dropdown submenus implementing the complete WAI-ARIA menu keyboard pattern. Supports arrow key navigation, Home/End jumps, Escape to close, and type-ahead character search for rapid item selection.