Design Lounge
Install

Nº 040 of 520

Nested dropdown menu

An application menubar with click-to-open menus, hover submenus guarded by a safe-triangle delay, check and radio items, a shortcuts column and complete keyboard support including typeahead.

Preview

Open full size
1280 × 800 · 16.5 KB · 182 lines

Use it

Free to use · credit appreciated

1 Once, in your project

npx skills add SusanAcharya/Design-Lounge

2 Then ask

Using the Design Lounge skill, build "Nested dropdown menu" (piece nested-dropdown-menu) in my stack. Match its brief; restyle it onto my product's theme.

No skill? Paste the brief instead. It carries the numbers, tokens and behaviour.

Brief

213 lines

Build brief for a coding agent. Rebuild this piece in the reader’s stack. If they haven’t said which stack, ask once, then default to semantic HTML + CSS + a little vanilla JS. Match the numbers below; don’t “improve” them.

What it is

The menubar of a design-tool (“Ferro”) over a dotted canvas: File, Edit, Arrange, Help. Menus open on click, 236px wide, 32px items with a 14px check/radio gutter, a label, and a right-aligned shortcut column in the system mono. The Edit menu has three levels: Paste special ▸ Paste properties ▸ Fill only. Submenus open on hover after 120ms; if the pointer is heading diagonally toward an open submenu, other items wait 300ms before stealing it (a safe triangle between the previous pointer position and the submenu’s left edge). Check items (Show rulers, Snap to grid, Pixel grid), a radio group (Zoom 50/100/200 %), disabled items and separators are all present. Keyboard does everything: arrows, Home/End, Right/Left for submenus, Esc per level, Enter/Space, Tab to close, and typeahead. Cool graphite, one sky-blue highlight.

Reference behaviour

  1. Initial state: the Edit menu is open beneath its menubar button (button highlighted --panel-2), showing 20 rows: Undo move ⌘Z · Redo (disabled) ⇧⌘Z · — · Cut · Copy · Paste · Paste special ▸ · Duplicate · Delete ⌫ · — · Select all ⌘A · Select same ▸ · — · VIEW heading · ✓ Show rulers ⇧R · ✓ Snap to grid · Pixel grid ⌘’ · — · ZOOM heading · 50 % · ● 100 % ⌘0 · 200 % · — · Preferences… ⌘,. The status line top-right reads “Last action: —”.
  2. Hover an item: it fills --accent with --accent-ink text; the shortcut goes 80% opaque dark. Focus follows hover.
  3. Hover “Paste special”: after 120ms its submenu opens to the right (left: calc(100% − 4px); top: −6px) with a 120ms pop (opacity 0 → 1, scale(.98) translateY(−2px) → none). The parent item stays highlighted while the submenu is open.
  4. Move the pointer diagonally from “Paste special” down-right toward the submenu, crossing “Duplicate”: Duplicate does not take the highlight for 300ms because the pointer is inside the triangle (previous pointer position, submenu top-left, submenu bottom-left). Moving straight down onto Duplicate (outside the triangle) switches immediately and closes the submenu.
  5. Hover “Paste properties” inside the submenu: a third-level menu opens the same way. Hovering another item in the second level closes the third level.
  6. Click “Show rulers”: its check toggles (aria-checked), the status line reads “Edit › Show rulers (off)”, and the menu stays open (check/radio items don’t dismiss). Click “200 %”: the radio moves, status “Edit › 200 % (on)”, menu stays open.
  7. Click “Copy”: status “Edit › Copy”; the menu closes and focus returns to the Edit button. Click “Fill only” (third level): status “Edit › Paste special › Paste properties › Fill only” and everything closes.
  8. With a menu open, hovering another menubar button (File, Arrange, Help) switches to that menu without a click. Clicking the open menu’s button closes it. Clicking anywhere outside the bar closes.
  9. Keyboard, on a menubar button: ↓ / Enter / Space opens the menu and focuses its first item; → / ← move between buttons (and switch open menus if one is open).
  10. Keyboard, in a menu: ↓ ↑ move with wrap; Home / End; → on an item with a submenu opens it and focuses its first item (→ on a plain item in a top-level menu switches to the next menubar menu); ← closes the current submenu and refocuses its parent item (← in a top-level menu switches to the previous menubar menu); Esc closes one level (submenu → parent item; top-level → menubar button); Enter / Space activate; Tab closes everything and continues; typing letters jumps to the next item whose label starts with the buffered prefix (700ms buffer).
  11. Disabled items (Redo, Print) don’t highlight on hover and don’t activate, but they are still focusable via keyboard so their shortcut can be read.

