Dropdown Menu
Menu that grows out of its trigger, even after flipping, with a quick item cascade, typeahead and full keyboard support.
- transform-origin
- staggerChildren
- Floating UI
- a11y: menu pattern
- compound components
Opens from its trigger. Try ↑ ↓, typing a letter, Esc.
Install
npx shadcn@latest add https://tcosta.dev/r/dropdown-menu.jsonOr copy the files below into components/dropdown-menu/. They use the shadcn/ui theme tokens and cn from @/lib/utils, and need motion + @floating-ui/react + class-variance-authority.
Source
dropdown-menu.tsx
'use client';
import type { Placement } from '@floating-ui/react';
import type { ReactNode } from 'react';
import { DropdownMenuContext } from './dropdown-menu-context';
import { useDropdownMenu } from './use-dropdown-menu';
type DropdownMenuProps = {
placement?: Placement;
children: ReactNode;
};
export function DropdownMenu({ placement = 'bottom-start', children }: DropdownMenuProps) {
const menu = useDropdownMenu(placement);
return <DropdownMenuContext value={menu}>{children}</DropdownMenuContext>;
}
dropdown-menu-trigger.tsx
'use client';
import { cn } from '@/lib/utils';
import { Button, type ButtonProps } from '../button';
import { useDropdownMenuContext } from './dropdown-menu-context';
type DropdownMenuTriggerProps = Omit<ButtonProps, 'ref'>;
export function DropdownMenuTrigger({
variant = 'outline',
size,
loading,
className,
...props
}: DropdownMenuTriggerProps) {
const { isOpen, setReference, getReferenceProps } = useDropdownMenuContext();
// Button's `size`/`loading` are variants, not DOM attributes, so they skip Floating UI's props.
return (
<Button
ref={setReference}
variant={variant}
size={size}
loading={loading}
data-state={isOpen ? 'open' : 'closed'}
className={cn('data-[state=open]:bg-accent', className)}
{...getReferenceProps(props)}
/>
);
}
dropdown-menu-content.tsx
'use client';
import { FloatingFocusManager, FloatingList } from '@floating-ui/react';
import { AnimatePresence, motion, type Variants } from 'motion/react';
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { useDropdownMenuContext } from './dropdown-menu-context';
import { transformOrigin } from './transform-origin';
// Opens a touch slower than it closes; items follow in a short cascade.
const menuVariants: Variants = {
closed: { opacity: 0, scale: 0.96, transition: { duration: 0.1, ease: 'easeIn' } },
open: {
opacity: 1,
scale: 1,
transition: { type: 'spring', duration: 0.25, bounce: 0, staggerChildren: 0.02 },
},
};
type DropdownMenuContentProps = {
className?: string;
children: ReactNode;
};
export function DropdownMenuContent({ className, children }: DropdownMenuContentProps) {
const {
isOpen,
placement,
floatingContext,
floatingStyles,
setFloating,
getFloatingProps,
elementsRef,
labelsRef,
} = useDropdownMenuContext();
return (
<AnimatePresence>
{isOpen && (
<FloatingFocusManager context={floatingContext} modal={false}>
<div
ref={setFloating}
style={floatingStyles}
className="z-50 outline-none"
{...getFloatingProps()}
>
<motion.div
variants={menuVariants}
initial="closed"
animate="open"
exit="closed"
style={{ transformOrigin: transformOrigin(placement) }}
className={cn(
'min-w-48 rounded-lg border bg-popover p-1 text-popover-foreground shadow-lg',
className,
)}
>
<FloatingList elementsRef={elementsRef} labelsRef={labelsRef}>
{children}
</FloatingList>
</motion.div>
</div>
</FloatingFocusManager>
)}
</AnimatePresence>
);
}
dropdown-menu-item.tsx
'use client';
import { useListItem } from '@floating-ui/react';
import { cva, type VariantProps } from 'class-variance-authority';
import { motion, type Variants } from 'motion/react';
import { Children, type ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { useDropdownMenuContext } from './dropdown-menu-context';
const itemVariants = cva(
[
'flex w-full cursor-default items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm outline-none select-none',
'disabled:pointer-events-none disabled:opacity-50',
],
{
variants: {
variant: {
default: 'focus:bg-accent focus:text-accent-foreground',
destructive: 'text-destructive focus:bg-destructive/10',
},
},
defaultVariants: {
variant: 'default',
},
},
);
// Timed like the menu: a quick fade in, and out as fast as the menu closes.
const enterVariants: Variants = {
closed: { opacity: 0, y: -2, transition: { duration: 0.1 } },
open: { opacity: 1, y: 0, transition: { duration: 0.15, ease: 'easeOut' } },
};
// Typeahead label from the text parts of the children, e.g. "Duplicate" for `Duplicate <Shortcut />`.
function textOf(children: ReactNode) {
const parts = Children.toArray(children).filter((child) => typeof child === 'string');
return parts.join('').trim() || null;
}
type DropdownMenuItemProps = VariantProps<typeof itemVariants> & {
onSelect?: () => void;
disabled?: boolean;
/** Text for typeahead. Defaults to the text in `children`. */
textValue?: string;
className?: string;
children: ReactNode;
};
export function DropdownMenuItem({
onSelect,
disabled = false,
textValue,
variant,
className,
children,
}: DropdownMenuItemProps) {
const { activeIndex, setIsOpen, getItemProps } = useDropdownMenuContext();
const { ref, index } = useListItem({
label: disabled ? null : (textValue ?? textOf(children)),
});
return (
// A plain <button> so it always gets Floating UI's latest ref (it changes once the item knows
// its index). The cascade animates the inner span, which inherits the menu's variants.
<button
ref={ref}
type="button"
role="menuitem"
disabled={disabled}
tabIndex={activeIndex === index ? 0 : -1}
className={cn(itemVariants({ variant }), className)}
{...getItemProps({
onClick: () => {
onSelect?.();
setIsOpen(false);
},
})}
>
<motion.span variants={enterVariants} className="flex flex-1 items-center gap-2">
{children}
</motion.span>
</button>
);
}
dropdown-menu-parts.tsx
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
type PartProps = {
className?: string;
children?: ReactNode;
};
export function DropdownMenuLabel({ className, children }: PartProps) {
return (
<div className={cn('px-2 py-1.5 text-xs font-medium text-muted-foreground', className)}>
{children}
</div>
);
}
export function DropdownMenuSeparator({ className }: PartProps) {
return <div role="separator" className={cn('-mx-1 my-1 h-px bg-border', className)} />;
}
export function DropdownMenuShortcut({ className, children }: PartProps) {
return (
<span className={cn('ml-auto pl-4 text-xs tracking-widest text-muted-foreground', className)}>
{children}
</span>
);
}
use-dropdown-menu.ts
import {
autoUpdate,
flip,
offset,
shift,
useClick,
useDismiss,
useFloating,
useInteractions,
useListNavigation,
useRole,
useTypeahead,
type Placement,
} from '@floating-ui/react';
import { useRef, useState } from 'react';
/** Positioning plus the WAI-ARIA menu behavior: arrows, Home/End, typeahead, Esc, click outside. */
export function useDropdownMenu(placement: Placement) {
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const elementsRef = useRef<Array<HTMLElement | null>>([]);
const labelsRef = useRef<Array<string | null>>([]);
const floating = useFloating({
open: isOpen,
onOpenChange: setIsOpen,
placement,
// Fixed + rendered in place (no portal): escapes `overflow: hidden` parents, and the items exist
// on the same render the menu opens, so keyboard opening can focus the first one.
strategy: 'fixed',
middleware: [offset(6), flip({ padding: 8 }), shift({ padding: 8 })],
whileElementsMounted: autoUpdate,
});
const interactions = useInteractions([
useClick(floating.context),
useRole(floating.context, { role: 'menu' }),
useDismiss(floating.context),
useListNavigation(floating.context, {
listRef: elementsRef,
activeIndex,
onNavigate: setActiveIndex,
loop: true,
}),
useTypeahead(floating.context, {
listRef: labelsRef,
activeIndex,
onMatch: isOpen ? setActiveIndex : undefined,
}),
]);
return {
...interactions,
isOpen,
setIsOpen,
activeIndex,
elementsRef,
labelsRef,
placement: floating.placement,
floatingContext: floating.context,
floatingStyles: floating.floatingStyles,
setReference: floating.refs.setReference,
setFloating: floating.refs.setFloating,
};
}
export type DropdownMenuState = ReturnType<typeof useDropdownMenu>;
transform-origin.ts
import type { Placement } from '@floating-ui/react';
const alignX = { start: 'left', end: 'right' } as const;
const alignY = { start: 'top', end: 'bottom' } as const;
/** The corner or edge touching the trigger, so the menu grows out of it (also after a flip). */
export function transformOrigin(placement: Placement) {
const [side, align] = placement.split('-') as [string, 'start' | 'end' | undefined];
if (side === 'top' || side === 'bottom') {
return `${align ? alignX[align] : 'center'} ${side === 'bottom' ? 'top' : 'bottom'}`;
}
return `${side === 'right' ? 'left' : 'right'} ${align ? alignY[align] : 'center'}`;
}
dropdown-menu-context.ts
'use client';
import { createContext, useContext } from 'react';
import type { DropdownMenuState } from './use-dropdown-menu';
export const DropdownMenuContext = createContext<DropdownMenuState | null>(null);
export function useDropdownMenuContext() {
const context = useContext(DropdownMenuContext);
if (!context) throw new Error('DropdownMenu parts must be rendered inside <DropdownMenu>.');
return context;
}
index.ts
export { DropdownMenu } from './dropdown-menu';
export { DropdownMenuContent } from './dropdown-menu-content';
export { DropdownMenuItem } from './dropdown-menu-item';
export {
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
} from './dropdown-menu-parts';
export { DropdownMenuTrigger } from './dropdown-menu-trigger';