Skip to content

Menu

Menu is an accessible list of actions and navigation destinations. It composes Klean Popover, so the browser still owns native top-layer display and light dismissal. Menu adds the missing composite behavior: menu and menuitem semantics, one roving focus stop, Arrow keys, Home/End, printable-key typeahead, disabled-item handling, selection, and reliable cleanup.

The application supplies real buttons and links plus ordinary Tailwind. There is no MenuTrigger, MenuItem, item-data schema, asChild, visual variant, provider, or theme object.

Menu.vue
Open Actions, then try Arrow Up/Down, Home/End, typing “d”, Tab, and Escape.

Installation

Run the same command in Vue, React, or Svelte. Klean detects the framework and conventional destination, then installs Popover first when it is missing.

The dependency is source-level, not configuration: Menu imports its sibling Popover. The registry resolves that prerequisite before Menu and installs only the direct packages their readable source imports. No initializer, klean-ui.json, public cn.js, alias prompt, or Klean runtime appears.

Usage

Vue

ProjectActions.vue
<script setup>
import Button from '~/components/ui/button/Button.vue'
import Menu from '~/components/ui/menu/Menu.vue'
</script>

<template>
  <Button popovertarget="project-actions">Actions</Button>

  <Menu id="project-actions" aria-label="Project actions" class="w-56">
    <button
      type="button"
      class="flex w-full cursor-pointer rounded px-3 py-2 text-left text-sm outline-none hover:bg-gray-100 focus:bg-gray-100"
    >
      Redeploy
    </button>
    <a
      href="/deployments"
      class="flex w-full rounded px-3 py-2 text-sm no-underline outline-none hover:bg-gray-100 focus:bg-gray-100"
    >
      View deployments
    </a>
  </Menu>
</template>

React

ProjectActions.jsx
import Button from '~/components/ui/button/Button.jsx'
import Menu from '~/components/ui/menu/Menu.jsx'

export default function ProjectActions() {
  return (
    <>
      <Button popovertarget="project-actions">Actions</Button>

      <Menu id="project-actions" aria-label="Project actions" className="w-56">
        <button
          type="button"
          className="flex w-full cursor-pointer rounded px-3 py-2 text-left text-sm outline-none hover:bg-gray-100 focus:bg-gray-100"
        >
          Redeploy
        </button>
        <a
          href="/deployments"
          className="flex w-full rounded px-3 py-2 text-sm no-underline outline-none hover:bg-gray-100 focus:bg-gray-100"
        >
          View deployments
        </a>
      </Menu>
    </>
  )
}

Svelte

ProjectActions.svelte
<script>
  import Button from '~/components/ui/button/Button.svelte'
  import Menu from '~/components/ui/menu/Menu.svelte'
</script>

<Button popovertarget="project-actions">Actions</Button>

<Menu id="project-actions" aria-label="Project actions" class="w-56">
  <button
    type="button"
    class="flex w-full cursor-pointer rounded px-3 py-2 text-left text-sm outline-none hover:bg-gray-100 focus:bg-gray-100"
  >
    Redeploy
  </button>
  <a
    href="/deployments"
    class="flex w-full rounded px-3 py-2 text-sm no-underline outline-none hover:bg-gray-100 focus:bg-gray-100"
  >
    View deployments
  </a>
</Menu>

The framework syntax changes; the HTML contract does not. A real button uses native popovertarget. Native button and anchor children become menu items automatically, so developers do not repeat roles or tab indices.

Truthful items

Use a native button when selection performs an action:

vue
<button
  type="button"
  class="cursor-pointer ..."
  @click="redeploy"
>Redeploy</button>

Use an anchor for navigation. The Boring Stack Link renders an anchor too, so it works without an adapter:

vue
<Link href="/projects/42/settings" class="...">Project settings</Link>

Menu does not accept an item array because an array forces the component to guess whether each record is a button, anchor, download, or framework Link. Authorization, conditional visibility, event handlers, and destinations remain obvious in application markup.

Native buttons keep the browser's default arrow cursor, so button-item recipes opt into cursor-pointer explicitly. That visible Tailwind class is part of the application-owned visual API; Menu does not mutate its children's appearance.

Disabled items

A disabled action uses the native disabled attribute and is skipped during keyboard navigation. A navigation item that must remain visible can use aria-disabled="true"; Menu prevents activation and skips it. Prefer hiding unauthorized items in application logic instead of teaching Menu about permissions.

