Skip to content
UI Kit
Accent color

Dropdown menu for React

Menus with icons, shortcuts, checkboxes and radios. Arrow keys, Home, End, typeahead, Escape. Plus a context menu.

One of 9 components in Overlays

3 variants

Actions, account and context menu

A dropdown menu tucks secondary actions behind one button, so the interface stays calm. This one follows the menu pattern people know from the desktop, arrow keys move, Home and End jump, typing a letter finds the item, Escape closes and returns focus.

Actions is the classic more menu with icons, shortcuts, a separator and a destructive item at the end. Account shows the person, a few links and sign out, the menu behind an avatar. Context menu opens on right click or the menu key with the actions for the item under the pointer, including checkbox and radio items for view options.

Put the most used action first and destructive ones last, set apart. Keep menus under ten items, group longer ones with labels. A menu holds actions, for choosing a value in a form use a select instead.

Variants

Details

What Dropdown menu brings along, read from the registry and the source.

Category
Overlays
Variants
3
Version
1.0.1 · since 1.0.0
File
components/ui/dropdown-menu.tsx
Brings along
utilskiticonpopover
npm packages
None
Languages
Texts come through props
Studio
Adjust in code

Props

The DropdownMenuProps interface from components/ui/dropdown-menu.tsx, with the comments from the code.

  • triggerrequired
    React.ReactElement

    The button that opens the menu

  • itemsrequired
    MenuItem[]
  • headeroptional
    React.ReactNode
  • sideoptional
    Side
  • alignoptional
    Align
  • openoptional
    boolean
  • defaultOpenoptional
    boolean
  • onOpenChangeoptional
    (open: boolean) => void

Accessibility

Read from the source of the component.

  • Dropdown menu handles the keys Enter, Arrow up, Arrow down, Home, End, Tab and Space itself.
  • Sets aria-disabled and aria-labelledby, so screen readers announce purpose and state.
  • Uses the roles menu, presentation and separator.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 2 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 310 lines
components/ui/dropdown-menu.tsx
'use client';

import { cloneElement, isValidElement, useCallback, useEffect, useId, useRef, useState } from 'react';
import { cn } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { Floating, floatingPop, originFor, panelSurface, useDismiss, useMounted, useOpen, usePresence, type Align, type Side } from '@/components/ui/popover';

/* Items arrive one after another, a few pixels from the trigger side, when a menu opens */
const CSS = '.kitfx-menu-item{animation:kitfx-menu-in .32s var(--ease-kit) both;animation-delay:calc(var(--i,0) * 24ms + 30ms)}@keyframes kitfx-menu-in{from{opacity:0;transform:translateY(-4px)}}@media (prefers-reduced-motion:reduce){.kitfx-menu-item{animation:none}}';

export type MenuItem =
  | { type?: 'item'; label: string; icon?: IconName; description?: string; shortcut?: string; href?: string; disabled?: boolean; destructive?: boolean; onSelect?: () => void }
  | { type: 'checkbox'; label: string; icon?: IconName; checked: boolean; onCheckedChange?: (checked: boolean) => void; disabled?: boolean }
  | { type: 'radio'; label: string; value: string; group: string; checked: boolean; onSelect?: (value: string) => void; disabled?: boolean }
  | { type: 'label'; label: string }
  | { type: 'separator' };

type Actionable = Exclude<MenuItem, { type: 'label' } | { type: 'separator' }>;
interface MenuListProps {
  items: MenuItem[];
  /** Something above the items, e.g. the signed-in user */
  header?: React.ReactNode;
  onClose: (focusTrigger: boolean) => void;
  /** Focus the first (or last) item on mount */
  autoFocus?: 'first' | 'last' | 'none' | 'skip';
  labelledBy?: string;
  id?: string;
  className?: string;
  style?: React.CSSProperties;
  /** Set while the menu animates out */
  closing?: boolean;
}