Structure

1280 × 800
┌──────────────────────────────────────────────────────────────────────────┐
│ FERRO  File [Edit] Arrange Help                Onboarding flow v3 · Saved │ bar 44
├──────────┬─────────────────────┬─────────────────────────────────────────┤
│          │ Undo move       ⌘Z  │                    Last action: —      │ menu 236 wide
│  canvas  │ Redo           ⇧⌘Z  │  (disabled)                            │ items 32
│  dotted  │ ───────────────────  │                                        │ padding 6
│  20px    │ Cut             ⌘X  │                                        │
│  grid    │ Copy            ⌘C  │                                        │
│          │ Paste           ⌘V  │                                        │
│          │ Paste special     ▸ ├───────────────────────┐                 │ submenu: left 100%−4, top −6
│          │ Duplicate       ⌘D  │ Paste to replace ⇧⌘R │                 │
│          │ Delete           ⌫  │ Paste over selection  │                 │
│          │ ───────────────────  │ Paste as plain … ⇧⌥⌘V│                 │
│          │ Select all      ⌘A  │ ─────────────────────  │                 │
│          │ Select same       ▸ │ Paste properties    ▸ ├──────────────┐  │ third level
│          │ ───────────────────  └───────────────────────┤ Fill only    │  │
│          │ VIEW                                        │ Stroke only  │  │
│          │ ✓ Show rulers   ⇧R                          │ Effects only │  │
│          │ …                                           └──────────────┘  │
└──────────┴─────────────────────────────────────────────────────────────────┘
  • <div class="bar" role="menubar" aria-label="Application"> — 44px, --panel, bottom hairline. Logo “FERRO” (Archivo Black 12px, accent). Then four .mb wrappers and a right-aligned document title.
    • .mb (position:relative) → <button class="mb-btn" role="menuitem" aria-haspopup="menu" aria-expanded> (28px tall, 6px radius) + <div class="menu" role="menu" aria-label="Edit"> (absolute top: calc(100% + 4px); left: 0).
    • Inside a .menu: <button class="it" role="menuitem|menuitemcheckbox|menuitemradio" aria-checked? aria-disabled? data-group?> with <span class="chk"> (14px gutter; contains a check SVG for checkboxes, a CSS dot for radios), <span class="lb">, and either <kbd> or a chevron SVG. Separators <div class="sep" role="separator"> (1px, 5px 4px margin). Group headings <div class="hd"> (10px uppercase tracked).
    • Submenu: <div class="sub"> (position:relative) wrapping the trigger .it[aria-haspopup="menu"][aria-expanded] and its own .menu (absolute left: calc(100% − 4px); top: −6px). Nest .sub again for a third level.
  • .canvas — flex 1, --canvas with a radial-gradient 1px dot every 20px; an inline SVG of four rounded frames (one with a dashed accent selection) and small labels. aria-hidden.
  • <p class="status" aria-live="polite"> — absolute top-right.

Tokens

:root {
  /* colour — cool graphite, sky-blue highlight */
  --bg: #1c1f24;
  --canvas: #22262c;
  --panel: #262a31;       /* menubar, menus, frames */
  --panel-2: #30353e;     /* menubar button hover/open */
  --line: #363c46;        /* hairlines, separators, canvas dots, menu ring */
  --line-2: #454c58;      /* frame strokes */
  --ink: #e6e9ee;
  --ink-2: #a5adba;       /* menubar buttons, status */
  --ink-3: #6f7886;       /* shortcuts, headings, disabled */
  --accent: #5ab0ff;      /* highlighted item, logo, selection outline */
  --accent-ink: #06192b;  /* text on highlight */

  /* type */
  --display: "Archivo Black", system-ui, sans-serif;
  --font: "Archivo", system-ui, sans-serif;
  --mono: ui-monospace, "SF Mono", Menlo, monospace;

  /* layout */
  --bar-h: 44px;
  --menu-w: 236px;
  --menu-pad: 6px;
  --item-h: 32px;
  --item-x: 8px;
  --gutter: 14px;         /* check / radio column */
  --sub-offset-x: -4px;   /* submenu overlaps parent by 4px */
  --sub-offset-y: -6px;   /* first submenu item aligns with parent item */
  --r: 8px;               /* menu */
  --r-item: 5px;
  --shadow: 0 0 0 1px var(--line), 0 12px 32px -8px rgba(0,0,0,.6);

  /* motion */
  --t-fast: 120ms;        /* menu pop */
  --sub-delay: 120ms;     /* hover before submenu opens */
  --safe-delay: 300ms;    /* grace while pointer moves toward a submenu */
  --type-buffer: 700ms;   /* typeahead reset */
  --ease: cubic-bezier(.2, .7, .2, 1);
  --ease-out: cubic-bezier(.16, 1, .3, 1);
}

