Dropdown
A menu of actions and nested choices opened from a trigger.
Default
Open on click from any trigger. DropdownMenuContent has no default width, so give it one, such as w-56. Items close the menu when chosen.
Width
Set the width on DropdownMenuContent to fit the longest label. Use w-(--anchor-width) to match the trigger, as a select does.
import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@smallest-inc/ui"; const actions = ["Rename agent", "Duplicate agent", "Export transcript", "Archive agent"]; export default function DropdownMenuWidth() { return ( <div className="flex flex-wrap items-start gap-3"> <DropdownMenu> <DropdownMenuTrigger render={<Button variant="secondary" />}>Narrow</DropdownMenuTrigger> <DropdownMenuContent className="w-48"> {actions.map((action) => ( <DropdownMenuItem key={action}>{action}</DropdownMenuItem> ))} </DropdownMenuContent> </DropdownMenu> <DropdownMenu> <DropdownMenuTrigger render={<Button variant="secondary" />}>Wide</DropdownMenuTrigger> <DropdownMenuContent className="w-80"> {actions.map((action) => ( <DropdownMenuItem key={action}>{action}</DropdownMenuItem> ))} </DropdownMenuContent> </DropdownMenu> <DropdownMenu> <DropdownMenuTrigger render={<Button variant="secondary" className="w-64 justify-start" />}> Match the trigger </DropdownMenuTrigger> <DropdownMenuContent className="w-(--anchor-width)"> {actions.map((action) => ( <DropdownMenuItem key={action}>{action}</DropdownMenuItem> ))} </DropdownMenuContent> </DropdownMenu> </div> ); }
Groups and separators
Flat lists need no dividers. Separate distinct groups with DropdownMenuSeparator, or wrap them in DropdownMenuGroup with separated. Destructive items use variant="danger" and go last.
import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@smallest-inc/ui"; export default function DropdownMenuGroups() { return ( <div className="flex flex-wrap items-center gap-3"> <DropdownMenu> <DropdownMenuTrigger render={<Button variant="secondary" />}>Project</DropdownMenuTrigger> <DropdownMenuContent className="w-56"> <DropdownMenuGroup> <DropdownMenuItem>Open project</DropdownMenuItem> <DropdownMenuItem>Rename</DropdownMenuItem> <DropdownMenuItem>Duplicate</DropdownMenuItem> </DropdownMenuGroup> <DropdownMenuGroup separated> <DropdownMenuItem variant="danger">Delete project</DropdownMenuItem> </DropdownMenuGroup> </DropdownMenuContent> </DropdownMenu> <DropdownMenu> <DropdownMenuTrigger render={<Button variant="secondary" />}>Call</DropdownMenuTrigger> <DropdownMenuContent className="w-56"> <DropdownMenuItem>Play recording</DropdownMenuItem> <DropdownMenuItem>View transcript</DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuItem>Share link</DropdownMenuItem> <DropdownMenuItem>Download audio</DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuItem variant="danger">Delete recording</DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> </div> ); }
Leading and trailing
Use leading for an icon and trailing for fixed content such as a DropdownMenuShortcut, a count or a status. Icons render at 18px.
import { Button, CopyIcon, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuShortcut, DropdownMenuTrigger, GlobeIcon, HistoryIcon, SettingsIcon, } from "@smallest-inc/ui"; export default function DropdownMenuLeadingTrailing() { return ( <div className="flex flex-wrap items-center gap-3"> <DropdownMenu> <DropdownMenuTrigger render={<Button variant="secondary" />}>Agent</DropdownMenuTrigger> <DropdownMenuContent className="w-60"> <DropdownMenuItem leading={<SettingsIcon />} trailing={<DropdownMenuShortcut>⌘,</DropdownMenuShortcut>}> Agent settings </DropdownMenuItem> <DropdownMenuItem leading={<HistoryIcon />} trailing={<DropdownMenuShortcut>⌘H</DropdownMenuShortcut>}> Call history </DropdownMenuItem> <DropdownMenuItem leading={<CopyIcon />} trailing={<DropdownMenuShortcut>⌘D</DropdownMenuShortcut>}> Duplicate </DropdownMenuItem> <DropdownMenuItem leading={<GlobeIcon />}>Open public link</DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> <DropdownMenu> <DropdownMenuTrigger render={<Button variant="secondary" />}>Linked agents</DropdownMenuTrigger> <DropdownMenuContent className="w-64"> <DropdownMenuItem trailing={<span className="text-fg-tertiary">10</span>}>All linked agents</DropdownMenuItem> <DropdownMenuItem trailing={<span className="text-fg-tertiary">Live</span>}>Piramal IVR agent</DropdownMenuItem> <DropdownMenuItem trailing={<span className="text-fg-tertiary">Draft</span>}>Traya outreach agent</DropdownMenuItem> <DropdownMenuItem>Show 7 more</DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> </div> ); }
Descriptions
Pass description for a supporting line under the label. With selected, the chosen row shows a trailing checkmark instead of a fill.
import { useState } from "react"; import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@smallest-inc/ui"; const IDENTITIES = [ { id: "workspace", label: "Workspace", description: "Always run actions as the Smallest workspace." }, { id: "member", label: "Member", description: "Run as the signed-in member when connected; otherwise as the workspace.", }, { id: "member-only", label: "Member only", description: "Run as the signed-in member only; fail if their account isn’t connected.", }, ]; export default function DropdownMenuDescriptions() { const [identity, setIdentity] = useState("workspace"); return ( <DropdownMenu> <DropdownMenuTrigger render={<Button variant="secondary" />}>Run as</DropdownMenuTrigger> <DropdownMenuContent className="w-[382px]"> {IDENTITIES.map((item) => ( <DropdownMenuItem key={item.id} description={item.description} selected={identity === item.id} onClick={() => setIdentity(item.id)} > {item.label} </DropdownMenuItem> ))} </DropdownMenuContent> </DropdownMenu> ); }
Avatars
Put a 16px Avatar in leading to identify people or agents in the row.
import { Avatar, AvatarFallback, Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger, } from "@smallest-inc/ui"; const AGENTS = [ { name: "Piramal IVR agent", color: "bg-violet-300" }, { name: "Traya outreach agent", color: "bg-orange-300" }, { name: "New York Life POC agent", color: "bg-neutral-800" }, ]; export default function DropdownMenuAvatars() { return ( <DropdownMenu> <DropdownMenuTrigger render={<Button variant="secondary" />}>Assign agent</DropdownMenuTrigger> <DropdownMenuContent className="w-64"> <DropdownMenuLabel>Linked agents</DropdownMenuLabel> {AGENTS.map((agent) => ( <DropdownMenuItem key={agent.name} leading={ <Avatar size="sm" className="size-4 after:hidden"> <AvatarFallback className={agent.color} /> </Avatar> } > {agent.name} </DropdownMenuItem> ))} </DropdownMenuContent> </DropdownMenu> ); }
Disabled
Pass disabled to show an action that is unavailable right now. It dims to 50% and cannot be chosen.
import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@smallest-inc/ui"; export default function DropdownMenuDisabled() { return ( <DropdownMenu> <DropdownMenuTrigger render={<Button variant="secondary" />}>Campaign</DropdownMenuTrigger> <DropdownMenuContent className="w-56"> <DropdownMenuItem>Pause campaign</DropdownMenuItem> <DropdownMenuItem>Edit schedule</DropdownMenuItem> <DropdownMenuItem disabled>Resume campaign</DropdownMenuItem> <DropdownMenuItem disabled>Export results</DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> ); }
Single select
Wrap DropdownMenuRadioItems in a DropdownMenuRadioGroup. The selected row shows a trailing checkmark without a row fill, and choosing a row closes the menu.
import { useState } from "react"; import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger, } from "@smallest-inc/ui"; const FREQUENCIES = ["Daily", "Weekly", "Monthly", "Never"]; export default function DropdownMenuSingleSelect() { const [frequency, setFrequency] = useState("Weekly"); return ( <DropdownMenu> <DropdownMenuTrigger render={<Button variant="secondary" />}>Digest: {frequency}</DropdownMenuTrigger> <DropdownMenuContent className="w-64"> <DropdownMenuLabel>Call summary emails</DropdownMenuLabel> <DropdownMenuRadioGroup value={frequency} onValueChange={(next) => setFrequency(String(next))}> {FREQUENCIES.map((option) => ( <DropdownMenuRadioItem key={option} value={option}> {option} </DropdownMenuRadioItem> ))} </DropdownMenuRadioGroup> </DropdownMenuContent> </DropdownMenu> ); }
Multi select
DropdownMenuCheckboxItems show a leading circular checkbox and keep the menu open while people pick several values.
import { Button, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuLabel, DropdownMenuTrigger, } from "@smallest-inc/ui"; export default function DropdownMenuMultiSelect() { return ( <DropdownMenu> <DropdownMenuTrigger render={<Button variant="secondary" />}>Status</DropdownMenuTrigger> <DropdownMenuContent className="w-64"> <DropdownMenuLabel>Call status</DropdownMenuLabel> <DropdownMenuCheckboxItem>Pending</DropdownMenuCheckboxItem> <DropdownMenuCheckboxItem defaultChecked>In queue</DropdownMenuCheckboxItem> <DropdownMenuCheckboxItem defaultChecked>In progress</DropdownMenuCheckboxItem> <DropdownMenuCheckboxItem>Completed</DropdownMenuCheckboxItem> <DropdownMenuCheckboxItem>No answer</DropdownMenuCheckboxItem> <DropdownMenuCheckboxItem>Failed</DropdownMenuCheckboxItem> </DropdownMenuContent> </DropdownMenu> ); }
Multi select with icons
Pass indicatorPosition="trailing" when each option has a leading icon, so the checkboxes line up in one column.
import { BrowserIcon, Button, ChipIcon, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuLabel, DropdownMenuTrigger, GlobeIcon, } from "@smallest-inc/ui"; export default function DropdownMenuMultiSelectIcons() { return ( <DropdownMenu> <DropdownMenuTrigger render={<Button variant="secondary" />}>Channels</DropdownMenuTrigger> <DropdownMenuContent className="w-64"> <DropdownMenuLabel>Deploy agent to</DropdownMenuLabel> <DropdownMenuCheckboxItem defaultChecked indicatorPosition="trailing" leading={<BrowserIcon />}> Web widget </DropdownMenuCheckboxItem> <DropdownMenuCheckboxItem indicatorPosition="trailing" leading={<GlobeIcon />}> Phone number </DropdownMenuCheckboxItem> <DropdownMenuCheckboxItem defaultChecked indicatorPosition="trailing" leading={<ChipIcon />}> API </DropdownMenuCheckboxItem> </DropdownMenuContent> </DropdownMenu> ); }
Select all
Pin an “All” row above a separator and derive its checked state from the rest.
import { useState } from "react"; import { Button, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuSeparator, DropdownMenuTrigger, } from "@smallest-inc/ui"; const STATUSES = ["Pending", "In queue", "In progress", "Completed", "No answer", "Failed"]; export default function DropdownMenuSelectAll() { const [selected, setSelected] = useState<string[]>(["In queue", "In progress"]); const allSelected = selected.length === STATUSES.length; return ( <DropdownMenu> <DropdownMenuTrigger render={<Button variant="secondary" />}> {allSelected ? "All statuses" : selected.length === 1 ? selected[0] : `${selected.length} statuses`} </DropdownMenuTrigger> <DropdownMenuContent className="w-64"> <DropdownMenuCheckboxItem checked={allSelected} onCheckedChange={(checked) => setSelected(checked ? STATUSES : [])} > All statuses </DropdownMenuCheckboxItem> <DropdownMenuSeparator /> {STATUSES.map((status) => ( <DropdownMenuCheckboxItem key={status} checked={selected.includes(status)} onCheckedChange={(checked) => setSelected((current) => checked ? [...current, status] : current.filter((value) => value !== status) ) } > {status} </DropdownMenuCheckboxItem> ))} </DropdownMenuContent> </DropdownMenu> ); }
Long labels
Labels wrap by default, so nothing is hidden. To keep every row on one line, wrap the label in a span with truncate and put the full text in title.
import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@smallest-inc/ui"; const agents = [ "Inbound support for enterprise customers in North America", "Outbound renewals", "After-hours escalation line for billing disputes and refunds", ]; export default function DropdownMenuLongLabels() { return ( <div className="flex flex-wrap items-start gap-3"> <DropdownMenu> <DropdownMenuTrigger render={<Button variant="secondary" />}>Wrap</DropdownMenuTrigger> <DropdownMenuContent className="w-64"> {agents.map((agent) => ( <DropdownMenuItem key={agent}>{agent}</DropdownMenuItem> ))} </DropdownMenuContent> </DropdownMenu> <DropdownMenu> <DropdownMenuTrigger render={<Button variant="secondary" />}>Truncate</DropdownMenuTrigger> <DropdownMenuContent className="w-64"> {agents.map((agent) => ( <DropdownMenuItem key={agent} title={agent}> <span className="min-w-0 truncate">{agent}</span> </DropdownMenuItem> ))} </DropdownMenuContent> </DropdownMenu> </div> ); }
Long lists
The menu stops at 500px or the space left in the viewport, whichever is smaller, and the list scrolls with a fade at the cut edge.
import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger, } from "@smallest-inc/ui"; const numbers = Array.from({ length: 40 }, (_, index) => `+1 415 555 ${String(1200 + index * 7).padStart(4, "0")}`); export default function DropdownMenuLongList() { return ( <DropdownMenu> <DropdownMenuTrigger render={<Button variant="secondary" />}>Caller ID</DropdownMenuTrigger> <DropdownMenuContent className="w-56"> <DropdownMenuRadioGroup defaultValue={numbers[0]}> {numbers.map((number) => ( <DropdownMenuRadioItem key={number} value={number} className="tabular-nums"> {number} </DropdownMenuRadioItem> ))} </DropdownMenuRadioGroup> </DropdownMenuContent> </DropdownMenu> ); }
Search
DropdownMenuSearch pins a field above the list, and the list scrolls under it. Filter the items yourself and say when nothing matches.
import { useState } from "react"; import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSearch, DropdownMenuTrigger, } from "@smallest-inc/ui"; const VOICES = [ "Aria, warm", "Arjun, calm", "Elena, bright", "Hana, soft", "Kofi, deep", "Lucas, energetic", "Mei, clear", "Omar, steady", "Priya, friendly", "Rohan, confident", "Sofia, gentle", "Tomás, relaxed", ]; export default function DropdownMenuSearchExample() { const [query, setQuery] = useState(""); const [voice, setVoice] = useState("Aria, warm"); const matches = VOICES.filter((item) => item.toLowerCase().includes(query.trim().toLowerCase())); return ( <DropdownMenu> <DropdownMenuTrigger render={<Button variant="secondary" />}>Voice</DropdownMenuTrigger> <DropdownMenuContent className="w-64"> <DropdownMenuSearch placeholder="Search voices" aria-label="Search voices" value={query} onChange={(event) => setQuery(event.target.value)} /> {matches.length === 0 ? <DropdownMenuLabel>No voices match “{query}”</DropdownMenuLabel> : null} <DropdownMenuRadioGroup value={voice} onValueChange={(next) => setVoice(String(next))}> {matches.map((item) => ( <DropdownMenuRadioItem key={item} value={item}> {item} </DropdownMenuRadioItem> ))} </DropdownMenuRadioGroup> </DropdownMenuContent> </DropdownMenu> ); }
Floating submenu
DropdownMenuSub opens a side panel from its DropdownMenuSubTrigger on hover or with the right arrow key. Floating is the default variant.
import { Button, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSub, DropdownMenuSubPanel, DropdownMenuSubTrigger, DropdownMenuTrigger, FlagIcon, GlobeIcon, } from "@smallest-inc/ui"; export default function DropdownMenuFloatingSubmenu() { return ( <DropdownMenu> <DropdownMenuTrigger render={<Button variant="secondary" />}>Filter</DropdownMenuTrigger> <DropdownMenuContent className="w-60"> <DropdownMenuLabel>Filter calls by</DropdownMenuLabel> <DropdownMenuSub> <DropdownMenuSubTrigger leading={<GlobeIcon />}>Hostname</DropdownMenuSubTrigger> <DropdownMenuSubPanel> <DropdownMenuRadioGroup defaultValue="app"> <DropdownMenuRadioItem value="app">app.smallest.ai</DropdownMenuRadioItem> <DropdownMenuRadioItem value="api">api.smallest.ai</DropdownMenuRadioItem> </DropdownMenuRadioGroup> </DropdownMenuSubPanel> </DropdownMenuSub> <DropdownMenuSub> <DropdownMenuSubTrigger leading={<FlagIcon />}>Country</DropdownMenuSubTrigger> <DropdownMenuSubPanel> <DropdownMenuCheckboxItem defaultChecked>India</DropdownMenuCheckboxItem> <DropdownMenuCheckboxItem>United States</DropdownMenuCheckboxItem> <DropdownMenuCheckboxItem>United Kingdom</DropdownMenuCheckboxItem> </DropdownMenuSubPanel> </DropdownMenuSub> </DropdownMenuContent> </DropdownMenu> ); }
Submenu search
Submenus have no search by default. Pass a DropdownMenuSearch to the search prop of DropdownMenuSubPanel when a panel has many options.
import { useState } from "react"; import { BrowserIcon, Button, ChipIcon, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSearch, DropdownMenuSub, DropdownMenuSubPanel, DropdownMenuSubTrigger, DropdownMenuTrigger, FlagIcon, GlobeIcon, } from "@smallest-inc/ui"; const FIELDS = [ { label: "Hostname", icon: <GlobeIcon />, options: ["app.smallest.ai", "api.smallest.ai", "docs.smallest.ai"] }, { label: "Page", icon: <ChipIcon />, options: ["/overview", "/agents", "/calls", "/settings"] }, { label: "Country", icon: <FlagIcon />, options: ["India", "United States", "United Kingdom", "Germany"] }, { label: "Browser", icon: <BrowserIcon />, options: ["Chrome", "Safari", "Firefox", "Edge"] }, ]; function SearchableSubmenu({ label, icon, options }: (typeof FIELDS)[number]) { const [query, setQuery] = useState(""); const matches = options.filter((option) => option.toLowerCase().includes(query.trim().toLowerCase())); return ( <DropdownMenuSub> <DropdownMenuSubTrigger leading={icon}>{label}</DropdownMenuSubTrigger> <DropdownMenuSubPanel search={ <DropdownMenuSearch placeholder={`Search ${label.toLowerCase()}`} aria-label={`Search ${label.toLowerCase()}`} value={query} onChange={(event) => setQuery(event.target.value)} /> } > {matches.map((option) => ( <DropdownMenuItem key={option}>{option}</DropdownMenuItem> ))} </DropdownMenuSubPanel> </DropdownMenuSub> ); } export default function DropdownMenuSubmenuSearch() { return ( <DropdownMenu> <DropdownMenuTrigger render={<Button variant="secondary" />}>Performance</DropdownMenuTrigger> <DropdownMenuContent className="w-60"> <DropdownMenuLabel>Filter performance by</DropdownMenuLabel> {FIELDS.map((field) => ( <SearchableSubmenu key={field.label} {...field} /> ))} </DropdownMenuContent> </DropdownMenu> ); }
Inline submenu
variant="inline" with a unique name drills into the same surface. The panel slides in with a back row labelled by title, and the menu height follows the visible pane.
import { useState } from "react"; import { BrowserIcon, Button, ChipIcon, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSearch, DropdownMenuSub, DropdownMenuSubPanel, DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@smallest-inc/ui"; export default function DropdownMenuInlineSubmenu() { const [query, setQuery] = useState(""); const show = (label: string) => label.toLowerCase().includes(query.trim().toLowerCase()); return ( <DropdownMenu> <DropdownMenuTrigger render={<Button variant="secondary" />}>Filter</DropdownMenuTrigger> <DropdownMenuContent className="w-72"> <DropdownMenuSearch placeholder="Search" aria-label="Search filters" value={query} onChange={(event) => setQuery(event.target.value)} /> <DropdownMenuLabel>Filter by</DropdownMenuLabel> {show("Model") ? ( <DropdownMenuSub name="model" variant="inline"> <DropdownMenuSubTrigger leading={<ChipIcon />}>Model</DropdownMenuSubTrigger> <DropdownMenuSubPanel title="Model"> <DropdownMenuRadioGroup defaultValue="fast"> <DropdownMenuRadioItem value="fast">Fast</DropdownMenuRadioItem> <DropdownMenuRadioItem value="quality">Quality</DropdownMenuRadioItem> </DropdownMenuRadioGroup> </DropdownMenuSubPanel> </DropdownMenuSub> ) : null} {show("Browser") ? ( <DropdownMenuSub name="browser" variant="inline"> <DropdownMenuSubTrigger leading={<BrowserIcon />}>Browser</DropdownMenuSubTrigger> <DropdownMenuSubPanel title="Browser"> <DropdownMenuCheckboxItem defaultChecked>Chrome</DropdownMenuCheckboxItem> <DropdownMenuCheckboxItem>Safari</DropdownMenuCheckboxItem> <DropdownMenuCheckboxItem>Firefox</DropdownMenuCheckboxItem> </DropdownMenuSubPanel> </DropdownMenuSub> ) : null} </DropdownMenuContent> </DropdownMenu> ); }
Submenu header and footer
Inline panels take an aside for a control beside the back row, such as an operator select, plus their own search and footer.
import { useState } from "react"; import { Button, ChevronsUpDownIcon, DropdownMenu, DropdownMenuContent, DropdownMenuFooter, DropdownMenuItem, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSearch, DropdownMenuSub, DropdownMenuSubPanel, DropdownMenuSubTrigger, DropdownMenuTrigger, Flag, FlagIcon, PlusIcon, type FlagCountry, } from "@smallest-inc/ui"; const LANGUAGES: { name: string; country: FlagCountry }[] = [ { name: "Arabic", country: "SA" }, { name: "Bengali", country: "BD" }, { name: "Chinese", country: "CN" }, { name: "Dutch", country: "NL" }, { name: "English", country: "GB" }, { name: "French", country: "FR" }, { name: "German", country: "DE" }, { name: "Hindi", country: "IN" }, { name: "Italian", country: "IT" }, { name: "Japanese", country: "JP" }, { name: "Korean", country: "KR" }, { name: "Portuguese", country: "PT" }, { name: "Spanish", country: "ES" }, { name: "Tamil", country: "IN" }, ]; const OPERATORS = ["is", "is not"]; function OperatorSelect({ value, onValueChange }: { value: string; onValueChange: (value: string) => void }) { return ( <DropdownMenu modal={false}> <DropdownMenuTrigger render={ <Button variant="transparent" size="sm" className="gap-1.5 rounded-lg px-2 font-normal text-fg-quaternary hover:bg-neutral-150 hover:text-fg" /> } > {value} <ChevronsUpDownIcon radius="0" className="size-[18px] text-fg-quaternary" /> </DropdownMenuTrigger> <DropdownMenuContent align="end" sideOffset={6} className="w-36"> <DropdownMenuRadioGroup value={value} onValueChange={(next) => onValueChange(String(next))}> {OPERATORS.map((operator) => ( <DropdownMenuRadioItem key={operator} value={operator}> {operator} </DropdownMenuRadioItem> ))} </DropdownMenuRadioGroup> </DropdownMenuContent> </DropdownMenu> ); } export default function DropdownMenuSubmenuHeader() { const [query, setQuery] = useState(""); const [operator, setOperator] = useState("is"); const [language, setLanguage] = useState("English"); const matches = LANGUAGES.filter((item) => item.name.toLowerCase().includes(query.trim().toLowerCase())); return ( <DropdownMenu> <DropdownMenuTrigger render={<Button variant="secondary" />}>Filter</DropdownMenuTrigger> <DropdownMenuContent className="w-72"> <DropdownMenuLabel>Filter agents by</DropdownMenuLabel> <DropdownMenuSub name="language" variant="inline"> <DropdownMenuSubTrigger leading={<FlagIcon />} trailing={<span className="text-fg-tertiary">{operator} {language}</span>} > Language </DropdownMenuSubTrigger> <DropdownMenuSubPanel title="Language" aside={<OperatorSelect value={operator} onValueChange={setOperator} />} search={ <DropdownMenuSearch placeholder="Search languages" aria-label="Search languages" value={query} onChange={(event) => setQuery(event.target.value)} /> } > <DropdownMenuRadioGroup value={language} onValueChange={(next) => setLanguage(String(next))}> {matches.map((item) => ( <DropdownMenuRadioItem key={item.name} value={item.name} leading={<Flag country={item.country} title={item.name} />} > {item.name} </DropdownMenuRadioItem> ))} </DropdownMenuRadioGroup> <DropdownMenuFooter> <DropdownMenuItem leading={<PlusIcon />}>Add custom language</DropdownMenuItem> </DropdownMenuFooter> </DropdownMenuSubPanel> </DropdownMenuSub> </DropdownMenuContent> </DropdownMenu> ); }
Footer
DropdownMenuFooter pins an action row below the scrolling list, such as “Add custom status” or a destructive action.
import { Button, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuFooter, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger, PlusIcon, TrashIcon, } from "@smallest-inc/ui"; export default function DropdownMenuFooterExample() { return ( <div className="flex flex-wrap items-center gap-3"> <DropdownMenu> <DropdownMenuTrigger render={<Button variant="secondary" />}>Custom status</DropdownMenuTrigger> <DropdownMenuContent className="w-64"> <DropdownMenuLabel>Status</DropdownMenuLabel> <DropdownMenuCheckboxItem>Pending</DropdownMenuCheckboxItem> <DropdownMenuCheckboxItem defaultChecked>In queue</DropdownMenuCheckboxItem> <DropdownMenuCheckboxItem defaultChecked>In progress</DropdownMenuCheckboxItem> <DropdownMenuCheckboxItem>Callback requested</DropdownMenuCheckboxItem> <DropdownMenuFooter> <DropdownMenuItem leading={<PlusIcon />}>Add custom status</DropdownMenuItem> </DropdownMenuFooter> </DropdownMenuContent> </DropdownMenu> <DropdownMenu> <DropdownMenuTrigger render={<Button variant="secondary" />}>Agent</DropdownMenuTrigger> <DropdownMenuContent className="w-64"> <DropdownMenuItem>Edit prompt</DropdownMenuItem> <DropdownMenuItem>Change voice</DropdownMenuItem> <DropdownMenuItem>Test call</DropdownMenuItem> <DropdownMenuItem>View analytics</DropdownMenuItem> <DropdownMenuFooter> <DropdownMenuItem variant="danger" leading={<TrashIcon />}> Delete this agent </DropdownMenuItem> </DropdownMenuFooter> </DropdownMenuContent> </DropdownMenu> </div> ); }
Footer caption
Use variant="caption" for a fixed line of help text instead of an action.
import { useState } from "react"; import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuFooter, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSearch, DropdownMenuTrigger, Flag, type FlagCountry, } from "@smallest-inc/ui"; const LANGUAGES: { name: string; country: FlagCountry }[] = [ { name: "Arabic", country: "SA" }, { name: "Bengali", country: "BD" }, { name: "Chinese", country: "CN" }, { name: "English", country: "GB" }, { name: "French", country: "FR" }, { name: "German", country: "DE" }, { name: "Hindi", country: "IN" }, { name: "Indonesian", country: "ID" }, { name: "Japanese", country: "JP" }, { name: "Korean", country: "KR" }, { name: "Spanish", country: "ES" }, { name: "Tamil", country: "IN" }, { name: "Thai", country: "TH" }, { name: "Vietnamese", country: "VN" }, ]; export default function DropdownMenuFooterCaption() { const [query, setQuery] = useState(""); const [language, setLanguage] = useState("English"); const matches = LANGUAGES.filter((item) => item.name.toLowerCase().includes(query.trim().toLowerCase())); return ( <DropdownMenu> <DropdownMenuTrigger render={<Button variant="secondary" />}>Language</DropdownMenuTrigger> <DropdownMenuContent className="w-72"> <DropdownMenuSearch placeholder="Search languages" aria-label="Search languages" value={query} onChange={(event) => setQuery(event.target.value)} /> <DropdownMenuRadioGroup value={language} onValueChange={(next) => setLanguage(String(next))}> {matches.map((item) => ( <DropdownMenuRadioItem key={item.name} value={item.name} leading={<Flag country={item.country} title={item.name} />} > {item.name} </DropdownMenuRadioItem> ))} </DropdownMenuRadioGroup> <DropdownMenuFooter variant="caption">Can’t find a language? Request it from support.</DropdownMenuFooter> </DropdownMenuContent> </DropdownMenu> ); }
Account menu
Use size="lg" with interactive={false} for an identity header that shows the name and email but runs no action. A large avatar goes in trailing, and actions follow a separator.
import { Avatar, AvatarFallback, Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, GlobeIcon, SettingsIcon, } from "@smallest-inc/ui"; export default function DropdownMenuAccountMenu() { return ( <DropdownMenu> <DropdownMenuTrigger render={<Button variant="secondary" />}>Account</DropdownMenuTrigger> <DropdownMenuContent className="w-72"> <DropdownMenuItem size="lg" interactive={false} description="ada@smallest.ai" trailing={ <Avatar> <AvatarFallback className="bg-violet-300" /> </Avatar> } > Ada Lovelace </DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuItem trailing={<SettingsIcon />}>Account settings</DropdownMenuItem> <DropdownMenuItem trailing={<GlobeIcon />}>Documentation</DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuItem>Log out</DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> ); }
Workspace switcher
Show the active workspace as a non-interactive lg header, then actions, then the other workspaces in a ScrollArea with scrollFade and a max height.
import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, PlusIcon, ScrollArea, SettingsIcon, } from "@smallest-inc/ui"; const WORKSPACES = [ { name: "Voiceflow Labs", email: "owner@voiceflow.example" }, { name: "Northwind Robotics", email: "ops@northwind.example" }, { name: "Atlas Dialer", email: "hello@atlas.example" }, { name: "Cascade Systems", email: "admin@cascade.example" }, { name: "Fable Analytics", email: "team@fable.example" }, { name: "Beacon Health", email: "care@beacon.example" }, { name: "Ridgeline Media", email: "studio@ridgeline.example" }, { name: "Solstice AI", email: "hi@solstice.example" }, ]; function WorkspaceMark({ name }: { name: string }) { return ( <span className="flex size-8 shrink-0 items-center justify-center rounded-[8px] bg-linear-to-b from-indigo-300 to-indigo-400 text-sm font-medium text-white"> {name.charAt(0)} </span> ); } export default function DropdownMenuWorkspaceSwitcher() { return ( <DropdownMenu> <DropdownMenuTrigger render={<Button variant="secondary" />}>Workspace</DropdownMenuTrigger> <DropdownMenuContent className="w-72"> <DropdownMenuItem size="lg" interactive={false} className="gap-2" leading={<WorkspaceMark name="Smallest" />} description="owner@smallest.ai" > Smallest </DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuItem trailing={<SettingsIcon />}>Workspace settings</DropdownMenuItem> <DropdownMenuItem trailing={<PlusIcon />}>Invite members</DropdownMenuItem> <DropdownMenuSeparator /> <ScrollArea scrollFade viewportClassName="max-h-84"> {WORKSPACES.map((workspace) => ( <DropdownMenuItem key={workspace.name} size="lg" className="gap-2" leading={<WorkspaceMark name={workspace.name} />} description={workspace.email} > {workspace.name} </DropdownMenuItem> ))} </ScrollArea> </DropdownMenuContent> </DropdownMenu> ); }
Best practices
When to use
- Use for contextual actions on one resource (“Edit agent”, “Duplicate agent”) or a compact set of related choices such as a digest frequency.
- Use Split Button for two frequent related actions, and Select or Combobox for form values that submit with a form.
Behavior
- DropdownMenuContent has no default width; always pass one, such as className="w-56", and keep it constant for the menu's lifetime.
- Items close the menu on click. Checkbox items stay open for multi-select; radio items close on choice.
- Single select uses DropdownMenuRadioGroup with a trailing check and no row fill; multi select uses DropdownMenuCheckboxItem. Pass indicatorPosition="trailing" when rows have leading icons.
- Submenus float by default. Use variant="inline" with a unique name to drill into the same surface when the panel needs its own search, aside or footer.
- Submenus have no search by default; pass search={<DropdownMenuSearch />} to DropdownMenuSubPanel when a panel has more than about eight options.
- Filter search results yourself and show a DropdownMenuLabel such as “No voices match” when nothing is left.
- Pin persistent actions with DropdownMenuFooter; use variant="caption" for a line of help text.
- Use size="lg" for rows with a 32–40px avatar or workspace mark, and interactive={false} for identity headers that run no action.
- Long option lists scroll inside the menu (capped at 500px); for grouped lists inside a menu use ScrollArea with scrollFade and a max height on viewportClassName.
Content
- Use sentence-case verb + noun actions: “Delete project”, not “Delete” or “Delete Project”.
- Separate distinct groups with DropdownMenuSeparator or DropdownMenuGroup separated; flat lists need no dividers.
- Put destructive actions last with variant="danger", behind a separator or in the footer.
- Put the current account or workspace in a non-interactive lg header, then actions, then the switcher list.
Accessibility
- Give every DropdownMenuSearch an aria-label as well as a placeholder.
- Icon-only triggers need an aria-label that names the menu, such as “Agent actions”.
- Arrow keys, Enter, Escape and Tab still reach the menu from the search field; do not stop those keys in onKeyDown.