/** The menu panel with full keyboard support: arrows, Home, End, typeahead, Enter, Space, Escape, Tab. */
export function MenuList({ items, header, onClose, autoFocus = 'first', labelledBy, id, className, style, closing }: MenuListProps) {
  const ref = useRef<HTMLDivElement>(null);
  const typed = useRef({ text: '', at: 0 });
  const nodes = () => Array.from(ref.current?.querySelectorAll<HTMLElement>('[data-menu-item]:not([aria-disabled="true"])') ?? []);

  useEffect(() => {
    // After the event that opened the menu, so its own focus handling cannot win
    if (autoFocus === 'skip') return;
    const id = requestAnimationFrame(() => {
      if (autoFocus === 'none') return ref.current?.focus();
      const list = nodes();
      (autoFocus === 'last' ? list[list.length - 1] : list[0])?.focus();
    });
    return () => cancelAnimationFrame(id);
  }, [autoFocus]);

  const move = (delta: number | 'first' | 'last') => {
    const list = nodes();
    if (!list.length) return;
    const i = list.indexOf(document.activeElement as HTMLElement);
    const next = delta === 'first' ? 0 : delta === 'last' ? list.length - 1 : (i + delta + list.length) % list.length;
    list[next].focus();
  };

  const onKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === 'ArrowDown') { e.preventDefault(); move(1); }
    else if (e.key === 'ArrowUp') { e.preventDefault(); move(-1); }
    else if (e.key === 'Home') { e.preventDefault(); move('first'); }
    else if (e.key === 'End') { e.preventDefault(); move('last'); }
    else if (e.key === 'Tab') onClose(false);
    else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey && e.key !== ' ') {
      const now = Date.now();
      typed.current = { text: (now - typed.current.at > 600 ? '' : typed.current.text) + e.key.toLowerCase(), at: now };
      const hit = nodes().find((n) => n.textContent?.trim().toLowerCase().startsWith(typed.current.text));
      hit?.focus();
    }
  };

  const select = (item: Actionable) => {
    if (item.disabled) return;
    if (item.type === 'checkbox') {
      item.onCheckedChange?.(!item.checked);
      return; // stays open, so several boxes can be toggled
    }
    if (item.type === 'radio') item.onSelect?.(item.value);
    else item.onSelect?.();
    onClose(true);
  };

  return (
    <div
      ref={ref}
      id={id}
      role="menu"
      aria-labelledby={labelledBy}
      tabIndex={-1}
      onKeyDown={onKeyDown}
      data-closing={closing || undefined}
      inert={closing || undefined}
      className={cn('min-w-56 max-w-[calc(100vw-16px)] p-1.5 outline-none', panelSurface, className)}
      style={style}
    >
      <style href="kitfx-menu" precedence="default">
        {CSS}
      </style>
      {header && <div className="kitfx-menu-item -mx-1.5 -mt-1.5 mb-1.5 border-b border-border px-4 py-3">{header}</div>}
      {items.map((item, i) => {
        const order = { '--i': i + (header ? 1 : 0) } as React.CSSProperties;
        if (item.type === 'separator') return <div key={i} role="separator" className="kitfx-menu-item -mx-1.5 my-1.5 h-px bg-border" style={order} />;
        if (item.type === 'label')
          return (
            <div key={i} role="presentation" style={order} className="kitfx-menu-item px-2.5 pb-1 pt-2 text-[11px] font-medium uppercase tracking-[0.08em] text-subtle-foreground">
              {item.label}
            </div>
          );
        const role = item.type === 'checkbox' ? 'menuitemcheckbox' : item.type === 'radio' ? 'menuitemradio' : 'menuitem';
        const checked = item.type === 'checkbox' || item.type === 'radio' ? item.checked : undefined;
        const destructive = item.type === undefined || item.type === 'item' ? item.destructive : false;
        const icon = item.type === 'radio' ? undefined : item.icon;
        const common = {
          role,
          tabIndex: -1,
          'data-menu-item': '',
          'aria-disabled': item.disabled || undefined,
          'aria-checked': checked,
          style: order,
          onClick: () => select(item),
          onKeyDown: (e: React.KeyboardEvent) => {
            if (e.key === 'Enter' || e.key === ' ') {
              e.preventDefault();
              select(item);
              if (e.key === 'Enter' && (item.type === undefined || item.type === 'item') && item.href) window.location.assign(item.href);
            }
          },
          onPointerMove: (e: React.PointerEvent<HTMLElement>) => e.pointerType === 'mouse' && !item.disabled && e.currentTarget.focus(),
          className: cn(
            'kitfx-menu-item group/item relative flex min-h-9 w-full cursor-pointer select-none items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-foreground outline-none transition-colors max-sm:min-h-11',
            'focus:bg-accent aria-disabled:pointer-events-none aria-disabled:opacity-45',
            destructive && 'text-destructive focus:bg-[color-mix(in_oklab,var(--destructive)_10%,transparent)]',
          ),
        };
        const content = (
          <>
            {(item.type === 'checkbox' || item.type === 'radio') && (
              <span className="grid size-4 shrink-0 place-items-center text-foreground" aria-hidden>
                <span className={cn('grid place-items-center transition-[scale,opacity] duration-200 ease-[cubic-bezier(0.34,1.56,0.64,1)] motion-reduce:transition-none', item.checked ? 'scale-100 opacity-100' : 'scale-50 opacity-0')}>
                  {item.type === 'checkbox' ? <Icon name="check" size={15} strokeWidth={2} /> : <span className="size-[7px] rounded-full bg-current" />}
                </span>
              </span>
            )}
            {icon && <Icon name={icon} size={16} className={cn('shrink-0 transition-[translate,color] duration-200 ease-[var(--ease-kit)] group-focus/item:translate-x-0.5 motion-reduce:transition-none', destructive ? 'text-destructive' : 'text-muted-foreground group-focus/item:text-foreground')} />}
            <span className="min-w-0 flex-1">
              <span className="block truncate">{item.label}</span>
              {(item.type === undefined || item.type === 'item') && item.description && <span className="block truncate text-xs text-muted-foreground">{item.description}</span>}
            </span>
            {(item.type === undefined || item.type === 'item') && item.shortcut && (
              <span className="ms-4 shrink-0 text-xs tracking-wide text-subtle-foreground">{item.shortcut}</span>
            )}
          </>
        );
        if ((item.type === undefined || item.type === 'item') && item.href)
          return (
            <a key={i} href={item.href} {...common}>
              {content}
            </a>
          );
        return (
          <div key={i} {...common}>
            {content}
          </div>
        );
      })}
    </div>
  );
}

