A trigger that opens a list of selectable items, built on Radix's DropdownMenu for focus management, keyboard navigation and close on outside click or Escape.

pnpm add @behivetech/atoms.dropdown-menu

This is the snapshot of v0.4.2, the current version, as recorded on 2026-11-10. The live page runs its examples →

registry/behivetech/atoms/dropdown-menu at v0.4.2, snapshotted 2026-11-10

dropdown-menu-content.tsx
1import { type ReactNode } from "react";
2import * as RadixDropdownMenu from "@radix-ui/react-dropdown-menu";
3import { getClassName } from "@behivetech/get-class-name";
4import styles from "./dropdown-menu-content.module.scss";
5
6export interface DropdownMenuContentProps {
7 /** Whether the menu is open; adds `--open`, or `--closed` while it animates out */
8 open: boolean;
9 /** Alignment of the menu relative to the trigger */
10 align: "start" | "center" | "end";
11 /** Additional class names to merge with the content element */
12 className?: string;
13 /** The menu items */
14 children: ReactNode;
15}
16
17/**
18 * DropdownMenu's popup. A block of its own (`BHT__DropdownMenuContent`) so the
19 * open and closed animations are styled through modifiers instead of Radix's
20 * `data-state`.
21 */
22export const DropdownMenuContent = ({
23 open,
24 align,
25 className,
26 children,
27}: DropdownMenuContentProps) => {
28 const [rootClass] = getClassName({
29 className,
30 modifiers: { open, closed: !open },
31 rootClass: "BHT__DropdownMenuContent",
32 styles,
33 });
34
35 return (
36 <RadixDropdownMenu.Content
37 className={rootClass}
38 align={align}
39 sideOffset={4}
40 >
41 {children}
42 </RadixDropdownMenu.Content>
43 );
44};
dropdown-menu-item.tsx
1import { useState } from "react";
2import * as RadixDropdownMenu from "@radix-ui/react-dropdown-menu";
3import { getClassName } from "@behivetech/get-class-name";
4import styles from "./dropdown-menu-item.module.scss";
5
6export interface DropdownMenuItemProps {
7 /** Text shown for this item */
8 label: string;
9 /** Prevents this item from being selected; adds the `--disabled` modifier */
10 disabled?: boolean;
11 /** Called when the item is chosen */
12 onSelect: () => void;
13}
14
15/**
16 * One item in DropdownMenu's popup. A block of its own (`BHT__DropdownMenuItem`)
17 * so `disabled` and `highlighted` are styled through modifiers instead of
18 * Radix's `data-disabled` and `data-highlighted`.
19 */
20export const DropdownMenuItem = ({
21 label,
22 disabled = false,
23 onSelect,
24}: DropdownMenuItemProps) => {
25 // Radix highlights an item exactly while it has focus (pointer move and
26 // arrow keys both focus it), so focus and blur are the React signal.
27 const [highlighted, setHighlighted] = useState(false);
28 const [rootClass] = getClassName({
29 modifiers: { disabled, highlighted },
30 rootClass: "BHT__DropdownMenuItem",
31 styles,
32 });
33
34 return (
35 <RadixDropdownMenu.Item
36 disabled={disabled}
37 className={rootClass}
38 onSelect={onSelect}
39 onFocus={() => setHighlighted(true)}
40 onBlur={() => setHighlighted(false)}
41 >
42 {label}
43 </RadixDropdownMenu.Item>
44 );
45};
dropdown-menu.tsx
1import { type ReactNode, useState } from "react";
2import * as RadixDropdownMenu from "@radix-ui/react-dropdown-menu";
3import { DropdownMenuContent } from "./dropdown-menu-content.js";
4import { DropdownMenuItem as DropdownMenuItemRow } from "./dropdown-menu-item.js";
5
6export interface DropdownMenuItem {
7 /** Unique identifier for this item, passed to onSelect */
8 id: string;
9 /** Text shown for this item */
10 label: string;
11 /** Prevents this item from being selected */
12 disabled?: boolean;
13}
14
15export interface DropdownMenuProps {
16 /** Element that opens the menu when activated — receives the trigger's props via Radix's `asChild` */
17 trigger: ReactNode;
18 /** Items rendered in the menu */
19 items: DropdownMenuItem[];
20 /** Called with the selected item's id when an item is chosen */
21 onSelect: (id: string) => void;
22 /** Controlled open state */
23 open?: boolean;
24 /** Called when the open state changes (trigger click, item select, outside click, Escape) */
25 onOpenChange?: (open: boolean) => void;
26 /** Alignment of the menu relative to the trigger; defaults to "start" */
27 align?: "start" | "center" | "end";
28 /** Additional class names to merge with the menu content element */
29 className?: string;
30}
31
32/**
33 * A trigger element that opens a list of selectable items, built on Radix's
34 * `DropdownMenu` for focus management, keyboard navigation, and outside-click/
35 * Escape-to-close.
36 */
37export const DropdownMenu = ({
38 trigger,
39 items,
40 onSelect,
41 open,
42 onOpenChange,
43 align = "start",
44 className,
45}: DropdownMenuProps) => {
46 // Tracks the open state so the content can carry `--open` / `--closed`;
47 // a controlled `open` wins.
48 const [internalOpen, setInternalOpen] = useState(false);
49 const isOpen = open ?? internalOpen;
50
51 const handleOpenChange = (next: boolean) => {
52 setInternalOpen(next);
53 onOpenChange?.(next);
54 };
55
56 return (
57 <RadixDropdownMenu.Root open={isOpen} onOpenChange={handleOpenChange}>
58 <RadixDropdownMenu.Trigger asChild>{trigger}</RadixDropdownMenu.Trigger>
59 <RadixDropdownMenu.Portal>
60 <DropdownMenuContent open={isOpen} align={align} className={className}>
61 {items.map((item) => (
62 <DropdownMenuItemRow
63 key={item.id}
64 label={item.label}
65 disabled={item.disabled}
66 onSelect={() => onSelect(item.id)}
67 />
68 ))}
69 </DropdownMenuContent>
70 </RadixDropdownMenu.Portal>
71 </RadixDropdownMenu.Root>
72 );
73};
dropdown-menu-content.module.scss
1// Material Design 3 menu. Each color falls back to the pre-MD3 `--color-*`
2// name, which `cms.base-styles` aliases to the matching MD3 role.
3
4.BHT__DropdownMenuContent {
5 min-width: 11.25rem;
6 background: var(
7 --md-sys-color-surface-container,
8 var(--color-surface-raised)
9 );
10 // No outline: MD3 menus separate from the page by tone and elevation.
11 border-radius: var(--md-sys-shape-corner-extra-small, 4px);
12 box-shadow: var(--md-sys-elevation-2, var(--shadow-md));
13 padding: var(--spacing-1, 0.25rem);
14 overflow: hidden;
15 // Portaled to document.body — see select-content.module.scss; the fallback has to
16 // clear --z-modal (400) and --z-sticky (200), not sit under them.
17 z-index: var(--z-dropdown, 450);
18
19 &--open {
20 &.BHT__DropdownMenuContent {
21 animation: dropdown-menu-in var(--md-sys-motion-duration-short-3, 150ms)
22 var(--md-sys-motion-easing-standard-decelerate, ease-out);
23 }
24 }
25
26 &--closed {
27 &.BHT__DropdownMenuContent {
28 animation: dropdown-menu-out var(--md-sys-motion-duration-short-2, 100ms)
29 var(--md-sys-motion-easing-standard-accelerate, ease-in);
30 }
31 }
32}
33
34@keyframes dropdown-menu-in {
35 from {
36 opacity: 0;
37 transform: translateY(-0.25rem);
38 }
39
40 to {
41 opacity: 1;
42 transform: translateY(0);
43 }
44}
45
46@keyframes dropdown-menu-out {
47 from {
48 opacity: 1;
49 transform: translateY(0);
50 }
51
52 to {
53 opacity: 0;
54 transform: translateY(-0.25rem);
55 }
56}
dropdown-menu-item.module.scss
1// Material Design 3 menu item. Its own block so `--disabled` and
2// `--highlighted` are real classes.
3
4.BHT__DropdownMenuItem {
5 position: relative;
6 isolation: isolate;
7 display: flex;
8 align-items: center;
9 padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);
10 border-radius: var(--md-sys-shape-corner-extra-small, 4px);
11 font-family: var(--md-sys-typescale-label-large-font, var(--font-body));
12 font-size: var(--md-sys-typescale-label-large-size, 0.875rem);
13 font-weight: var(--md-sys-typescale-label-large-weight, 500);
14 line-height: var(--md-sys-typescale-label-large-line-height, 1.25rem);
15 letter-spacing: var(--md-sys-typescale-label-large-tracking, 0.0071em);
16 color: var(--md-sys-color-on-surface, var(--color-on-surface));
17 cursor: pointer;
18 user-select: none;
19 outline: none;
20
21 // State layer. `--highlighted` follows Radix's highlight, set for both pointer
22 // hover and keyboard navigation, so it stands in for :hover here.
23 &::after {
24 content: "";
25 position: absolute;
26 inset: 0;
27 border-radius: inherit;
28 background: currentColor;
29 opacity: 0;
30 pointer-events: none;
31 transition: opacity var(--md-sys-motion-duration-short-2, 100ms)
32 var(--md-sys-motion-easing-standard, ease);
33 }
34
35 &--highlighted {
36 &.BHT__DropdownMenuItem::after {
37 opacity: var(--md-sys-state-hover-opacity, 0.08);
38 }
39 }
40
41 &:active::after {
42 opacity: var(--md-sys-state-pressed-opacity, 0.1);
43 }
44
45 // The state layer alone is a faint keyboard cue, so keyboard focus also
46 // gets the ring. Inset because the content's `overflow: hidden` would clip
47 // an outer ring on the first and last items.
48 &:focus-visible {
49 outline: 3px solid var(--md-sys-color-secondary, var(--color-border-focus));
50 outline-offset: -3px;
51 }
52
53 &--disabled {
54 &.BHT__DropdownMenuItem {
55 opacity: 0.4;
56 cursor: not-allowed;
57 pointer-events: none;
58 }
59 }
60}
index.ts
1export { DropdownMenu } from "./dropdown-menu.js";
2export type { DropdownMenuProps, DropdownMenuItem } from "./dropdown-menu.js";
dropdown-menu.example.tsx
1import { useState } from "react";
2import { DropdownMenu } from "./dropdown-menu.js";
3
4const siteItems = [
5 { id: "site-1", label: "My BEhive Site" },
6 { id: "site-2", label: "Second Site" },
7 { id: "site-3", label: "Archived Site", disabled: true },
8];
9
10export const BasicDropdownMenu = () => {
11 const [selected, setSelected] = useState("site-1");
12 const selectedLabel = siteItems.find((item) => item.id === selected)?.label;
13
14 return (
15 <DropdownMenu
16 trigger={<button type="button">{selectedLabel} ▾</button>}
17 items={siteItems}
18 onSelect={setSelected}
19 />
20 );
21};
22
23export const ControlledDropdownMenu = () => {
24 const [open, setOpen] = useState(false);
25 const [selected, setSelected] = useState("site-1");
26 const selectedLabel = siteItems.find((item) => item.id === selected)?.label;
27
28 return (
29 <>
30 <p>Selected: {selectedLabel}</p>
31 <DropdownMenu
32 trigger={<button type="button">Toggle menu</button>}
33 items={siteItems}
34 open={open}
35 onOpenChange={setOpen}
36 onSelect={(id) => {
37 setSelected(id);
38 setOpen(false);
39 }}
40 />
41 </>
42 );
43};