vue
<button
  type="button"
  disabled
  class="disabled:cursor-not-allowed disabled:opacity-40"
>
  Stop provisioning
</button>

API

InputDefaultPurpose
idgeneratedNative target identifier. Supply a stable value when a button invokes the Menu.
placementbottom-startPreferred logical placement. It may flip or shift to remain visible.
offset8Pixel distance between the invoker and menu.
framework open bindinguncontrolledObserve or control visibility only when application behavior genuinely needs it.
defaultOpenfalseInitial uncontrolled visibility, mainly useful for examples and tests.
class / classNameOrdinary Tailwind classes merged last on the menu surface.
default contentNative buttons, anchors, or framework links.

Vue uses v-model:open, React uses open with onOpenChange, and Svelte uses bind:open. Placement and offset are geometry, not appearance. Menu has no variant, tone, size, inset, destructive, animation, or theme props.

Keyboard and focus

  • Click, Enter, or Space on the real trigger opens and focuses the first enabled item.
  • Arrow Down on the trigger opens at the first enabled item; Arrow Up opens at the last.
  • Arrow Down and Arrow Up wrap between enabled items.
  • Home and End move to the first and last enabled items.
  • Printable characters use a short buffered typeahead against visible text or aria-label.
  • Enter and Space activation remain native to the real button or anchor, avoiding double firing.
  • Escape closes and restores focus to the invoker.
  • Selection closes and restores focus; link navigation may then move to the destination.
  • Tab or Shift+Tab closes and continues to the next or previous control outside the menu; neither key moves between menu items.
  • Outside interaction closes without stealing focus from the selected target.

The vertical key contract is the same in right-to-left documents. Menu adds no animation, so reduced-motion users get a stable surface by default. Product motion, if useful, belongs in caller Tailwind and must use motion-safe: or an equivalent fallback.

  • Menu is a composite widget of actions and destinations with arrow navigation and typeahead.
  • Popover holds ordinary forms, filters, help, or previews and keeps normal Tab order.
  • Select chooses one value and has selected-option behavior.
  • Combobox combines text input, filtering, and an option popup.
  • Dialog is modal, contains focus, and makes the background inert.

Website navigation remains a semantic nav and list of links with ordinary Tab behavior. Do not add Menu roles merely because navigation appears in a floating surface.

Product recipes

Slipway needs compact operational actions; Hagfish needs a stronger border and offset shadow. Those are caller recipes, not Klean themes.

product-menus.vue
Every visual choice is visible Tailwind at the call site. The same Menu behavior carries both products without a variant or theme selector.

Complete framework source

The preview Source tab contains the complete Vue component. The equivalent framework-native React and Svelte sources are copyable here; both import their local Klean Popover and preserve the same behavior contract.

React source

Menu.jsx
import {
  forwardRef,
  useCallback,
  useEffect,
  useImperativeHandle,
  useRef,
  useState
} from 'react'
import { twMerge } from 'tailwind-merge'
import Popover from '../popover/Popover.jsx'

const ITEM_SELECTOR =
  '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]'
const TABBABLE_SELECTOR =
  'a[href], button, input, select, textarea, [tabindex], [contenteditable="true"]'

function eventPath(event) {
  return (
    event.nativeEvent?.composedPath?.() ??
    event.composedPath?.() ?? [event.target]
  )
}

function itemRole(element) {
  return ['menuitem', 'menuitemcheckbox', 'menuitemradio'].includes(
    element.getAttribute('role')
  )
}

function itemIsDisabled(item) {
  return (
    item.matches(':disabled') ||
    item.getAttribute('aria-disabled') === 'true' ||
    item.hidden ||
    item.closest('[hidden]') !== null
  )
}

