Talisson Costa
← All components

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.json

Or 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';