Design system
GitHub

Component

Dropdown menu

Action menu with standard and glass panels, submenus and selection controls.

npx -y github:anwarlemu/agentcard-brand-cli#release add dropdown-menu
Open alone
dropdown-menu.preview
Preview
Open alone
dropdown-menu.choices
Choices
Open alone
dropdown-menu.glass
Glass
Open alone
dropdown-menu.open
Open

Usage

use-it-for.md

Use for actions on an item or a compact navigation menu. Set variant="glass" on blue backgrounds. Use onSelect for actions and DropdownMenuHoldItem for destructive actions that require a hold.

never.md
  • Menus for navigation that should be visible
  • More than one level of submenu
  • A destructive row that fires on one click

Dependencies: use-hold, surface, @radix-ui/react-dropdown-menu, lucide-react.

Example

examples/dropdown-menu.tsx
"use client";

import * as React from "react";

import { Button } from "@/components/ui/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuCheckboxItem,
  DropdownMenuSub,
  DropdownMenuSubContent,
  DropdownMenuSubTrigger,
  DropdownMenuHoldItem,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuShortcut,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Field } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { CopyButton } from "@/components/ui/copy-button";
import { Surface } from "@/lib/surface";

function CardMenu({ defaultOpen = false }: { defaultOpen?: boolean }) {
  const [open, setOpen] = React.useState(defaultOpen);
  const [paused, setPaused] = React.useState(false);
  const [closed, setClosed] = React.useState(false);
  const [limit, setLimit] = React.useState(250);
  const [balance, setBalance] = React.useState(500);
  const [editing, setEditing] = React.useState<"limit" | "funds" | null>(null);
  const [message, setMessage] = React.useState("");
  return (
    <div className="grid justify-items-start gap-3">
    <DropdownMenu open={open} onOpenChange={setOpen} modal={false}>
      <DropdownMenuTrigger asChild>
        <Button variant="secondary" disabled={closed}>Card actions</Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="start" onOpenAutoFocus={defaultOpen ? (event) => event.preventDefault() : undefined} onCloseAutoFocus={defaultOpen ? (event) => event.preventDefault() : undefined}>
        <DropdownMenuLabel>Travel agent</DropdownMenuLabel>
        <DropdownMenuItem onSelect={() => setEditing("limit")}>
          Edit limits
          <DropdownMenuShortcut>E</DropdownMenuShortcut>
        </DropdownMenuItem>
        <DropdownMenuItem onSelect={() => setEditing("funds")}>
          Add funds
          <DropdownMenuShortcut>F</DropdownMenuShortcut>
        </DropdownMenuItem>
        <DropdownMenuItem onSelect={() => { setPaused(!paused); setMessage(paused ? "Card resumed." : "Card paused."); }}>{paused ? "Resume card" : "Pause card"}</DropdownMenuItem>
        <DropdownMenuSeparator />
        <DropdownMenuHoldItem onConfirm={() => { setClosed(true); setOpen(false); setEditing(null); setMessage("Card closed."); }}>Close card</DropdownMenuHoldItem>
      </DropdownMenuContent>
    </DropdownMenu>
    {editing && <form key={editing} className="grid gap-3" onSubmit={(event) => {
      event.preventDefault();
      const amount = Number(new FormData(event.currentTarget).get("amount"));
      if (editing === "limit") { setLimit(amount); setMessage(`Purchase limit updated to $${amount.toFixed(2)}.`); }
      else { setBalance(balance + amount); setMessage(`Balance updated to $${(balance + amount).toFixed(2)}.`); }
      setEditing(null);
    }}>
      <Field label={editing === "limit" ? "Purchase limit (USD)" : "Amount to add (USD)"} required>
        <Input name="amount" type="number" min="0.01" step="0.01" defaultValue={editing === "limit" ? limit : 100} />
      </Field>
      <div className="flex gap-2"><Button type="submit">{editing === "limit" ? "Save limit" : "Add funds"}</Button><Button variant="secondary" type="button" onClick={() => setEditing(null)}>Cancel</Button></div>
    </form>}
    <p role="status" className="text-body-sm text-fg-muted empty:hidden">{message}</p>
    {closed && <Button variant="secondary" size="sm" onClick={() => { setClosed(false); setPaused(false); setMessage("Card active."); }}>Reset demo</Button>}
    </div>
  );
}

export function Site() {
  return <CardMenu />;
}

export function Open() {
  return (
    <div className="h-64">
      <CardMenu defaultOpen />
    </div>
  );
}