const Menu = forwardRef(function Menu(
  {
    id,
    open: controlledOpen,
    defaultOpen = false,
    onOpenChange,
    placement = 'bottom-start',
    offset = 8,
    className,
    children,
    onKeyDown,
    onClickCapture,
    ...contentProps
  },
  forwardedRef
) {
  const popoverRef = useRef(null)
  const activeInvoker = useRef(null)
  const pendingFocus = useRef('first')
  const restoreOnClose = useRef(false)
  const tabExit = useRef({ pending: false, target: undefined })
  const typeahead = useRef('')
  const typeaheadTimer = useRef()
  const [internalOpen, setInternalOpen] = useState(defaultOpen)
  const isControlled = controlledOpen !== undefined
  const isOpen = isControlled ? controlledOpen : internalOpen
  const latestOpen = useRef(isOpen)
  latestOpen.current = isOpen

  const contentElement = useCallback(() => popoverRef.current?.content, [])

  const invokers = useCallback(() => {
    const content = contentElement()
    const root = content?.getRootNode?.() ?? document

    return [...(root.querySelectorAll?.('[popovertarget]') ?? [])].filter(
      (element) => element.getAttribute('popovertarget') === content?.id
    )
  }, [contentElement])

  const syncInvokerSemantics = useCallback(() => {
    for (const invoker of invokers()) {
      invoker.setAttribute('aria-haspopup', 'menu')
    }
  }, [invokers])

  const matchingInvoker = useCallback(
    (event) => {
      const contentId = contentElement()?.id
      return eventPath(event).find(
        (element) => element?.getAttribute?.('popovertarget') === contentId
      )
    },
    [contentElement]
  )

  const restoreInvokerFocus = useCallback(() => {
    const invoker = activeInvoker.current?.isConnected
      ? activeInvoker.current
      : invokers()[0]
    invoker?.focus?.({ preventScroll: true })
  }, [invokers])

  const adjacentTabStop = useCallback(
    (backward) => {
      const content = contentElement()
      const invoker = activeInvoker.current?.isConnected
        ? activeInvoker.current
        : invokers()[0]
      let anchor = invoker
      let root = content?.getRootNode?.() ?? document

      while (anchor && root) {
        const stops = [
          ...(root.querySelectorAll?.(TABBABLE_SELECTOR) ?? [])
        ].filter(
          (element) =>
            !content?.contains(element) &&
            element.tabIndex >= 0 &&
            !element.matches(':disabled') &&
            !element.closest('[hidden], [inert]')
        )
        const current = stops.indexOf(anchor)
        let target

        if (current >= 0) {
          target = stops[current + (backward ? -1 : 1)]
        } else {
          const candidates = stops.filter((element) => {
            const relation = anchor.compareDocumentPosition(element)
            return backward ? Boolean(relation & 2) : Boolean(relation & 4)
          })
          target = backward ? candidates.at(-1) : candidates[0]
        }

        if (target) return target
        if (!root.host) return undefined
        anchor = root.host
        root = anchor.getRootNode?.()
      }

      return undefined
    },
    [contentElement, invokers]
  )

  const completeTabExit = useCallback(() => {
    const target = tabExit.current.target
    if (target?.isConnected) {
      target.focus({ preventScroll: true })
    } else {
      const root = contentElement()?.getRootNode?.() ?? document
      root.activeElement?.blur?.()
    }

    tabExit.current = { pending: false, target: undefined }
  }, [contentElement])

  const menuItems = useCallback(() => {
    const content = contentElement()
    if (!content) return []

    for (const element of content.querySelectorAll('button, a[href]')) {
      if (!element.hasAttribute('role'))
        element.setAttribute('role', 'menuitem')
    }

    const items = [...content.querySelectorAll(ITEM_SELECTOR)].filter(
      (element) => element.closest('[role="menu"]') === content
    )
    for (const item of items) item.tabIndex = -1
    return items
  }, [contentElement])

  const enabledItems = useCallback(
    () => menuItems().filter((item) => !itemIsDisabled(item)),
    [menuItems]
  )

  const focusedElement = useCallback(
    () =>
      contentElement()?.getRootNode?.().activeElement ?? document.activeElement,
    [contentElement]
  )

  const focusItem = useCallback(
    (item) => {
      if (!item) return
      for (const candidate of menuItems()) candidate.tabIndex = -1
      item.tabIndex = 0
      item.focus({ preventScroll: true })
    },
    [menuItems]
  )

  const focusEdge = useCallback(
    (edge = 'first') => {
      const items = enabledItems()
      const item = edge === 'last' ? items.at(-1) : items[0]
      if (item) focusItem(item)
      else contentElement()?.focus({ preventScroll: true })
    },
    [contentElement, enabledItems, focusItem]
  )

  const clearTypeahead = useCallback(() => {
    typeahead.current = ''
    clearTimeout(typeaheadTimer.current)
    typeaheadTimer.current = undefined
  }, [])

  const requestOpen = useCallback(
    (nextOpen) => {
      if (!isControlled) setInternalOpen(nextOpen)
      onOpenChange?.(nextOpen)
    },
    [isControlled, onOpenChange]
  )

  const openMenu = useCallback(
    (edge = 'first') => {
      pendingFocus.current = edge
      if (latestOpen.current) focusEdge(edge)
      else requestOpen(true)
    },
    [focusEdge, requestOpen]
  )

  const closeMenu = useCallback(
    ({ restoreFocus = false } = {}) => {
      restoreOnClose.current ||= restoreFocus
      if (latestOpen.current) requestOpen(false)
      else if (restoreOnClose.current) {
        restoreOnClose.current = false
        queueMicrotask(restoreInvokerFocus)
      }
    },
    [requestOpen, restoreInvokerFocus]
  )

  useImperativeHandle(
    forwardedRef,
    () => ({ content: contentElement(), open: openMenu, close: closeMenu }),
    [closeMenu, contentElement, openMenu]
  )

  useEffect(() => {
    syncInvokerSemantics()

    if (isOpen) {
      focusEdge(pendingFocus.current)
      pendingFocus.current = 'first'
      return
    }

    clearTypeahead()
    menuItems()
    if (tabExit.current.pending) completeTabExit()
    else if (restoreOnClose.current) restoreInvokerFocus()
    restoreOnClose.current = false
  }, [
    clearTypeahead,
    completeTabExit,
    focusEdge,
    isOpen,
    menuItems,
    restoreInvokerFocus,
    syncInvokerSemantics
  ])

  useEffect(() => {
    const content = contentElement()
    const root = content?.getRootNode?.() ?? document

    function rememberInvoker(event) {
      const invoker = matchingInvoker(event)
      if (invoker) activeInvoker.current = invoker
    }

    function handleInvokerKeydown(event) {
      const invoker = matchingInvoker(event)
      if (!invoker || invoker.matches(':disabled')) return
      activeInvoker.current = invoker

      if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
        event.preventDefault()
        openMenu(event.key === 'ArrowUp' ? 'last' : 'first')
      }
    }

    root.addEventListener('keydown', handleInvokerKeydown)
    root.addEventListener('click', rememberInvoker, true)
    syncInvokerSemantics()
    menuItems()

    const observer =
      typeof MutationObserver !== 'undefined' && content
        ? new MutationObserver(menuItems)
        : undefined
    observer?.observe(content, { childList: true, subtree: true })

    return () => {
      observer?.disconnect()
      root.removeEventListener('keydown', handleInvokerKeydown)
      root.removeEventListener('click', rememberInvoker, true)
      clearTypeahead()
    }
  }, [
    clearTypeahead,
    contentElement,
    matchingInvoker,
    menuItems,
    openMenu,
    syncInvokerSemantics
  ])

  function itemFromEvent(event) {
    const content = contentElement()
    return eventPath(event).find(
      (element) =>
        element?.nodeType === 1 &&
        itemRole(element) &&
        element.closest?.('[role="menu"]') === content
    )
  }

  function handleClick(event) {
    const item = itemFromEvent(event)
    if (!item) {
      onClickCapture?.(event)
      return
    }

    if (itemIsDisabled(item)) {
      event.preventDefault()
      event.nativeEvent.stopImmediatePropagation()
      return
    }

    closeMenu({ restoreFocus: true })
    onClickCapture?.(event)
  }

  function handleTypeahead(event) {
    if (
      event.key.length !== 1 ||
      event.key === ' ' ||
      event.altKey ||
      event.ctrlKey ||
      event.metaKey
    ) {
      return false
    }

    event.preventDefault()
    clearTimeout(typeaheadTimer.current)
    typeahead.current += event.key.toLocaleLowerCase()
    typeaheadTimer.current = setTimeout(clearTypeahead, 500)

    const items = enabledItems()
    if (!items.length) return true
    const current = items.indexOf(focusedElement())
    const ordered = [
      ...items.slice(current + 1),
      ...items.slice(0, current + 1)
    ]
    const itemText = (item) =>
      (item.getAttribute('aria-label') ?? item.textContent ?? '')
        .trim()
        .toLocaleLowerCase()
    let match = ordered.find((item) =>
      itemText(item).startsWith(typeahead.current)
    )

    if (!match && new Set(typeahead.current).size === 1) {
      typeahead.current = typeahead.current.at(-1)
      match = ordered.find((item) =>
        itemText(item).startsWith(typeahead.current)
      )
    }

    if (match) focusItem(match)
    return true
  }

  function handleKeydown(event) {
    const items = enabledItems()
    const currentIndex = items.indexOf(focusedElement())
    let nextIndex

    if (event.key === 'Escape') {
      event.preventDefault()
      event.stopPropagation()
      closeMenu({ restoreFocus: true })
    } else if (event.key === 'Tab') {
      event.preventDefault()
      clearTypeahead()
      restoreOnClose.current = false
      tabExit.current = {
        pending: true,
        target: adjacentTabStop(event.shiftKey)
      }
      closeMenu()
    } else if (event.key === 'ArrowDown') {
      nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % items.length
    } else if (event.key === 'ArrowUp') {
      nextIndex =
        currentIndex < 0
          ? items.length - 1
          : (currentIndex - 1 + items.length) % items.length
    } else if (event.key === 'Home') {
      nextIndex = 0
    } else if (event.key === 'End') {
      nextIndex = items.length - 1
    } else if (!handleTypeahead(event)) {
      onKeyDown?.(event)
      return
    }

    if (nextIndex !== undefined && items.length) {
      event.preventDefault()
      focusItem(items[nextIndex])
    }
    onKeyDown?.(event)
  }

  return (
    <Popover
      {...contentProps}
      ref={popoverRef}
      id={id}
      open={isOpen}
      onOpenChange={requestOpen}
      placement={placement}
      offset={offset}
      role="menu"
      tabIndex={-1}
      data-slot="menu"
      className={twMerge('min-w-40 p-1', className)}
      onClickCapture={handleClick}
      onKeyDown={handleKeydown}
    >
      {typeof children === 'function'
        ? children({ open: isOpen, close: closeMenu })
        : children}
    </Popover>
  )
})