Typography

RoleFamilySizeWeightLine-heightTrackingNotes
LogoArchivo Black12px4001+0.06emUPPERCASE --accent
Document titleArchivo Black12px4001+0.02em
Menubar buttonArchivo13px50028px height0--ink-2; hover/open --ink
Menu itemArchivo13px40032px height0--ink; highlighted --accent-ink
Shortcutsystem mono11px4001+0.04em--ink-3
Group headingArchivo10px5001.3+0.12emUPPERCASE --ink-3, padding 6px 8px 3px
StatusArchivo12px4001.40--ink-3; value --ink-2 500
Canvas labelsArchivo (SVG)12px400—0--ink-3

Motion

ElementTriggerPropertyFrom → ToDurationEasingNotes
.menuopen (any level)opacity, transform0, scale(.98) translateY(-2px) → 1, none120ms--ease-outkeyframes pop; transform-origin: top left
.menuclosedisplayblock → none0—no exit animation — menus should vanish on activation
submenuhover parentopenafter 120ms——cancelled if the pointer leaves before
other itemshover while sub openhighlightafter 300ms if inside the safe triangle, else 0——re-evaluated on each mouseover
item highlighthover / focusbackground, colorinstant0—never transition menu highlights

Reduced motion: the pop keyframe runs at 1ms. Delays are interaction logic, not motion, and stay.

States

  • Item default: --ink on transparent. Highlighted (:hover, :focus-visible, or parent of an open submenu .sub.open > .it): --accent background, --accent-ink text, shortcut --accent-ink at 80% opacity, 5px radius.
  • Checkbox item: role="menuitemcheckbox", aria-checked; the .chk gutter shows a check SVG only when checked (visibility), so labels stay aligned.
  • Radio item: role="menuitemradio", data-group="zoom"; a 6px currentColor dot when checked.
  • Submenu trigger: aria-haspopup="menu", aria-expanded mirrors open state; chevron 14px on the right instead of a shortcut.
  • Disabled: aria-disabled="true", text --ink-3, no hover fill, activation ignored.
  • Menubar button open: --panel-2 background, --ink text, aria-expanded="true".
  • Menubar button focus-visible: 2px accent box-shadow ring.
  • Separator: 1px --line, 5px 4px margins. Heading: non-interactive, skipped by keyboard navigation.

Accessibility

  • Roles: menubar › menuitem buttons with aria-haspopup="menu" + aria-expanded › menu (with aria-label) › menuitem / menuitemcheckbox / menuitemradio / separator. Submenu triggers are menuitem + aria-haspopup="menu" + aria-expanded, and their menu is the next sibling.
  • Keyboard map is in Reference behaviour 9–10. Handle keys on each .menu and stopPropagation so a nested menu’s keys never reach the parent handler.
  • Hover moves focus to the item (it.focus()), so keyboard and pointer share one notion of “current item”.
  • Activation of a plain menuitem closes all menus and returns focus to the menubar button; check/radio items keep the menu open and update aria-checked.
  • Status line is aria-live="polite" and reports the full path (“Edit › Paste special › Paste properties › Fill only”) plus “(on)/(off)” for toggles.
  • Disabled items remain focusable (aria-disabled, not disabled) so screen readers can discover them.
  • Contrast: --ink on --panel 12:1; --accent-ink on --accent 8.9:1; --ink-3 on --panel 3.6:1 (shortcuts and headings — non-essential); disabled items intentionally low.
  • Hit targets: items 32px × full width; menubar buttons 28px (bar is 44px — extend the hit area to the bar height if you need it).