// The dock's menu: glass over whatever is behind it.
export function Glass() {
  const [product, setProduct] = React.useState("");
  return (
    <Surface ground="blue" className="flex h-80 flex-col items-start gap-4 rounded-panel p-8">
      <DropdownMenu defaultOpen modal={false}>
        <DropdownMenuTrigger asChild>
          <Button variant="on-brand">Products</Button>
        </DropdownMenuTrigger>
        <DropdownMenuContent variant="glass" align="start" className="w-64" onOpenAutoFocus={(event) => event.preventDefault()} onCloseAutoFocus={(event) => event.preventDefault()}>
          <DropdownMenuItem onSelect={() => setProduct("Vault")}>Vault</DropdownMenuItem>
          <DropdownMenuItem onSelect={() => setProduct("Issuing")}>Issuing</DropdownMenuItem>
          <DropdownMenuItem onSelect={() => setProduct("Voice")}>Voice</DropdownMenuItem>
          <DropdownMenuSeparator />
          <DropdownMenuItem onSelect={() => setProduct("Updates")}>
            Updates
            <DropdownMenuShortcut>New</DropdownMenuShortcut>
          </DropdownMenuItem>
        </DropdownMenuContent>
      </DropdownMenu>
      {product && <p role="status" className="text-body-sm">{product === "Vault" ? "Vault stores cards for your agents." : product === "Issuing" ? "Issuing creates a card for each agent." : product === "Voice" ? "Voice lets customers approve purchases by speaking." : "You’re viewing the latest product updates."}</p>}
    </Surface>
  );
}

// A working glass submenu and a persistent preference; the submenu inherits its parent panel's treatment.
export function Choices() {
  const [exported, setExported] = React.useState("");
  const [notifications, setNotifications] = React.useState(true);
  return (
    <Surface ground="blue" className="min-h-80 rounded-panel p-8">
      <DropdownMenu modal={false}>
        <DropdownMenuTrigger asChild><Button variant="on-brand">Preferences</Button></DropdownMenuTrigger>
        <DropdownMenuContent variant="glass" align="start">
          <DropdownMenuCheckboxItem checked={notifications} onCheckedChange={setNotifications}>Notifications</DropdownMenuCheckboxItem>
          <DropdownMenuSub>
            <DropdownMenuSubTrigger>Exports</DropdownMenuSubTrigger>
            <DropdownMenuSubContent>
              <DropdownMenuItem onSelect={() => setExported(`notifications\n${notifications}`)}>CSV</DropdownMenuItem>
              <DropdownMenuItem onSelect={() => setExported(JSON.stringify({ notifications }, null, 2))}>JSON</DropdownMenuItem>
              <DropdownMenuItem disabled>PDF (coming soon)</DropdownMenuItem>
            </DropdownMenuSubContent>
          </DropdownMenuSub>
        </DropdownMenuContent>
      </DropdownMenu>
      {exported && <div className="mt-4 flex items-start gap-3"><pre aria-label="Export preview" className="text-body-sm">{exported}</pre><CopyButton value={exported} label="Copy export" /></div>}
      <p className="mt-56 font-display text-caption" role="status">Notifications {notifications ? "on" : "off"}</p>
    </Surface>
  );
}

Source

components/ui/dropdown-menu.tsx
"use client";

import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight } from "lucide-react";

import { useHold } from "@/hooks/use-hold";
import { usePortalContainer } from "@/lib/surface";
import { cn } from "@/lib/utils";

// The brand's menu: the floating panel (white, a 16 percent ink hairline, 8px corners, the tooltip shadow) with 4px of
// padding, rows in the 14px display face that take a faint wash when highlighted, with no transition, since menus are
// worked from the keyboard. It grows from its trigger (scale 0.97 and a fade, 160ms ease-out). `glass` is the dock's
// menu: ink at 42 percent under a light sheen and a blur, a white hairline, white rows that wash white when highlighted.
// Destructive rows can require a hold (DropdownMenuHoldItem) so a stray click can't delete anything.
const PANEL = {
  default: "border-hairline bg-surface text-fg shadow-tooltip",
  glass: "border-line-on-brand bg-glass bg-(image:--ds-glass-sheen) text-on-brand shadow-floating [backdrop-filter:var(--ds-glass-blur)]",
} as const;

const MenuVariant = React.createContext<keyof typeof PANEL>("default");

