DropdownMenu

ui overlaymenu

Headless Popover-Menü: render-prop-Trigger, Items und Separatoren. Schließt bei Klick-außerhalb und Escape. Ausrichtung (links/rechts), Aufklapprichtung (down/up) und Mindestbreite konfigurierbar.

Gewählt:

Abhängigkeiten

Barrierefreiheit

  • Menü: role="menu" + aria-label; Items: role="menuitem"; Separator: role="separator".
  • Trigger sollte aria-haspopup="menu" und aria-expanded setzen (siehe FilterDropdown).
  • Hinweis: keine Roving-Tabindex-/Pfeiltasten-Navigation — bei Bedarf ergänzen oder React Aria <Menu> erwägen.

Beispiele

Aktionsmenü
<DropdownMenu
  ariaLabel="Aktionen"
  trigger={({ open, toggle }) => (
    <Button aria-expanded={open} onClick={toggle}>Mehr</Button>
  )}
>
  <DropdownMenuItem onSelect={rename}>Umbenennen</DropdownMenuItem>
  <DropdownMenuSeparator />
  <DropdownMenuItem onSelect={remove}>Löschen</DropdownMenuItem>
</DropdownMenu>

Genutzte Tokens · 11

--bg--border--elev-overlay--fg--font-body--muted--radius-md--radius-sm--space-2--surface--text-sm

Quelldateien

components/ui/DropdownMenu.tsx tsx
import {
  createContext,
  useContext,
  useEffect,
  useRef,
  useState,
  type ReactNode,
} from 'react';
import styles from './DropdownMenu.module.css';

interface DropdownContext {
  close: () => void;
}

const Ctx = createContext<DropdownContext | null>(null);

interface DropdownMenuProps {
  ariaLabel: string;
  align?: 'left' | 'right';
  /** Aufklapp-Richtung. `'down'` (Default) öffnet unter dem Trigger, `'up'`
   *  darüber — z. B. für eine Aktionsleiste am unteren Rand. */
  direction?: 'down' | 'up';
  /** Feste Mindestbreite in px (überschreibt das CSS-Default von 180px). Sinnvoll,
   *  wenn der Inhalt seine Breite ändert und das Menü nicht springen soll. */
  minWidth?: number;
  trigger: (state: { open: boolean; toggle: () => void }) => ReactNode;
  children: ReactNode;
}

export function DropdownMenu({
  ariaLabel,
  align = 'right',
  direction = 'down',
  minWidth,
  trigger,
  children,
}: DropdownMenuProps) {
  const [open, setOpen] = useState(false);
  const wrapRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!open) return;
    const onMouseDown = (e: MouseEvent) => {
      if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setOpen(false);
    };
    const onKeyDown = (e: KeyboardEvent) => {
      if (e.key === 'Escape') setOpen(false);
    };
    document.addEventListener('mousedown', onMouseDown);
    document.addEventListener('keydown', onKeyDown);
    return () => {
      document.removeEventListener('mousedown', onMouseDown);
      document.removeEventListener('keydown', onKeyDown);
    };
  }, [open]);

  const close = () => setOpen(false);
  const toggle = () => setOpen((v) => !v);

  return (
    <div className={styles.wrap} ref={wrapRef}>
      {trigger({ open, toggle })}
      {open && (
        <div
          className={`${styles.menu} ${align === 'left' ? styles.alignLeft : styles.alignRight} ${direction === 'up' ? styles.dropUp : ''}`}
          style={minWidth != null ? { minWidth } : undefined}
          role="menu"
          aria-label={ariaLabel}
        >
          <Ctx.Provider value={{ close }}>{children}</Ctx.Provider>
        </div>
      )}
    </div>
  );
}

interface DropdownMenuItemProps {
  onSelect?: () => void;
  disabled?: boolean;
  title?: string;
  /** Ob das Menü nach der Auswahl schließt. Default `true`. Auf `false` setzen
   *  für In-Place-Aktionen (z. B. Theme umschalten), deren Wirkung man bei
   *  offenem Menü sehen will. */
  closeOnSelect?: boolean;
  children: ReactNode;
}

export function DropdownMenuItem({
  onSelect,
  disabled,
  title,
  closeOnSelect = true,
  children,
}: DropdownMenuItemProps) {
  const ctx = useContext(Ctx);
  return (
    <button
      type="button"
      role="menuitem"
      className={styles.item}
      disabled={disabled}
      title={title}
      onClick={() => {
        if (disabled) return;
        onSelect?.();
        if (closeOnSelect) ctx?.close();
      }}
    >
      {children}
    </button>
  );
}

export function DropdownMenuSeparator() {
  return <div role="separator" className={styles.separator} />;
}
components/ui/DropdownMenu.module.css css
.wrap {
  position: relative;
  display: inline-flex;
}

.menu {
  position: absolute;
  top: calc(100% + 6px);
  min-width: 180px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  box-shadow: var(--elev-overlay);
  padding: 6px;
  z-index: 50;
  /* Reset typography that may be inherited from parent containers
     (e.g. uppercase / mono / wide tracking on section titles). */
  font-family: var(--font-body);
  font-size: var(--text-sm);
  letter-spacing: normal;
  text-transform: none;
  color: var(--fg);
}
.alignRight {
  right: 0;
}
.alignLeft {
  left: 0;
}
/* Nach oben aufklappen (Trigger sitzt am unteren Rand). */
.dropUp {
  top: auto;
  bottom: calc(100% + 6px);
}

.item {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  width: 100%;
  padding: 8px 10px;
  border: 0;
  background: transparent;
  border-radius: var(--radius-sm);
  font: inherit;
  color: inherit;
  cursor: pointer;
  text-align: left;
  white-space: nowrap;
}
.item:hover {
  background: var(--bg);
}
.item:focus-visible {
  outline: none;
  background: var(--bg);
}
.item:disabled {
  color: var(--muted);
  cursor: not-allowed;
  opacity: 0.55;
}
.item:disabled:hover {
  background: transparent;
}

.separator {
  height: 1px;
  margin: 4px 6px;
  background: var(--border);
}

Holen

# MCP (Claude Code / Cursor)
get_component({ name: "dropdown-menu" })

# Registry direkt
https://www.ekklesi.tools/design/registry/components/dropdown-menu.json