export interface DropdownMenuProps {
  /** The button that opens the menu */
  trigger: React.ReactElement;
  items: MenuItem[];
  header?: React.ReactNode;
  side?: Side;
  align?: Align;
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  className?: string;
}

/**
 * A menu of actions behind a button.
 *   <DropdownMenu trigger={<Button variant="outline">Options</Button>} items={[{ label: 'Edit', icon: 'edit', shortcut: '⌘E' }, { type: 'separator' }, …]} />
 */
export function DropdownMenu({ trigger, items, header, side = 'bottom', align = 'start', open: openProp, defaultOpen, onOpenChange, className }: DropdownMenuProps) {
  const [open, setOpen] = useOpen(openProp, defaultOpen, onOpenChange);
  const mounted = useMounted();
  const anchorRef = useRef<HTMLSpanElement>(null);
  const panelRef = useRef<HTMLDivElement>(null);
  const [focus, setFocus] = useState<'first' | 'last' | 'none' | 'skip'>(defaultOpen ? 'skip' : 'none');
  const id = useId();
  const triggerId = `${id}-trigger`;
  const { present, closing } = usePresence(open, 150);

  const focusTrigger = () => anchorRef.current?.querySelector<HTMLElement>('button,a,[tabindex]')?.focus();
  const close = useCallback(
    (reason?: 'escape' | 'outside') => {
      setOpen(false);
      if (reason === 'escape') focusTrigger();
    },
    [setOpen],
  );
  const refs = useRef([anchorRef, panelRef]).current;
  useDismiss(open, close, refs);

  const triggerEl = isValidElement<Record<string, unknown>>(trigger)
    ? cloneElement(trigger, {
        id: triggerId,
        'aria-haspopup': 'menu',
        'aria-expanded': open,
        'aria-controls': open ? id : undefined,
        onClick: (e: React.MouseEvent) => {
          (trigger.props.onClick as ((e: React.MouseEvent) => void) | undefined)?.(e);
          setFocus(e.detail === 0 ? 'first' : 'none');
          setOpen(!open);
        },
        onKeyDown: (e: React.KeyboardEvent) => {
          if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
            e.preventDefault();
            setFocus(e.key === 'ArrowDown' ? 'first' : 'last');
            setOpen(true);
          }
        },
      })
    : trigger;

  return (
    <>
      <span ref={anchorRef} className="inline-flex">
        {triggerEl}
      </span>
      {mounted && present && (
        <Floating anchor={anchorRef} side={side} align={align} offset={6} ref={panelRef}>
          {(pos) => (
            <MenuList
              closing={closing}
              id={id}
              labelledBy={triggerId}
              items={items}
              header={header}
              autoFocus={focus}
              onClose={(f) => {
                setOpen(false);
                if (f) focusTrigger();
              }}
              className={cn(floatingPop, className)}
              style={{ '--origin': originFor(pos, side) } as React.CSSProperties}
            />
          )}
        </Floating>
      )}
    </>
  );
}