const ROW =
  "relative flex w-full cursor-default items-center gap-2 rounded-menu px-2.5 py-2 font-display text-body-sm leading-snug text-fg-secondary outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-40 data-[highlighted]:bg-wash-hover data-[highlighted]:text-fg group-data-[variant=glass]/menu:text-on-brand group-data-[variant=glass]/menu:leading-none group-data-[variant=glass]/menu:px-2.75 group-data-[variant=glass]/menu:py-3 group-data-[variant=glass]/menu:data-[highlighted]:bg-wash-glass-hover group-data-[variant=glass]/menu:data-[highlighted]:shadow-glass-hover group-data-[variant=glass]/menu:data-[highlighted]:text-on-brand [&_svg]:size-3.5 [&_svg]:shrink-0";
const DANGER = "text-danger data-[highlighted]:bg-danger/6 data-[highlighted]:text-danger group-data-[variant=glass]/menu:text-danger group-data-[variant=glass]/menu:data-[highlighted]:text-danger";
const QUIET = "text-fg-muted group-data-[variant=glass]/menu:text-fg-on-brand-muted";

function DropdownMenu(props: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
  return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}

function DropdownMenuTrigger(props: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
  return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
}

function DropdownMenuContent({ className, sideOffset = 6, collisionPadding = 8, align = "end", variant = "default", ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Content> & { variant?: keyof typeof PANEL; onOpenAutoFocus?: (event: Event) => void }) {
  return (
    <DropdownMenuPrimitive.Portal container={usePortalContainer()}>
      <MenuVariant.Provider value={variant}>
        <DropdownMenuPrimitive.Content
          data-slot="dropdown-menu-content"
          data-variant={variant}
          sideOffset={sideOffset}
          collisionPadding={collisionPadding}
          align={align}
          className={cn("group/menu z-50 flex min-w-[min(12rem,var(--radix-dropdown-menu-content-available-width))] max-w-(--radix-dropdown-menu-content-available-width) max-h-(--radix-dropdown-menu-content-available-height) overflow-y-auto overscroll-contain origin-(--radix-dropdown-menu-content-transform-origin) animate-pop-in flex-col gap-0.5 rounded-control border p-1 outline-none", PANEL[variant], className)}
          {...props}
        />
      </MenuVariant.Provider>
    </DropdownMenuPrimitive.Portal>
  );
}

function DropdownMenuGroup(props: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
  return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
}

function DropdownMenuItem({ className, variant = "default", ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & { variant?: "default" | "destructive" }) {
  return <DropdownMenuPrimitive.Item data-slot="dropdown-menu-item" className={cn(ROW, variant === "destructive" && DANGER, className)} {...props} />;
}

/** A row that fires only after being held (destructive by default), its fill running across as the hint. The fill runs
 * at a constant rate while held and snaps back in 200ms ease-out on release. */
function DropdownMenuHoldItem({
  holdMs = 2000,
  onConfirm,
  variant = "destructive",
  children,
  className,
  ...props
}: Omit<React.ComponentProps<typeof DropdownMenuPrimitive.Item>, "onSelect"> & { holdMs?: number; onConfirm: () => void; variant?: "default" | "destructive" }) {
  const { isHolding, handlers } = useHold(holdMs, onConfirm, props.disabled);
  const danger = variant === "destructive";
  return (
    <DropdownMenuPrimitive.Item
      data-slot="dropdown-menu-hold-item"
      onSelect={(event) => event.preventDefault()}
      className={cn(ROW, "overflow-hidden", danger && DANGER, className)}
      {...props}
      {...handlers}
      onPointerDown={(event) => { props.onPointerDown?.(event); handlers.onPointerDown(event); }}
      onKeyDown={(event) => { props.onKeyDown?.(event); handlers.onKeyDown(event); }}
      onPointerUp={(event) => { handlers.onPointerUp(); props.onPointerUp?.(event); }}
      onPointerLeave={(event) => { handlers.onPointerLeave(); props.onPointerLeave?.(event); }}
      onPointerCancel={(event) => { handlers.onPointerCancel(); props.onPointerCancel?.(event); }}
      onKeyUp={(event) => { handlers.onKeyUp(event); props.onKeyUp?.(event); }}
      onBlur={(event) => { handlers.onBlur(); props.onBlur?.(event); }}
    >
      <span
        aria-hidden
        className={cn("absolute inset-0 origin-left transition-transform motion-reduce:transition-none", danger ? "bg-danger/12" : "bg-wash-pill", isHolding ? "scale-x-100 ease-linear" : "scale-x-0 duration-200 ease-out")}
        style={isHolding ? { transitionDuration: `${holdMs}ms` } : undefined}
      />
      <span className="relative flex flex-1 items-center gap-2">{isHolding ? "Keep holding" : children}</span>
      <span className={cn("relative ml-4 text-caption", QUIET)}>Hold {holdMs / 1000}s</span>
    </DropdownMenuPrimitive.Item>
  );
}

function DropdownMenuCheckboxItem({ className, children, checked, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
  return (
    <DropdownMenuPrimitive.CheckboxItem data-slot="dropdown-menu-checkbox-item" checked={checked} className={cn(ROW, "pr-8", className)} {...props}>
      {children}
      <DropdownMenuPrimitive.ItemIndicator className="absolute right-2.5 text-fg-brand group-data-[variant=glass]/menu:text-on-brand">
        <Check strokeWidth={1.75} />
      </DropdownMenuPrimitive.ItemIndicator>
    </DropdownMenuPrimitive.CheckboxItem>
  );
}

function DropdownMenuRadioGroup(props: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
  return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
}

function DropdownMenuRadioItem({ className, children, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
  return (
    <DropdownMenuPrimitive.RadioItem data-slot="dropdown-menu-radio-item" className={cn(ROW, "pr-8 data-[state=checked]:text-fg", className)} {...props}>
      {children}
      <DropdownMenuPrimitive.ItemIndicator className="absolute right-2.5 text-fg-brand group-data-[variant=glass]/menu:text-on-brand">
        <Check strokeWidth={1.75} />
      </DropdownMenuPrimitive.ItemIndicator>
    </DropdownMenuPrimitive.RadioItem>
  );
}

function DropdownMenuLabel({ className, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Label>) {
  return <DropdownMenuPrimitive.Label data-slot="dropdown-menu-label" className={cn("px-2.5 pt-2 pb-1 font-display text-caption", QUIET, className)} {...props} />;
}

function DropdownMenuSeparator({ className, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
  return <DropdownMenuPrimitive.Separator data-slot="dropdown-menu-separator" className={cn("-mx-1 my-1 h-px bg-line group-data-[variant=glass]/menu:bg-line-on-brand", className)} {...props} />;
}

function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
  return <span data-slot="dropdown-menu-shortcut" className={cn("ml-auto pl-4 text-caption", QUIET, className)} {...props} />;
}

function DropdownMenuSub(props: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
  return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}

function DropdownMenuSubTrigger({ className, children, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger>) {
  return (
    <DropdownMenuPrimitive.SubTrigger data-slot="dropdown-menu-sub-trigger" className={cn(ROW, "data-[state=open]:bg-wash-hover group-data-[variant=glass]/menu:data-[state=open]:bg-wash-glass-hover group-data-[variant=glass]/menu:data-[state=open]:shadow-glass-hover", className)} {...props}>
      {children}
      <ChevronRight className="ml-auto" strokeWidth={1.5} />
    </DropdownMenuPrimitive.SubTrigger>
  );
}

function DropdownMenuSubContent({ className, variant: variantProp, collisionPadding = 8, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent> & { variant?: keyof typeof PANEL }) {
  const inheritedVariant = React.useContext(MenuVariant);
  const variant = variantProp ?? inheritedVariant;
  return (
    <DropdownMenuPrimitive.Portal container={usePortalContainer()}>
      <MenuVariant.Provider value={variant}>
        <DropdownMenuPrimitive.SubContent
          data-slot="dropdown-menu-sub-content"
          collisionPadding={collisionPadding}
          data-variant={variant}
          className={cn("group/menu z-50 flex min-w-[min(10rem,var(--radix-dropdown-menu-content-available-width))] max-w-(--radix-dropdown-menu-content-available-width) max-h-(--radix-dropdown-menu-content-available-height) overflow-y-auto overscroll-contain origin-(--radix-dropdown-menu-content-transform-origin) animate-pop-in flex-col gap-0.5 rounded-control border p-1", PANEL[variant], className)}
          {...props}
        />
      </MenuVariant.Provider>
    </DropdownMenuPrimitive.Portal>
  );
}

export {
  DropdownMenu,
  DropdownMenuCheckboxItem,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuHoldItem,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuSeparator,
  DropdownMenuShortcut,
  DropdownMenuSub,
  DropdownMenuSubContent,
  DropdownMenuSubTrigger,
  DropdownMenuTrigger,
};

Reference: agentcard-website src/app/globals.css (.v7-dock-card); live https://www.agentcard.sh/.