Responsive rules

  • ≥ 1280: as specified.
  • 1024–1279: unchanged.
  • 768–1023: submenus flip to the left (right: calc(100% − 4px)) when rect.right + 236 > viewport width; otherwise identical.
  • < 640: the menubar collapses to a single “Menu” button; each top-level menu becomes a full-height sheet from the left (position:fixed; inset:0 auto 0 0; width:280px); submenus replace the sheet’s content with a ”‹ Back” row instead of opening beside it; hover logic disabled (tap only).

Acceptance checklist

  • Menus are 236px min-width, 8px radius, 6px padding, with a 1px #363c46 ring and 0 12px 32px -8px rgba(0,0,0,.6) shadow; items are 32px with 5px radius.
  • The Edit menu is open on first paint with the 20 rows listed, ✓ on Show rulers and Snap to grid, ● on 100 %.
  • Hovering a submenu trigger opens its submenu after 120ms at left: calc(100% − 4px); top: −6px with a 120ms pop.
  • Moving diagonally toward an open submenu across another item does not switch highlight for 300ms; moving outside the triangle switches at once.
  • Three levels open and close correctly; opening a sibling closes any deeper level.
  • Check items toggle aria-checked and keep the menu open; radio items are exclusive within data-group and keep the menu open; plain items close everything and refocus the menubar button.
  • ↓ ↑ wrap; Home/End; → opens a submenu (or switches menubar menu); ← closes a submenu (or switches menubar menu); Esc closes one level; Tab closes all.
  • Typing “pa” focuses “Paste”, typing “pas” again within 700ms keeps the prefix and stays on “Paste”/“Paste special” as appropriate; after 700ms the buffer resets.
  • Hovering another menubar button while a menu is open switches menus without a click; clicking outside closes.
  • Disabled items (Redo, Print) do not highlight or activate but are focusable.
  • The status line announces the full path of the last activated item.
  • Highlight colours never transition; menus have no exit animation.

Implementation notes

Safe triangle — keep the last two pointer positions; when a submenu is open, a hover on another item is deferred if the current position lies inside the triangle formed by the previous position and the submenu’s left edge:

const sign = (p, a, b) => (p.x - b.x) * (a.y - b.y) - (a.x - b.x) * (p.y - b.y);
function towardSub(menu) {
  const s = menu.querySelector(':scope > .sub.open > .menu'); if (!s) return false;
  const r = s.getBoundingClientRect(), a = prev, b = { x: r.left, y: r.top }, c = { x: r.left, y: r.bottom };
  const d1 = sign(last, a, b), d2 = sign(last, b, c), d3 = sign(last, c, a);
  return !((d1 < 0 || d2 < 0 || d3 < 0) && (d1 > 0 || d2 > 0 || d3 > 0));
}
addEventListener('mousemove', e => { prev = last; last = { x: e.clientX, y: e.clientY }; });
// in the menu's mouseover handler:
if (towardSub(menu) && !(mySub && mySub.classList.contains('open'))) subT = setTimeout(go, 300); else go();

Direct children only. Every handler must operate on the items of its menu, not descendants of nested menus, or hover in a submenu will close it:

const items = m => [...m.querySelectorAll(':scope > .it, :scope > .sub > .it')];
menu.addEventListener('keydown', e => {
  const list = items(menu), i = list.indexOf(document.activeElement); if (i < 0) return;
  /* handle keys for list[i] … */
  e.preventDefault(); e.stopPropagation();     // parent menus must not also react
});

Activation semantics by role:

if (role === 'menuitemcheckbox') it.setAttribute('aria-checked', String(it.getAttribute('aria-checked') !== 'true'));
else if (role === 'menuitemradio') group.forEach(r => r.setAttribute('aria-checked', String(r === it)));
if (role === 'menuitem') { const b = openButton; closeAll(); b.focus(); }   // toggles keep the menu open

Common mistakes: opening submenus on mouseenter with no delay (flicker when skimming the list); closing a submenu the instant the pointer leaves the trigger (the pointer must cross the 4px overlap); animating the close; letting keydown bubble from a nested menu into the parent (double navigation); using disabled on items (removes them from the accessibility tree).

Details

Palette
Type
Archivo Black · Archivo
Motion
Subtle motion
Build
Multi-step
Designed bySusan AcharyaKathmandu

Studied

Tailwind CSS docs

Put each instruction next to its code. Draw the grid with thin lines and let the empty sides carry a faint hatch.

All sources

On the shelves