/**
 * A menu at the pointer on right click, or with the context menu key and Shift F10.
 *   <ContextMenu items={…}><div>Right click here</div></ContextMenu>
 */
export function ContextMenu({ items, header, className, children }: { items: MenuItem[]; header?: React.ReactNode; className?: string; children: React.ReactNode }) {
  const [at, setAt] = useState<{ x: number; y: number } | null>(null);
  const mounted = useMounted();
  const areaRef = useRef<HTMLDivElement>(null);
  const panelRef = useRef<HTMLDivElement>(null);
  const close = useCallback(() => setAt(null), []);
  const refs = useRef([panelRef]).current;
  useDismiss(Boolean(at), close, refs);
  // The last position stays while the menu leaves
  const last = useRef(at);
  if (at) last.current = at;
  const { present, closing } = usePresence(Boolean(at), 150);
  const anchor = useCallback(() => (last.current ? new DOMRect(last.current.x, last.current.y, 0, 0) : null), [at]);

  return (
    <div
      ref={areaRef}
      tabIndex={0}
      className={cn('rounded-xl', className)}
      onContextMenu={(e) => {
        e.preventDefault();
        if (e.clientX === 0 && e.clientY === 0) {
          const r = e.currentTarget.getBoundingClientRect();
          setAt({ x: r.left + 16, y: r.top + 16 });
        } else setAt({ x: e.clientX, y: e.clientY });
      }}
    >
      {children}
      {mounted && present && (
        <Floating anchor={anchor} side="bottom" align="start" offset={2} ref={panelRef}>
          {() => (
            <MenuList
              closing={closing}
              items={items}
              header={header}
              autoFocus="none"
              onClose={(f) => {
                setAt(null);
                if (f) areaRef.current?.focus();
              }}
              className={cn(floatingPop, '[--origin:top_left]')}
            />
          )}
        </Floating>
      )}
    </div>
  );
}

Frequent questions

How do I install Dropdown menu?

With aiw add @ai-website/dropdown-menu. The command puts components/ui/dropdown-menu.tsx into your project, the code is yours from then on. It brings along icon and popover. It needs no further npm packages.

Which variants does Dropdown menu have?

3 variants, Actions, Account and Context menu.

Can an AI assistant build with Dropdown menu?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Dropdown menu with aiw_components and install it with aiw_add @ai-website/dropdown-menu.

Often searched

Jump to

Copied to the clipboard