export default Menu

Svelte source

Menu.svelte
<script>
  import { onMount, untrack } from "svelte";
  import { twMerge } from "tailwind-merge";
  import Popover from "../popover/Popover.svelte";

  const TABBABLE_SELECTOR =
    'a[href], button, input, select, textarea, [tabindex], [contenteditable="true"]';

  let {
    id,
    open = $bindable(),
    defaultOpen = false,
    onOpenChange,
    placement = "bottom-start",
    offset = 8,
    class: className = "",
    children,
    onkeydown,
    onclickcapture,
    ...contentProps
  } = $props();

  let popoverElement = $state();
  let internalOpen = $state(untrack(() => defaultOpen));
  let activeInvoker = $state();
  let isOpen = $derived(open ?? internalOpen);
  let pendingFocus = "first";
  let restoreOnClose = false;
  let tabExitPending = false;
  let tabExitTarget;
  let typeahead = "";
  let typeaheadTimer;

  function contentElement() {
    return popoverElement?.getContent?.();
  }

  function eventPath(event) {
    return event.composedPath?.() ?? [event.target];
  }

  function invokers() {
    const content = contentElement();
    const root = content?.getRootNode?.() ?? document;

    return [...(root.querySelectorAll?.("[popovertarget]") ?? [])].filter(
      (element) => element.getAttribute("popovertarget") === content?.id,
    );
  }

  function syncInvokerSemantics() {
    for (const invoker of invokers()) {
      invoker.setAttribute("aria-haspopup", "menu");
    }
  }

  function matchingInvoker(event) {
    const contentId = contentElement()?.id;
    return eventPath(event).find(
      (element) => element?.getAttribute?.("popovertarget") === contentId,
    );
  }

  function restoreInvokerFocus() {
    const invoker = activeInvoker?.isConnected ? activeInvoker : invokers()[0];
    invoker?.focus?.({ preventScroll: true });
  }

  function adjacentTabStop(backward) {
    const content = contentElement();
    const invoker = activeInvoker?.isConnected ? activeInvoker : invokers()[0];
    let anchor = invoker;
    let root = content?.getRootNode?.() ?? document;

    while (anchor && root) {
      const stops = [
        ...(root.querySelectorAll?.(TABBABLE_SELECTOR) ?? []),
      ].filter(
        (element) =>
          !content?.contains(element) &&
          element.tabIndex >= 0 &&
          !element.matches(":disabled") &&
          !element.closest("[hidden], [inert]"),
      );
      const current = stops.indexOf(anchor);
      let target;

      if (current >= 0) {
        target = stops[current + (backward ? -1 : 1)];
      } else {
        const candidates = stops.filter((element) => {
          const relation = anchor.compareDocumentPosition(element);
          return backward ? Boolean(relation & 2) : Boolean(relation & 4);
        });
        target = backward ? candidates.at(-1) : candidates[0];
      }

      if (target) return target;
      if (!root.host) return undefined;
      anchor = root.host;
      root = anchor.getRootNode?.();
    }

    return undefined;
  }

  function completeTabExit() {
    if (tabExitTarget?.isConnected) {
      tabExitTarget.focus({ preventScroll: true });
    } else {
      const root = contentElement()?.getRootNode?.() ?? document;
      root.activeElement?.blur?.();
    }

    tabExitTarget = undefined;
    tabExitPending = false;
  }

  function itemRole(element) {
    return ["menuitem", "menuitemcheckbox", "menuitemradio"].includes(
      element.getAttribute("role"),
    );
  }

  function menuItems() {
    const content = contentElement();
    if (!content) return [];

    for (const element of content.querySelectorAll("button, a[href]")) {
      if (!element.hasAttribute("role"))
        element.setAttribute("role", "menuitem");
    }

    const items = [
      ...content.querySelectorAll(
        '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]',
      ),
    ].filter((element) => element.closest('[role="menu"]') === content);

    for (const item of items) item.tabIndex = -1;
    return items;
  }

  function itemIsDisabled(item) {
    return (
      item.matches(":disabled") ||
      item.getAttribute("aria-disabled") === "true" ||
      item.hidden ||
      item.closest("[hidden]") !== null
    );
  }

  function enabledItems() {
    return menuItems().filter((item) => !itemIsDisabled(item));
  }

  function focusedElement() {
    return (
      contentElement()?.getRootNode?.().activeElement ?? document.activeElement
    );
  }

  function focusItem(item) {
    if (!item) return;
    for (const candidate of menuItems()) candidate.tabIndex = -1;
    item.tabIndex = 0;
    item.focus({ preventScroll: true });
  }

  function focusEdge(edge = "first") {
    const items = enabledItems();
    const item = edge === "last" ? items.at(-1) : items[0];
    if (item) focusItem(item);
    else contentElement()?.focus({ preventScroll: true });
  }

  function clearTypeahead() {
    typeahead = "";
    clearTimeout(typeaheadTimer);
    typeaheadTimer = undefined;
  }

  function normalizedText(item) {
    return (item.getAttribute("aria-label") ?? item.textContent ?? "")
      .trim()
      .toLocaleLowerCase();
  }

  function handleTypeahead(event) {
    if (
      event.key.length !== 1 ||
      event.key === " " ||
      event.altKey ||
      event.ctrlKey ||
      event.metaKey
    ) {
      return false;
    }

    event.preventDefault();
    clearTimeout(typeaheadTimer);
    typeahead += event.key.toLocaleLowerCase();
    typeaheadTimer = setTimeout(clearTypeahead, 500);

    const items = enabledItems();
    if (!items.length) return true;
    const current = items.indexOf(focusedElement());
    const ordered = [
      ...items.slice(current + 1),
      ...items.slice(0, current + 1),
    ];
    let match = ordered.find((item) =>
      normalizedText(item).startsWith(typeahead),
    );

    if (!match && new Set(typeahead).size === 1) {
      typeahead = typeahead.at(-1);
      match = ordered.find((item) =>
        normalizedText(item).startsWith(typeahead),
      );
    }

    if (match) focusItem(match);
    return true;
  }

  function requestOpen(nextOpen) {
    if (open === undefined) internalOpen = nextOpen;
    else open = nextOpen;
    onOpenChange?.(nextOpen);
  }

  function openMenu(edge = "first") {
    pendingFocus = edge;
    if (isOpen) focusEdge(edge);
    else requestOpen(true);
  }

  function closeMenu({ restoreFocus = false } = {}) {
    restoreOnClose ||= restoreFocus;
    if (isOpen) requestOpen(false);
    else if (restoreOnClose) {
      restoreOnClose = false;
      queueMicrotask(restoreInvokerFocus);
    }
  }

  function itemFromEvent(event) {
    const content = contentElement();
    return eventPath(event).find(
      (element) =>
        element?.nodeType === 1 &&
        itemRole(element) &&
        element.closest?.('[role="menu"]') === content,
    );
  }

  function handleClick(event) {
    const item = itemFromEvent(event);
    if (!item) {
      onclickcapture?.(event);
      return;
    }

    if (itemIsDisabled(item)) {
      event.preventDefault();
      event.stopImmediatePropagation();
      return;
    }

    closeMenu({ restoreFocus: true });
    onclickcapture?.(event);
  }

  function handleKeydown(event) {
    const items = enabledItems();
    const currentIndex = items.indexOf(focusedElement());
    let nextIndex;

    if (event.key === "Escape") {
      event.preventDefault();
      event.stopPropagation();
      closeMenu({ restoreFocus: true });
    } else if (event.key === "Tab") {
      event.preventDefault();
      clearTypeahead();
      restoreOnClose = false;
      tabExitTarget = adjacentTabStop(event.shiftKey);
      tabExitPending = true;
      closeMenu();
    } else if (event.key === "ArrowDown") {
      nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % items.length;
    } else if (event.key === "ArrowUp") {
      nextIndex =
        currentIndex < 0
          ? items.length - 1
          : (currentIndex - 1 + items.length) % items.length;
    } else if (event.key === "Home") {
      nextIndex = 0;
    } else if (event.key === "End") {
      nextIndex = items.length - 1;
    } else if (!handleTypeahead(event)) {
      onkeydown?.(event);
      return;
    }

    if (nextIndex !== undefined && items.length) {
      event.preventDefault();
      focusItem(items[nextIndex]);
    }
    onkeydown?.(event);
  }

  $effect(() => {
    const nextOpen = isOpen;
    queueMicrotask(() => {
      syncInvokerSemantics();

      if (nextOpen) {
        focusEdge(pendingFocus);
        pendingFocus = "first";
        return;
      }

      clearTypeahead();
      menuItems();
      if (tabExitPending) completeTabExit();
      else if (restoreOnClose) restoreInvokerFocus();
      restoreOnClose = false;
    });
  });

  onMount(() => {
    const content = contentElement();
    const root = content?.getRootNode?.() ?? document;

    function rememberInvoker(event) {
      const invoker = matchingInvoker(event);
      if (invoker) activeInvoker = invoker;
    }

    function handleInvokerKeydown(event) {
      const invoker = matchingInvoker(event);
      if (!invoker || invoker.matches(":disabled")) return;
      activeInvoker = invoker;

      if (event.key === "ArrowDown" || event.key === "ArrowUp") {
        event.preventDefault();
        openMenu(event.key === "ArrowUp" ? "last" : "first");
      }
    }

    root.addEventListener("keydown", handleInvokerKeydown);
    root.addEventListener("click", rememberInvoker, true);
    syncInvokerSemantics();
    menuItems();

    const observer =
      typeof MutationObserver !== "undefined" && content
        ? new MutationObserver(menuItems)
        : undefined;
    observer?.observe(content, { childList: true, subtree: true });

    return () => {
      observer?.disconnect();
      root.removeEventListener("keydown", handleInvokerKeydown);
      root.removeEventListener("click", rememberInvoker, true);
      clearTypeahead();
    };
  });
