Dropdown
Composable popover menu (trigger, panel, groups, rows, dividers) built on React Aria — the recipe behind the sidebar team/account menus.
Menu
Trigger, groups, rows, and dividers.
function DropdownDemo() {
const [selected, setSelected] = useState("profile");
return (
<Dropdown>
{/* DropdownTrigger renders its own <button>, so it takes the trigger's
* content and classes directly — wrapping a <Button> inside it would
* nest a button in a button. */}
<DropdownTrigger className="inline-flex cursor-pointer items-center gap-1.5 rounded-2lg border border-border-button-default bg-background-primary-default px-3 py-2 text-body-medium text-text-primary shadow-xs transition-colors duration-150 hover:bg-background-primary-hover">
<RiSettings3Line className="size-4 text-foreground-icon-secondary" aria-hidden />
Open menu
</DropdownTrigger>
<DropdownPopover aria-label="Workspace menu">
<DropdownGroup label="Account">
<DropdownItem selected={selected === "profile"} onSelect={() => setSelected("profile")}>
<RiUserLine className="size-4" /> Profile
</DropdownItem>
<DropdownItem selected={selected === "projects"} onSelect={() => setSelected("projects")}>
<RiFolderLine className="size-4" /> Projects
</DropdownItem>
</DropdownGroup>
<DropdownDivider />
<DropdownGroup label="Workspace">
<DropdownItem selected={selected === "settings"} onSelect={() => setSelected("settings")}>
<RiCheckLine className="size-4" /> Settings
</DropdownItem>
</DropdownGroup>
</DropdownPopover>
</Dropdown>
);
}function DropdownDemo() {
const [selected, setSelected] = useState("profile");
return (
<Dropdown>
{/* DropdownTrigger renders its own <button>, so it takes the trigger's
* content and classes directly — wrapping a <Button> inside it would
* nest a button in a button. */}
<DropdownTrigger className="inline-flex cursor-pointer items-center gap-1.5 rounded-2lg border border-border-button-default bg-background-primary-default px-3 py-2 text-body-medium text-text-primary shadow-xs transition-colors duration-150 hover:bg-background-primary-hover">
<RiSettings3Line className="size-4 text-foreground-icon-secondary" aria-hidden />
Open menu
</DropdownTrigger>
<DropdownPopover aria-label="Workspace menu">
<DropdownGroup label="Account">
<DropdownItem selected={selected === "profile"} onSelect={() => setSelected("profile")}>
<RiUserLine className="size-4" /> Profile
</DropdownItem>
<DropdownItem selected={selected === "projects"} onSelect={() => setSelected("projects")}>
<RiFolderLine className="size-4" /> Projects
</DropdownItem>
</DropdownGroup>
<DropdownDivider />
<DropdownGroup label="Workspace">
<DropdownItem selected={selected === "settings"} onSelect={() => setSelected("settings")}>
<RiCheckLine className="size-4" /> Settings
</DropdownItem>
</DropdownGroup>
</DropdownPopover>
</Dropdown>
);
}Installation
npx shadcn@latest add https://boardcn.dev/r/dropdown.jsonnpx shadcn@latest add https://boardcn.dev/r/dropdown.jsonnpm packages
- react-aria-components
BoardCN dependencies
The CLI installs these for you — you do not need to add them yourself.
Source
The 2 files the CLI copies into your project.
"use client";
import { createContext, useContext, useRef, useState, type ComponentProps, type ReactNode, type RefObject } from "react";
import {
Button as AriaButton,
Dialog as AriaDialog,
DialogTrigger as AriaDialogTrigger,
Popover as AriaPopover,
} from "react-aria-components";
import {
MENU_ITEM,
MENU_ITEM_ACTIVE,
MENU_ITEM_INTERACTIVE,
MENU_POPOVER_SURFACE,
MENU_POPOVER_WIDTH,
} from "@/components/base/dropdown/menu-styles";
import { cx } from "@/utils/cx";
import { useDismissOnOutsidePress, useTriggerToggle } from "@/utils/use-dismiss-on-outside-press";
/**
* Dropdown — the BoardCN popover-menu recipe as composable primitives, built
* on React Aria's DialogTrigger/Popover. Unlike Select (which picks a value
* into a trigger), Dropdown is a free-form menu surface: grouped rows of
* actions, headers, footers, whatever the panel needs.
*
* The same recipe powers the dashboard sidebar's team/account menus and the
* AI chat template's add/model/folder menus:
*
* - Panel: white, 1px border/button/default, radius 16, p 10, shadow/dropdown.
* - Appear animation: 150ms fade + scale-95 + 2px blur in and out.
* - Rows: rounded-2lg, background/secondary/hover on hover and on the selected
* row, body-medium labels, 4px apart (the panel's flex column carries a
* gap-1, the same rhythm as the Select listbox).
*
* Composition:
*
* ```tsx
* <Dropdown isOpen={isOpen} onOpenChange={setIsOpen}>
* <DropdownTrigger>Open</DropdownTrigger>
* <DropdownPopover aria-label="Actions" placement="bottom start">
* <DropdownGroup label="Add">
* <DropdownItem onSelect={close}>…row content…</DropdownItem>
* </DropdownGroup>
* <DropdownDivider />
* <DropdownGroup label="Plugins">…</DropdownGroup>
* </DropdownPopover>
* </Dropdown>
* ```
*
* State can be uncontrolled (omit isOpen/onOpenChange) — control it when the
* trigger needs to react to the open state (e.g. rotating a chevron).
*/
/* ------------------------------------------------------------------- shell */
interface DropdownContextValue {
triggerRef: RefObject<HTMLButtonElement | null>;
popoverRef: RefObject<HTMLElement | null>;
}
const DropdownContext = createContext<DropdownContextValue | null>(null);
export interface DropdownProps {
isOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;
/** Trigger (a DropdownTrigger) followed by a DropdownPopover. */
children: ReactNode;
}
/**
* The popover is `isNonModal`: react-aria's modal scroll lock puts
* `overflow: hidden` on <html>, which collapses the page scroll position and
* visibly yanks sticky layout (e.g. the docs sidebar) whenever a menu opens.
* Non-modal skips the lock, but react-aria hard-couples outside-press
* dismissal to modality — so open state lives here (bridging any controlled
* props) and dismissal is restored via useDismissOnOutsidePress, the same
* fix as Select and the date-picker family.
*/
export function Dropdown({ isOpen: controlledOpen, onOpenChange, children }: DropdownProps) {
const triggerRef = useRef<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLElement>(null);
const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
const isOpen = controlledOpen ?? uncontrolledOpen;
const setOpen = (next: boolean) => {
setUncontrolledOpen(next);
onOpenChange?.(next);
};
useDismissOnOutsidePress(isOpen, () => setOpen(false), [triggerRef, popoverRef]);
// Pressing the trigger while open closes the menu instead of reopening
const allowOpenChange = useTriggerToggle(isOpen, triggerRef);
return (
<DropdownContext.Provider value={{ triggerRef, popoverRef }}>
<AriaDialogTrigger isOpen={isOpen} onOpenChange={(o) => allowOpenChange(o) && setOpen(o)}>
{children}
</AriaDialogTrigger>
</DropdownContext.Provider>
);
}
/** The element that opens the menu. Style it entirely via className. */
export function DropdownTrigger({ className, ...props }: ComponentProps<typeof AriaButton>) {
const context = useContext(DropdownContext);
return (
<AriaButton
ref={context?.triggerRef}
{...props}
className={cx(
"cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring",
className as string,
)}
/>
);
}
/* ------------------------------------------------------------------- panel */
export interface DropdownPopoverProps
extends Pick<ComponentProps<typeof AriaPopover>, "placement" | "offset" | "crossOffset"> {
"aria-label": string;
/** Extra classes on the panel — e.g. a width override (default w-[266px]). */
className?: string;
/** Classes on the inner dialog (the flex column), e.g. gap between groups. */
dialogClassName?: string;
children: ReactNode;
}
export function DropdownPopover({
"aria-label": ariaLabel,
placement = "bottom start",
offset = 4,
crossOffset,
className,
dialogClassName,
children,
}: DropdownPopoverProps) {
const context = useContext(DropdownContext);
return (
<AriaPopover
ref={context?.popoverRef}
isNonModal
placement={placement}
offset={offset}
crossOffset={crossOffset}
className={cx(
MENU_POPOVER_WIDTH,
MENU_POPOVER_SURFACE,
className,
)}
>
{/* gap-1 keeps bare DropdownItems 4px apart, the same rhythm as the
Select listbox; DropdownDivider's margins are sized to absorb it. */}
<AriaDialog aria-label={ariaLabel} className={cx("flex flex-col gap-1 outline-none", dialogClassName)}>
{children}
</AriaDialog>
</AriaPopover>
);
}
/* ----------------------------------------------------------------- content */
export interface DropdownGroupProps {
/** Muted body-medium heading above the rows. */
label?: string;
className?: string;
children: ReactNode;
}
export function DropdownGroup({ label, className, children }: DropdownGroupProps) {
return (
// pt-1 is spacing for the group LABEL — a label-less group must not
// carry it, or its first row floats 4px lower than the panel padding
// implies (visible as extra space above the first item's hover pill).
<div className={cx("flex w-full flex-col gap-1.5", label && "pt-1", className)}>
{label && <span className="pl-2 text-body-medium text-text-secondary">{label}</span>}
<div className="flex w-full flex-col gap-1">{children}</div>
</div>
);
}
export interface DropdownItemProps {
/** Highlights the row like the hover state (current selection). */
selected?: boolean;
onSelect?: () => void;
/** Row padding defaults to p-2 — override for denser rows (px-2 py-1.5). */
className?: string;
children: ReactNode;
}
/**
* A menu row. Content is free-form — icon + label, avatar + name, label +
* trailing badge — laid out in a gap-2 flex row.
*/
export function DropdownItem({ selected, onSelect, className, children }: DropdownItemProps) {
return (
<button
type="button"
aria-pressed={selected}
onClick={onSelect}
className={cx(
MENU_ITEM,
selected ? MENU_ITEM_ACTIVE : MENU_ITEM_INTERACTIVE,
className,
)}
>
{children}
</button>
);
}
/** Full-bleed 1px divider between groups (bleeds through the panel's p-2.5).
* my-1.5 + the dialog's gap-1 on both sides = the original 10px breathing room. */
export function DropdownDivider({ className }: { className?: string }) {
return <div className={cx("-mx-2.5 my-1.5 h-px shrink-0 bg-border-button-default", className)} />;
}"use client";
import { createContext, useContext, useRef, useState, type ComponentProps, type ReactNode, type RefObject } from "react";
import {
Button as AriaButton,
Dialog as AriaDialog,
DialogTrigger as AriaDialogTrigger,
Popover as AriaPopover,
} from "react-aria-components";
import {
MENU_ITEM,
MENU_ITEM_ACTIVE,
MENU_ITEM_INTERACTIVE,
MENU_POPOVER_SURFACE,
MENU_POPOVER_WIDTH,
} from "@/components/base/dropdown/menu-styles";
import { cx } from "@/utils/cx";
import { useDismissOnOutsidePress, useTriggerToggle } from "@/utils/use-dismiss-on-outside-press";
/**
* Dropdown — the BoardCN popover-menu recipe as composable primitives, built
* on React Aria's DialogTrigger/Popover. Unlike Select (which picks a value
* into a trigger), Dropdown is a free-form menu surface: grouped rows of
* actions, headers, footers, whatever the panel needs.
*
* The same recipe powers the dashboard sidebar's team/account menus and the
* AI chat template's add/model/folder menus:
*
* - Panel: white, 1px border/button/default, radius 16, p 10, shadow/dropdown.
* - Appear animation: 150ms fade + scale-95 + 2px blur in and out.
* - Rows: rounded-2lg, background/secondary/hover on hover and on the selected
* row, body-medium labels, 4px apart (the panel's flex column carries a
* gap-1, the same rhythm as the Select listbox).
*
* Composition:
*
* ```tsx
* <Dropdown isOpen={isOpen} onOpenChange={setIsOpen}>
* <DropdownTrigger>Open</DropdownTrigger>
* <DropdownPopover aria-label="Actions" placement="bottom start">
* <DropdownGroup label="Add">
* <DropdownItem onSelect={close}>…row content…</DropdownItem>
* </DropdownGroup>
* <DropdownDivider />
* <DropdownGroup label="Plugins">…</DropdownGroup>
* </DropdownPopover>
* </Dropdown>
* ```
*
* State can be uncontrolled (omit isOpen/onOpenChange) — control it when the
* trigger needs to react to the open state (e.g. rotating a chevron).
*/
/* ------------------------------------------------------------------- shell */
interface DropdownContextValue {
triggerRef: RefObject<HTMLButtonElement | null>;
popoverRef: RefObject<HTMLElement | null>;
}
const DropdownContext = createContext<DropdownContextValue | null>(null);
export interface DropdownProps {
isOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;
/** Trigger (a DropdownTrigger) followed by a DropdownPopover. */
children: ReactNode;
}
/**
* The popover is `isNonModal`: react-aria's modal scroll lock puts
* `overflow: hidden` on <html>, which collapses the page scroll position and
* visibly yanks sticky layout (e.g. the docs sidebar) whenever a menu opens.
* Non-modal skips the lock, but react-aria hard-couples outside-press
* dismissal to modality — so open state lives here (bridging any controlled
* props) and dismissal is restored via useDismissOnOutsidePress, the same
* fix as Select and the date-picker family.
*/
export function Dropdown({ isOpen: controlledOpen, onOpenChange, children }: DropdownProps) {
const triggerRef = useRef<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLElement>(null);
const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
const isOpen = controlledOpen ?? uncontrolledOpen;
const setOpen = (next: boolean) => {
setUncontrolledOpen(next);
onOpenChange?.(next);
};
useDismissOnOutsidePress(isOpen, () => setOpen(false), [triggerRef, popoverRef]);
// Pressing the trigger while open closes the menu instead of reopening
const allowOpenChange = useTriggerToggle(isOpen, triggerRef);
return (
<DropdownContext.Provider value={{ triggerRef, popoverRef }}>
<AriaDialogTrigger isOpen={isOpen} onOpenChange={(o) => allowOpenChange(o) && setOpen(o)}>
{children}
</AriaDialogTrigger>
</DropdownContext.Provider>
);
}
/** The element that opens the menu. Style it entirely via className. */
export function DropdownTrigger({ className, ...props }: ComponentProps<typeof AriaButton>) {
const context = useContext(DropdownContext);
return (
<AriaButton
ref={context?.triggerRef}
{...props}
className={cx(
"cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring",
className as string,
)}
/>
);
}
/* ------------------------------------------------------------------- panel */
export interface DropdownPopoverProps
extends Pick<ComponentProps<typeof AriaPopover>, "placement" | "offset" | "crossOffset"> {
"aria-label": string;
/** Extra classes on the panel — e.g. a width override (default w-[266px]). */
className?: string;
/** Classes on the inner dialog (the flex column), e.g. gap between groups. */
dialogClassName?: string;
children: ReactNode;
}
export function DropdownPopover({
"aria-label": ariaLabel,
placement = "bottom start",
offset = 4,
crossOffset,
className,
dialogClassName,
children,
}: DropdownPopoverProps) {
const context = useContext(DropdownContext);
return (
<AriaPopover
ref={context?.popoverRef}
isNonModal
placement={placement}
offset={offset}
crossOffset={crossOffset}
className={cx(
MENU_POPOVER_WIDTH,
MENU_POPOVER_SURFACE,
className,
)}
>
{/* gap-1 keeps bare DropdownItems 4px apart, the same rhythm as the
Select listbox; DropdownDivider's margins are sized to absorb it. */}
<AriaDialog aria-label={ariaLabel} className={cx("flex flex-col gap-1 outline-none", dialogClassName)}>
{children}
</AriaDialog>
</AriaPopover>
);
}
/* ----------------------------------------------------------------- content */
export interface DropdownGroupProps {
/** Muted body-medium heading above the rows. */
label?: string;
className?: string;
children: ReactNode;
}
export function DropdownGroup({ label, className, children }: DropdownGroupProps) {
return (
// pt-1 is spacing for the group LABEL — a label-less group must not
// carry it, or its first row floats 4px lower than the panel padding
// implies (visible as extra space above the first item's hover pill).
<div className={cx("flex w-full flex-col gap-1.5", label && "pt-1", className)}>
{label && <span className="pl-2 text-body-medium text-text-secondary">{label}</span>}
<div className="flex w-full flex-col gap-1">{children}</div>
</div>
);
}
export interface DropdownItemProps {
/** Highlights the row like the hover state (current selection). */
selected?: boolean;
onSelect?: () => void;
/** Row padding defaults to p-2 — override for denser rows (px-2 py-1.5). */
className?: string;
children: ReactNode;
}
/**
* A menu row. Content is free-form — icon + label, avatar + name, label +
* trailing badge — laid out in a gap-2 flex row.
*/
export function DropdownItem({ selected, onSelect, className, children }: DropdownItemProps) {
return (
<button
type="button"
aria-pressed={selected}
onClick={onSelect}
className={cx(
MENU_ITEM,
selected ? MENU_ITEM_ACTIVE : MENU_ITEM_INTERACTIVE,
className,
)}
>
{children}
</button>
);
}
/** Full-bleed 1px divider between groups (bleeds through the panel's p-2.5).
* my-1.5 + the dialog's gap-1 on both sides = the original 10px breathing room. */
export function DropdownDivider({ className }: { className?: string }) {
return <div className={cx("-mx-2.5 my-1.5 h-px shrink-0 bg-border-button-default", className)} />;
}/**
* Shared visual recipe for BoardCN menu surfaces.
*
* Select and Dropdown intentionally keep their distinct React Aria semantics
* (single-value listbox vs. action menu), while consuming the same panel,
* motion, and row treatments from here.
*/
export const MENU_POPOVER_SURFACE = [
"max-w-[calc(100vw-32px)] overflow-y-auto",
"rounded-2xl border border-border-button-default bg-background-primary-default p-2.5 shadow-dropdown",
"transition duration-150 ease-out",
"data-[entering]:opacity-0 data-[entering]:scale-95 data-[entering]:blur-[2px]",
"data-[exiting]:opacity-0 data-[exiting]:scale-95 data-[exiting]:blur-[2px]",
"data-[placement=bottom]:origin-top-left data-[placement=top]:origin-bottom-left",
"data-[placement=left]:origin-right data-[placement=right]:origin-left",
].join(" ");
export const MENU_POPOVER_WIDTH = "w-[266px]";
export const MENU_ITEMS_CONTAINER = "flex w-full flex-col gap-1 outline-none";
export const MENU_ITEM = [
"flex w-full cursor-pointer items-center gap-2 rounded-2lg p-2 text-left",
"text-text-primary outline-none transition-colors",
].join(" ");
export const MENU_ITEM_ACTIVE = "bg-dropdown-item-hover-background";
export const MENU_ITEM_INTERACTIVE =
"hover:bg-dropdown-item-hover-background focus-visible:bg-dropdown-item-hover-background";/**
* Shared visual recipe for BoardCN menu surfaces.
*
* Select and Dropdown intentionally keep their distinct React Aria semantics
* (single-value listbox vs. action menu), while consuming the same panel,
* motion, and row treatments from here.
*/
export const MENU_POPOVER_SURFACE = [
"max-w-[calc(100vw-32px)] overflow-y-auto",
"rounded-2xl border border-border-button-default bg-background-primary-default p-2.5 shadow-dropdown",
"transition duration-150 ease-out",
"data-[entering]:opacity-0 data-[entering]:scale-95 data-[entering]:blur-[2px]",
"data-[exiting]:opacity-0 data-[exiting]:scale-95 data-[exiting]:blur-[2px]",
"data-[placement=bottom]:origin-top-left data-[placement=top]:origin-bottom-left",
"data-[placement=left]:origin-right data-[placement=right]:origin-left",
].join(" ");
export const MENU_POPOVER_WIDTH = "w-[266px]";
export const MENU_ITEMS_CONTAINER = "flex w-full flex-col gap-1 outline-none";
export const MENU_ITEM = [
"flex w-full cursor-pointer items-center gap-2 rounded-2lg p-2 text-left",
"text-text-primary outline-none transition-colors",
].join(" ");
export const MENU_ITEM_ACTIVE = "bg-dropdown-item-hover-background";
export const MENU_ITEM_INTERACTIVE =
"hover:bg-dropdown-item-hover-background focus-visible:bg-dropdown-item-hover-background";Props
Generated from the component's TypeScript types. Standard DOM and React Aria props are omitted.
Dropdown
The popover is `isNonModal`: react-aria's modal scroll lock puts `overflow: hidden` on <html>, which collapses the page scroll position and visibly yanks sticky layout (e.g. the docs sidebar) whenever a menu opens. Non-modal skips the lock, but react-aria hard-couples outside-press dismissal to modality — so open state lives here (bridging any controlled props) and dismissal is restored via useDismissOnOutsidePress, the same fix as Select and the date-picker family.
| Prop | Type | Default | Description |
|---|---|---|---|
| childrenrequired | ReactNode | — | Trigger (a DropdownTrigger) followed by a DropdownPopover. |
| isOpen | boolean | — | — |
| onOpenChange | (isOpen: boolean) => void | — | — |
DropdownDivider
Full-bleed 1px divider between groups (bleeds through the panel's p-2.5). my-1.5 + the dialog's gap-1 on both sides = the original 10px breathing room.
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | — | — |
DropdownGroup
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | — | — |
| label | string | — | Muted body-medium heading above the rows. |
DropdownItem
A menu row. Content is free-form — icon + label, avatar + name, label + trailing badge — laid out in a gap-2 flex row.
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | — | Row padding defaults to p-2 — override for denser rows (px-2 py-1.5). |
| onSelect | () => void | — | — |
| selected | boolean | — | Highlights the row like the hover state (current selection). |
DropdownPopover
| Prop | Type | Default | Description |
|---|---|---|---|
| aria-labelrequired | string | — | — |
| className | string | — | Extra classes on the panel — e.g. a width override (default w-[266px]). |
| dialogClassName | string | — | Classes on the inner dialog (the flex column), e.g. gap between groups. |