Dropdown menu
A menu of actions and options behind a button, with groups, submenus, checkbox and radio items, and shortcuts.
Installation
Run this in your project. It adds the files, installs the npm packages and anything this builds on, and sets up the theme:
npx shadcn@latest add https://kryptonite-ui.vercel.app/r/dropdown-menu.jsonInstall manually
Install the dependencies:
npm install @base-ui/react @tabler/icons-react class-variance-authority cnIt builds on these, so copy them first:
Copy this into
components/ui/dropdown-menu.tsx:dropdown-menu.tsx"use client" import * as React from "react" import { DirectionProvider, useDirection, type TextDirection, } from "@base-ui/react/direction-provider" import { Menu as MenuPrimitive } from "@base-ui/react/menu" import { mergeProps } from "@base-ui/react/merge-props" import { useRender } from "@base-ui/react/use-render" import { IconCheck, IconChevronRight, IconCircleFilled, } from "@tabler/icons-react" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "cn" import { SafeAreaOverlay } from "@/components/ui/safe-area" type ClassName<State> = string | ((state: State) => string | undefined) | undefined function mergeClassName<State>(base: string, className: ClassName<State>) { return typeof className === "function" ? (state: State) => cn(base, className(state)) : cn(base, className) } type DropdownMenuContextValue = { direction: TextDirection chosen: string | null choose: (id: string) => void } const DropdownMenuContext = React.createContext<DropdownMenuContextValue | null>(null) const DropdownMenuGroupContext = React.createContext(false) function useDropdownMenuContext(part: string) { const context = React.useContext(DropdownMenuContext) if (!context) { throw new Error(`${part} must be used within <DropdownMenu>.`) } return context } function readDirection(element: Element | null | undefined) { if (!element || !element.isConnected) { return undefined } return getComputedStyle(element).direction === "rtl" ? "rtl" : "ltr" } function getSubmenuElements() { const triggers = document.querySelectorAll( "[data-slot=dropdown-menu-sub-trigger][data-popup-open]" ) const reference = triggers[triggers.length - 1] const id = reference?.getAttribute("aria-controls") const floating = id ? document.getElementById(id) : null return reference && floating ? { reference, floating } : null } type DropdownMenuProps<Payload> = MenuPrimitive.Root.Props<Payload> & { showSafeArea?: boolean } function DropdownMenu<Payload>({ onOpenChange, onOpenChangeComplete, showSafeArea = false, children, ...props }: DropdownMenuProps<Payload>) { const [open, setOpen] = React.useState(false) const inherited = useDirection() const [triggerDirection, setTriggerDirection] = React.useState<TextDirection>() const [chosen, setChosen] = React.useState<string | null>(null) const direction = triggerDirection === "rtl" ? "rtl" : inherited const context = React.useMemo( () => ({ direction, chosen, choose: setChosen }), [direction, chosen] ) return ( <DropdownMenuContext.Provider value={context}> <DirectionProvider direction={direction}> <MenuPrimitive.Root onOpenChange={(open, eventDetails) => { onOpenChange?.(open, eventDetails) if (!eventDetails.isCanceled) { setOpen(open) } if (open && !eventDetails.isCanceled) { setChosen(null) if (eventDetails.trigger) { setTriggerDirection(readDirection(eventDetails.trigger)) } } }} onOpenChangeComplete={(open) => { onOpenChangeComplete?.(open) if (!open) { setChosen(null) } }} {...props} > {children} </MenuPrimitive.Root> {showSafeArea && (props.open ?? open) ? ( <SafeAreaOverlay getElements={getSubmenuElements} /> ) : null} </DirectionProvider> </DropdownMenuContext.Provider> ) } function DropdownMenuTrigger<Payload>( props: MenuPrimitive.Trigger.Props<Payload> ) { return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} /> } function DropdownMenuPortal(props: MenuPrimitive.Portal.Props) { return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} /> } type DropdownMenuContentProps = MenuPrimitive.Popup.Props & Pick< MenuPrimitive.Positioner.Props, | "side" | "align" | "sideOffset" | "alignOffset" | "anchor" | "collisionPadding" | "collisionAvoidance" | "sticky" > & { portalProps?: Omit<MenuPrimitive.Portal.Props, "children"> } const dropdownMenuContentClassName = "relative max-h-(--available-height) max-w-[min(var(--available-width),20rem)] min-w-40 origin-(--transform-origin) overflow-x-hidden overflow-y-auto overscroll-none rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-(length:--hairline) forced-colors:border ring-foreground/10 transition-[opacity,scale] duration-150 ease-out-quint outline-none focus-visible:outline-hidden [--dropdown-menu-item-radius:max(calc(var(--radius-sm)*0.5),calc(var(--radius-lg)-0.25rem))] data-ending-style:opacity-0 data-starting-style:opacity-0 data-ending-style:data-instant:transition-none motion-safe:data-starting-style:scale-96 motion-safe:data-[chosen]:data-ending-style:delay-120 motion-reduce:transition-opacity" const SIDE_COLLISION_AVOIDANCE = { fallbackAxisSide: "end" } as const function DropdownMenuPopup({ slot, className, side, align, sideOffset, alignOffset, anchor, collisionPadding = 8, collisionAvoidance, sticky, portalProps, ...props }: DropdownMenuContentProps & { slot: string }) { const { direction, chosen } = useDropdownMenuContext( slot === "dropdown-menu-content" ? "DropdownMenuContent" : "DropdownMenuSubContent" ) return ( <MenuPrimitive.Portal {...portalProps}> <MenuPrimitive.Positioner data-slot={slot.replace("content", "positioner")} dir={direction === "rtl" ? "rtl" : undefined} side={side} align={align} sideOffset={sideOffset} alignOffset={alignOffset} anchor={anchor} collisionPadding={collisionPadding} collisionAvoidance={ collisionAvoidance ?? (side === "bottom" || side === "top" ? undefined : SIDE_COLLISION_AVOIDANCE) } sticky={sticky} className="isolate z-50 outline-none focus-visible:outline-hidden" > <MenuPrimitive.Popup data-slot={slot} data-chosen={chosen === null ? undefined : ""} className={mergeClassName( cn( dropdownMenuContentClassName, slot === "dropdown-menu-content" && "min-w-[max(var(--anchor-width),10rem)]" ), className )} {...props} /> </MenuPrimitive.Positioner> </MenuPrimitive.Portal> ) } function DropdownMenuContent({ side = "bottom", align = "start", sideOffset = 4, alignOffset = 0, ...props }: DropdownMenuContentProps) { return ( <DropdownMenuPopup slot="dropdown-menu-content" side={side} align={align} sideOffset={sideOffset} alignOffset={alignOffset} {...props} /> ) } function DropdownMenuGroup(props: MenuPrimitive.Group.Props) { return ( <DropdownMenuGroupContext.Provider value> <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} /> </DropdownMenuGroupContext.Provider> ) } type DropdownMenuLabelProps = MenuPrimitive.GroupLabel.Props & { inset?: boolean } const dropdownMenuLabelClassName = "px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground wrap-anywhere data-inset:ps-8" function DropdownMenuStandaloneLabel({ className, render, ...props }: MenuPrimitive.GroupLabel.Props) { const resolved = mergeClassName(dropdownMenuLabelClassName, className) return useRender({ defaultTagName: "div", render, props: mergeProps<"div">( { className: typeof resolved === "function" ? resolved({}) : resolved, }, props as React.ComponentProps<"div"> ), }) } function DropdownMenuLabel({ inset, ...props }: DropdownMenuLabelProps) { const inGroup = React.useContext(DropdownMenuGroupContext) const Label = inGroup ? MenuPrimitive.GroupLabel : DropdownMenuStandaloneLabel return ( <Label data-slot="dropdown-menu-label" data-inset={inset ? "" : undefined} {...props} className={ inGroup ? mergeClassName(dropdownMenuLabelClassName, props.className) : props.className } /> ) } const dropdownMenuItemVariants = cva( "relative flex min-h-8 w-full min-w-0 cursor-default items-center gap-2 rounded-(--dropdown-menu-item-radius) px-2 py-1.5 text-start text-sm wrap-anywhere outline-none select-none focus-visible:outline-hidden data-highlighted:bg-accent data-highlighted:text-accent-foreground data-inset:ps-8 motion-safe:data-[chosen]:animate-menu-blink forced-colors:data-highlighted:outline-2 forced-colors:data-highlighted:-outline-offset-2 forced-colors:data-highlighted:outline-solid pointer-coarse:min-h-11 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-highlighted:[&_svg:not([class*='text-'])]:text-accent-foreground", { variants: { variant: { default: "", destructive: "text-destructive [--menu-blink:color-mix(in_oklab,var(--color-destructive)_10%,transparent)] data-highlighted:bg-destructive/10 data-highlighted:text-destructive dark:[--menu-blink:color-mix(in_oklab,var(--color-destructive)_20%,transparent)] dark:data-highlighted:bg-destructive/20 forced-colors:data-highlighted:outline-2 forced-colors:data-highlighted:-outline-offset-2 forced-colors:data-highlighted:outline-solid [&_svg:not([class*='text-'])]:text-destructive data-highlighted:[&_svg:not([class*='text-'])]:text-destructive", }, }, defaultVariants: { variant: "default", }, } ) type ChoosableEvent = React.MouseEvent<HTMLElement> & { baseUIHandlerPrevented?: boolean } function useChooseOnClick<E extends ChoosableEvent>( closeOnClick: boolean, onClick: ((event: E) => void) | undefined ) { const context = React.useContext(DropdownMenuContext) const id = React.useId() return { chosen: context?.chosen === id ? "" : undefined, onClick: (event: E) => { onClick?.(event) if ( context && closeOnClick && event.detail !== 0 && !event.defaultPrevented && !event.baseUIHandlerPrevented ) { context.choose(id) } }, } } type DropdownMenuItemProps = MenuPrimitive.Item.Props & VariantProps<typeof dropdownMenuItemVariants> & { inset?: boolean } function DropdownMenuItem({ className, variant = "default", inset, closeOnClick = true, onClick, ...props }: DropdownMenuItemProps) { const choice = useChooseOnClick(closeOnClick, onClick) return ( <MenuPrimitive.Item data-slot="dropdown-menu-item" data-variant={variant} data-inset={inset ? "" : undefined} data-chosen={choice.chosen} closeOnClick={closeOnClick} onClick={choice.onClick} className={mergeClassName( dropdownMenuItemVariants({ variant }), className )} {...props} /> ) } const indicatorStartClassName = "pointer-events-none absolute start-2 flex size-4 items-center justify-center" const indicatorEndClassName = "pointer-events-none ms-auto flex size-4 shrink-0 items-center justify-center [[data-slot=dropdown-menu-shortcut]~&]:ms-0" const dropdownMenuIndicatorClassName = "flex items-center justify-center opacity-0 transition-[opacity,scale] duration-150 ease-out-quint data-checked:opacity-100 motion-safe:scale-50 motion-safe:data-checked:scale-100 motion-reduce:transition-none" type DropdownMenuIndicatorPosition = "start" | "end" type DropdownMenuCheckboxItemProps = MenuPrimitive.CheckboxItem.Props & { inset?: boolean indicator?: DropdownMenuIndicatorPosition } function DropdownMenuCheckboxItem({ className, children, inset, indicator = "start", closeOnClick = false, onClick, ...props }: DropdownMenuCheckboxItemProps) { const choice = useChooseOnClick(closeOnClick, onClick) return ( <MenuPrimitive.CheckboxItem data-slot="dropdown-menu-checkbox-item" data-inset={inset ? "" : undefined} data-indicator={indicator} data-chosen={choice.chosen} closeOnClick={closeOnClick} onClick={choice.onClick} className={mergeClassName( cn(dropdownMenuItemVariants(), indicator === "start" && "ps-8"), className )} {...props} > {indicator === "start" ? ( <span aria-hidden className={indicatorStartClassName}> <MenuPrimitive.CheckboxItemIndicator keepMounted data-slot="dropdown-menu-checkbox-item-indicator" className={dropdownMenuIndicatorClassName} > <IconCheck className="text-current" /> </MenuPrimitive.CheckboxItemIndicator> </span> ) : null} {children} {indicator === "end" ? ( <span aria-hidden className={indicatorEndClassName}> <MenuPrimitive.CheckboxItemIndicator keepMounted data-slot="dropdown-menu-checkbox-item-indicator" className={dropdownMenuIndicatorClassName} > <IconCheck className="text-current" /> </MenuPrimitive.CheckboxItemIndicator> </span> ) : null} </MenuPrimitive.CheckboxItem> ) } function DropdownMenuRadioGroup(props: MenuPrimitive.RadioGroup.Props) { return ( <DropdownMenuGroupContext.Provider value> <MenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} /> </DropdownMenuGroupContext.Provider> ) } type DropdownMenuRadioItemProps = MenuPrimitive.RadioItem.Props & { inset?: boolean indicator?: DropdownMenuIndicatorPosition } function DropdownMenuRadioItem({ className, children, inset, indicator = "start", closeOnClick = false, onClick, ...props }: DropdownMenuRadioItemProps) { const choice = useChooseOnClick(closeOnClick, onClick) return ( <MenuPrimitive.RadioItem data-slot="dropdown-menu-radio-item" data-inset={inset ? "" : undefined} data-indicator={indicator} data-chosen={choice.chosen} closeOnClick={closeOnClick} onClick={choice.onClick} className={mergeClassName( cn(dropdownMenuItemVariants(), indicator === "start" && "ps-8"), className )} {...props} > {indicator === "start" ? ( <span aria-hidden className={indicatorStartClassName}> <MenuPrimitive.RadioItemIndicator keepMounted data-slot="dropdown-menu-radio-item-indicator" className={dropdownMenuIndicatorClassName} > <IconCircleFilled className="size-2 text-current" /> </MenuPrimitive.RadioItemIndicator> </span> ) : null} {children} {indicator === "end" ? ( <span aria-hidden className={indicatorEndClassName}> <MenuPrimitive.RadioItemIndicator keepMounted data-slot="dropdown-menu-radio-item-indicator" className={dropdownMenuIndicatorClassName} > <IconCheck className="text-current" /> </MenuPrimitive.RadioItemIndicator> </span> ) : null} </MenuPrimitive.RadioItem> ) } function DropdownMenuSeparator({ className, ...props }: MenuPrimitive.Separator.Props) { return ( <MenuPrimitive.Separator data-slot="dropdown-menu-separator" className={mergeClassName("-mx-1 my-1 h-px bg-border", className)} {...props} /> ) } function DropdownMenuShortcut({ className, dir = "ltr", children, ...props }: React.ComponentProps<"kbd">) { return ( <kbd data-slot="dropdown-menu-shortcut" className={cn( "ms-auto shrink-0 ps-3 font-sans text-xs tracking-widest text-muted-foreground in-data-highlighted:text-accent-foreground/70 in-data-[variant=destructive]:text-destructive/70", className )} {...props} > <span dir={dir} className="[unicode-bidi:isolate]"> {children} </span> </kbd> ) } function DropdownMenuSub(props: MenuPrimitive.SubmenuRoot.Props) { return <MenuPrimitive.SubmenuRoot {...props} /> } type DropdownMenuSubTriggerProps = MenuPrimitive.SubmenuTrigger.Props & { inset?: boolean } function DropdownMenuSubTrigger({ className, children, inset, ...props }: DropdownMenuSubTriggerProps) { return ( <MenuPrimitive.SubmenuTrigger data-slot="dropdown-menu-sub-trigger" data-inset={inset ? "" : undefined} className={mergeClassName( cn( dropdownMenuItemVariants(), "data-popup-open:bg-accent data-popup-open:text-accent-foreground forced-colors:data-popup-open:outline-2 forced-colors:data-popup-open:-outline-offset-2 forced-colors:data-popup-open:outline-solid" ), className )} {...props} > {children} <IconChevronRight aria-hidden data-slot="dropdown-menu-sub-trigger-icon" className="ms-auto rtl:-scale-x-100" /> </MenuPrimitive.SubmenuTrigger> ) } function DropdownMenuSubContent({ sideOffset = 0, alignOffset = -4, ...props }: DropdownMenuContentProps) { return ( <DropdownMenuPopup slot="dropdown-menu-sub-content" sideOffset={sideOffset} alignOffset={alignOffset} {...props} /> ) } const createDropdownMenuHandle = MenuPrimitive.createHandle export { DropdownMenu, DropdownMenuTrigger, DropdownMenuPortal, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, createDropdownMenuHandle, dropdownMenuItemVariants, } export type { DropdownMenuProps, DropdownMenuContentProps, DropdownMenuLabelProps, DropdownMenuItemProps, DropdownMenuCheckboxItemProps, DropdownMenuRadioItemProps, DropdownMenuSubTriggerProps, }
Examples
Checkboxes
Controlled
Disabled
Open on hover
Inside a sheet
Check on the right
Long content
Radio group
Row actions
Right to left
Safe area
Scrolling
Select-like
Feedback
Questions, bugs or ideas for Dropdown menu? Post them here, or reply to someone else. Sign in with GitHub to comment.