</script>

<Popover
  {...contentProps}
  bind:this={popoverElement}
  {id}
  open={isOpen}
  onOpenChange={requestOpen}
  {placement}
  {offset}
  role="menu"
  tabindex={-1}
  data-slot="menu"
  class={twMerge("min-w-40 p-1", className)}
  onclickcapture={handleClick}
  onkeydown={handleKeydown}
>
  {@render children?.({ open: isOpen, close: closeMenu })}
</Popover>

Accessibility and Durable UI contract

  • The invoker remains a real button and automatically receives aria-haspopup="menu", aria-controls, and synchronized aria-expanded.
  • Button and anchor children keep their truthful native activation while Menu supplies composite roles and roving focus.
  • Disabled items cannot activate and never become the active roving focus stop.
  • Escape and selection restore focus only when the invoker still exists; Tab exits forward or backward in composed document order; outside interaction does not steal focus.
  • Keyboard behavior remains correct in RTL layouts and as items change.
  • Menu open state is ephemeral and is never written to storage, cookies, server data, or the URL.
  • Meaningful state selected from a menu follows the Durable UI contract; appearance follows the application-owned theming convention.
  • Popover — a generic non-modal surface without menu semantics.
  • Button — the truthful invoker and action element.
  • Select — a fixed-list persistent value rather than an action.
  • Dialog — modal content that makes the background inert.

All open source projects are released under the MIT License.