Menus & FeedbackDropdown Menu
The action menu — the full Radix compound on one item grammar.
The thinking
Every surface that holds actions needs a menu, and a menu is only as honest as its plainest row. The load-bearing decision here is one
itemClasses string, shared verbatim by the plain item, the checkbox, the radio, and the sub-trigger — four widgets the eye reads as one list because they're built from the same flex, padding, and data-[highlighted] rule. Everything underneath that grammar is Radix, deliberately untouched, because a menu is where I refuse to spend novelty — you open one to leave it. The detail that lifts it past a default is what the stateful rows do with the leading edge: checkbox and radio pad to pl-8 and park their indicator in an absolute left-2 span, so a stateful row and a plain one share the exact text baseline (the check just appears in space that was always reserved).Interface design
The surface is one quiet panel:
bg-popover and text-popover-foreground inside a border-border ring, rounded-lg outside, rounded-sm rows inside, with a p-1 gutter between the two radii so the highlight never touches the frame. The label takes the item's exact metrics — px-2 py-1.5 text-sm — and changes only the weight to font-medium, so a heading sits on the same grid as the rows it heads. The separator is -mx-1 my-1 h-px bg-border, negative-margined through the gutter so the line runs border to border — the surface splitting, not a rule drawn on it. And the shortcut is a plain span, ml-auto font-mono text-xs text-muted-foreground, never the key-cap treatment: a key-cap belongs in prose, and a menu that set every combo in one would be showing you twenty tiny buttons. The shortcuts here are labels, not controls.Interaction
Open is
animate-pop-in — 0.2s of cubic-bezier(0.23, 0.88, 0.26, 0.92), opacity and scale from 0.96 — and close is animate-pop-out at 0.15s, because a dismissed surface should get out of the way faster than it arrived. Both aim through origin-[--radix-dropdown-menu-content-transform-origin], so wherever Radix flips the surface to dodge a screen edge, the growth still reads from the trigger sitting sideOffset = 4 pixels away — the menu always appears to come from the thing you pressed. Inside, pointer and keyboard are one system: Radix reports both through data-[highlighted], so hovering a row and arrowing to it paint the identical bg-secondary-hover step, with no focus: styling competing for the same pixels. The deepest intent-reading is in the submenu, and none of it is my code: Radix waits 100ms of hover before opening a sub-trigger, so a pointer passing through on its way to Sign out never summons a panel it didn't ask for, and when you leave the trigger toward an open submenu it draws a grace polygon from your exit point across the submenu's rect and honors it for 300ms — a diagonal cut across the neighboring rows reads as travel, not a change of mind. While you're crossing, the trigger holds data-[state=open]:bg-secondary-hover, because a row you traveled through is still part of the path to where your pointer is now. The row you came through stays lit.Sound design
Items speak through the layer —
role=menuitem is the contract, so this file ships zero audio. The stateful rows carry menuitemcheckbox and menuitemradio, both named in the SoundLayer's selector; without those two, half a menu would tap and the other half would go silent. And choosing from a menu is a tap, not a toggle — the checkbox item taps too, because the menu closes on the choice, so the lasting state lives in the reopened menu, not in the moment of selection.Empathy
A disabled row keeps its place in the list:
data-[disabled]:pointer-events-none makes it inert and data-[disabled]:opacity-50 dims it, so the demo's Add from team still tells you the action exists. Rows set outline-none, which is only safe because focus never goes invisible — Radix moves it row by row and reports it through data-[highlighted], so the bg-secondary-hover wash is the focus ring for keyboard and pointer alike. Screen readers get the real widget: menuitem on plain rows, menuitemcheckbox and menuitemradio on the stateful ones — the bold Check and the filled Circle are for eyes, the roles are for everyone else. motion-reduce:animate-none sits on both content and sub-content, so if you've asked the OS for less motion the menu simply appears and simply leaves. The honest gap is long content: min-w-[8rem] is a floor with no ceiling, so a long label widens the whole menu. I haven't needed to clamp it yet."use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CaretRight, Check, Circle } from "@phosphor-icons/react/dist/ssr";
import { cn } from "@/lib/utils";
// sound is delegated: items carry role=menuitem (and menuitemcheckbox /
// menuitemradio for the stateful ones), all of which the SoundLayer
// listens for — this whole compound ships zero audio.
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
// one item grammar for the whole surface — plain, checkbox, radio and
// sub-trigger all share it, so the eye reads one list, not four widgets
const itemClasses =
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none data-[highlighted]:bg-secondary-hover data-[disabled]:pointer-events-none data-[disabled]:opacity-50";
const DropdownMenuContent = ({
ref,
className,
sideOffset = 4,
...props
}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & {
ref?: React.Ref<React.ComponentRef<typeof DropdownMenuPrimitive.Content>>;
}) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
// the popper var aims the pop at the trigger, wherever radix
// flipped the surface to — growth always reads from its source
"z-50 min-w-[8rem] overflow-hidden rounded-lg border border-border bg-popover p-1 text-popover-foreground origin-[--radix-dropdown-menu-content-transform-origin] data-[state=open]:animate-pop-in data-[state=closed]:animate-pop-out motion-reduce:animate-none",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = ({
ref,
className,
...props
}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
ref?: React.Ref<React.ComponentRef<typeof DropdownMenuPrimitive.Item>>;
}) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(itemClasses, className)}
{...props}
/>
);
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = ({
ref,
className,
children,
checked,
...props
}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & {
ref?: React.Ref<React.ComponentRef<typeof DropdownMenuPrimitive.CheckboxItem>>;
}) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
checked={checked}
className={cn(itemClasses, "pl-8", className)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-3.5 w-3.5" weight="bold" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = ({
ref,
className,
children,
...props
}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> & {
ref?: React.Ref<React.ComponentRef<typeof DropdownMenuPrimitive.RadioItem>>;
}) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(itemClasses, "pl-8", className)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2" weight="fill" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = ({
ref,
className,
...props
}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
ref?: React.Ref<React.ComponentRef<typeof DropdownMenuPrimitive.Label>>;
}) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-medium", className)}
{...props}
/>
);
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = ({
ref,
className,
...props
}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> & {
ref?: React.Ref<React.ComponentRef<typeof DropdownMenuPrimitive.Separator>>;
}) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
);
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
// a span, not Kbd — the key-cap is for prose; inside a menu the shortcut
// is a column of quiet mono labels, not twenty tiny buttons
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => (
<span
className={cn("ml-auto font-mono text-xs text-muted-foreground", className)}
{...props}
/>
);
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
const DropdownMenuSubTrigger = ({
ref,
className,
children,
...props
}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
ref?: React.Ref<React.ComponentRef<typeof DropdownMenuPrimitive.SubTrigger>>;
}) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
// stays highlighted while its submenu is open — the trigger is part
// of the path to the pointer's current position
className={cn(itemClasses, "data-[state=open]:bg-secondary-hover", className)}
{...props}
>
{children}
<CaretRight className="ml-auto h-3.5 w-3.5" />
</DropdownMenuPrimitive.SubTrigger>
);
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = ({
ref,
className,
...props
}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> & {
ref?: React.Ref<React.ComponentRef<typeof DropdownMenuPrimitive.SubContent>>;
}) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-lg border border-border bg-popover p-1 text-popover-foreground origin-[--radix-dropdown-menu-content-transform-origin] data-[state=open]:animate-pop-in data-[state=closed]:animate-pop-out motion-reduce:animate-none",
className
)}
{...props}
/>
);
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuPortal,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
DropdownMenuGroup,
};