FewTone UI
ComponentsDocs
  • Home

Getting started

  • Introduction
  • Installation
  • MCP Server
  • Smooth UI Skill
  • Roadmap

Components46

  • Accordion
  • Alert Dialog
  • Avatar
  • Badge
  • Breadcrumb
  • Button
  • Calendar
  • Card
  • Checkbox
  • Combobox
  • Command
  • Context Menu
  • Copy Button
  • Date Picker
  • Dialog
  • Dropdown Menu
  • Hover Card
  • Input
  • Input OTP
  • Loading Button
  • Marquee
  • Navigation Menu
  • Number Input
  • Number Ticker
  • Pagination
  • Popover
  • Progress
  • Radio Group
  • Scroll Area
  • Select
  • Sheet
  • Sidebar
  • Skeleton
  • Slider
  • Spinner
  • Stepper
  • Switch
  • Switcher
  • Table
  • Tabs
  • Text Effects
  • Textarea
  • Theme Toggle
  • Toast
  • Toggle Group
  • Tooltip
© 2026 FewTone · MIT licenceGitHub
  1. Components
  2. Dropdown Menu

Dropdown Menu

pnpm dlx shadcn@latest add …/r/dropdown-menu.json

An accessible React dropdown menu for Next.js with a highlight that glides between items, shortcuts, checkbox and radio items and sub-menus, built on Radix.

import { Button } from "@/components/ui/button"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuShortcut,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"

export function AccountMenu() {
  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="outline">Open menu</Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent className="w-56" align="start">
        <DropdownMenuLabel>My account</DropdownMenuLabel>
        <DropdownMenuItem>
          Profile
          <DropdownMenuShortcut>⇧⌘P</DropdownMenuShortcut>
        </DropdownMenuItem>
        <DropdownMenuItem>Settings</DropdownMenuItem>
        <DropdownMenuSeparator />
        <DropdownMenuItem variant="destructive">Log out</DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  )
}
account.tsx
"use client";

import { Button } from "@/components/ui/button";
import {
  DropdownMenu,
  DropdownMenuCheckboxItem,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuShortcut,
  DropdownMenuSub,
  DropdownMenuSubContent,
  DropdownMenuSubTrigger,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useState } from "react";

/** An account menu: items with shortcuts, a setting to switch, a sub-menu and a destructive action */
export default function DropdownMenuAccount() {
  const [notifications, setNotifications] = useState(true);

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="outline">Open menu</Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent className="w-56" align="start">
        <DropdownMenuLabel>My account</DropdownMenuLabel>
        <DropdownMenuGroup>
          <DropdownMenuItem>
            Profile
            <DropdownMenuShortcut>⇧⌘P</DropdownMenuShortcut>
          </DropdownMenuItem>
          <DropdownMenuItem>
            Billing
            <DropdownMenuShortcut>⌘B</DropdownMenuShortcut>
          </DropdownMenuItem>
          <DropdownMenuItem>
            Settings
            <DropdownMenuShortcut>⌘,</DropdownMenuShortcut>
          </DropdownMenuItem>
        </DropdownMenuGroup>
        <DropdownMenuSeparator />
        <DropdownMenuGroup>
          {/* Switching a setting keeps the menu open, so you see the check draw itself in */}
          <DropdownMenuCheckboxItem
            checked={notifications}
            onCheckedChange={setNotifications}
            onSelect={(event) => event.preventDefault()}
          >
            Email notifications
          </DropdownMenuCheckboxItem>
          <DropdownMenuSub>
            <DropdownMenuSubTrigger>Invite people</DropdownMenuSubTrigger>
            <DropdownMenuSubContent className="w-44">
              <DropdownMenuItem>Email</DropdownMenuItem>
              <DropdownMenuItem>Message</DropdownMenuItem>
              <DropdownMenuSeparator />
              <DropdownMenuItem>Copy invite link</DropdownMenuItem>
            </DropdownMenuSubContent>
          </DropdownMenuSub>
        </DropdownMenuGroup>
        <DropdownMenuSeparator />
        <DropdownMenuItem variant="destructive">
          Log out
          <DropdownMenuShortcut>⇧⌘Q</DropdownMenuShortcut>
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

Installation

  1. 1

    Install the dependencies.

    Package manager
    $ pnpm add class-variance-authority lucide-react motion radix-ui
  2. 2

    Add the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.

    app/globals.css
    :root {
      --size-control-sm: 2rem;
      --size-control-md: 2.25rem;
      --size-control-lg: 2.5rem;
      --size-icon-sm: 0.875rem;
      --size-icon-md: 1rem;
      --size-icon-lg: 1.25rem;
      --size-roll-fade: 0.25em;
      --size-roll-fade-x: 0.5em;
      --duration-instant: 80ms;
      --duration-fast: 150ms;
      --duration-base: 220ms;
      --duration-slow: 350ms;
      --duration-slower: 500ms;
      --z-overlay: 1200;
      --z-modal: 1300;
      --z-popover: 1400;
      --z-toast: 1500;
      --z-tooltip: 1600;
      --focus-ring-width: 2px;
      --focus-ring-offset: 2px;
      --opacity-disabled: 0.5;
      --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1);
      --destructive-foreground: oklch(1 0 0);
      --overlay: color-mix(in oklch, oklch(0 0 0) 40%, transparent);
    }
    
    .dark {
      --destructive-foreground: oklch(1 0 0);
      --overlay: color-mix(in oklch, oklch(0 0 0) 60%, transparent);
    }
    
    @theme inline {
      --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both;
      --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both;
      --animate-collapsible-open: collapsible-open var(--duration-slow) cubic-bezier(0.22, 1, 0.36, 1);
      --animate-collapsible-close: collapsible-close var(--duration-base) cubic-bezier(0.55, 0, 1, 0.45) both;
      --color-destructive-foreground: var(--destructive-foreground);
      --color-overlay: var(--overlay);
      @keyframes popover-in {
        from {
          opacity: 0;
          filter: blur(6px);
          transform: scale(0.96);
        }
        to {
          opacity: 1;
          filter: blur(0);
          transform: scale(1);
        }
      }
      @keyframes popover-out {
        from {
          opacity: 1;
          filter: blur(0);
          transform: scale(1);
        }
        to {
          opacity: 0;
          filter: blur(6px);
          transform: scale(0.96);
        }
      }
      @keyframes collapsible-open {
        from {
          height: 0;
          opacity: 0;
          filter: blur(6px);
        }
        to {
          height: var(--collapsible-height);
          opacity: 1;
          filter: blur(0);
        }
      }
      @keyframes collapsible-close {
        from {
          height: var(--collapsible-height);
          opacity: 1;
          filter: blur(0);
        }
        to {
          height: 0;
          opacity: 0;
          filter: blur(6px);
        }
      }
    }
    
    @media (prefers-reduced-motion: reduce) {
      :root {
        --duration-instant: 1ms;
        --duration-fast: 1ms;
        --duration-base: 1ms;
        --duration-slow: 1ms;
        --duration-slower: 1ms;
      }
    }
  3. 3

    Add the motion presets.

    lib/motion.ts
    import type { Transition } from "motion/react";
    
    /*
     * Motion tokens for JavaScript animations (motion/react).
     * Durations and easings mirror the CSS tokens in src/styles/tokens.css;
     * `pnpm check:tokens` fails if the two drift apart.
     */
    
    /** Durations in seconds. Mirrors --duration-* in tokens.css. */
    export const duration = {
      instant: 0.08,
      fast: 0.15,
      base: 0.22,
      slow: 0.35,
      slower: 0.5,
    } as const;
    
    /** Cubic-bezier easings. Mirrors --ease-* in tokens.css. */
    export const ease = {
      out: [0.22, 1, 0.36, 1],
      in: [0.55, 0, 1, 0.45],
      inOut: [0.65, 0, 0.35, 1],
    } as const;
    
    /**
     * Named springs: the library's motion voice.
     * - snappy: quick, almost no overshoot. Toggles, tabs, small controls.
     * - smooth: slow and settled. Panels, sidebars, page sections.
     * - bouncy: playful overshoot. Rare, for moments of delight.
     */
    export const spring = {
      snappy: { type: "spring", stiffness: 520, damping: 38, mass: 1 },
      smooth: { type: "spring", stiffness: 120, damping: 20, mass: 1 },
      bouncy: { type: "spring", stiffness: 300, damping: 15, mass: 1 },
    } as const satisfies Record<string, Transition>;
    
    export type SpringName = keyof typeof spring;
    
    /**
     * The blur (owner, 2026-10-15): content that appears, leaves or swaps sharpens in from a 6px blur as it
     * fades, instead of only fading. Pair it with the movement the pattern already has. Not for fills,
     * highlights, hover or anything full-screen (those still fade), and with reduced motion it's opacity only.
     * The CSS keyframes in tokens.css use the same 6px (checked by `pnpm check:tokens`).
     */
    export const blur = { hidden: "blur(6px)", shown: "blur(0px)" } as const;
    
    /** Swapping content in place (Loading Button, Copy Button): the new rises 6px from blurred; the old lifts away blurring */
    export const swap = {
      initial: { opacity: 0, filter: blur.hidden, y: 6 },
      animate: { opacity: 1, filter: blur.shown, y: 0 },
      exit: { opacity: 0, filter: blur.hidden, y: -6 },
    } as const;
    
    /**
     * The flow of a changing number (Number Ticker): a spring for the characters' moves, the width and the digits'
     * spin, sampled into CSS linear(), and a fade for characters coming and going. Web Animations timings, in
     * milliseconds. The curve is from number-flow, © 2024 Maxwell Barvian, MIT licence (its full notice is in
     * components/number-ticker/number-ticker.tsx).
     */
    export const flow = {
      transform: {
        duration: 900,
        easing:
          "linear(0,.005,.019,.039,.066,.096,.129,.165,.202,.24,.278,.316,.354,.39,.426,.461,.494,.526,.557,.586,.614,.64,.665,.689,.711,.731,.751,.769,.786,.802,.817,.831,.844,.856,.867,.877,.887,.896,.904,.912,.919,.925,.931,.937,.942,.947,.951,.955,.959,.962,.965,.968,.971,.973,.976,.978,.98,.981,.983,.984,.986,.987,.988,.989,.99,.991,.992,.992,.993,.994,.994,.995,.995,.996,.996,.9963,.9967,.9969,.9972,.9975,.9977,.9979,.9981,.9982,.9984,.9985,.9987,.9988,.9989,1)",
      },
      opacity: { duration: 450, easing: "ease-out" },
    } as const satisfies Record<string, EffectTiming>;
  4. 4

    Copy the source into your project.

    components/ui/dropdown-menu.tsx
    "use client";
    
    import { duration, ease, spring } from "@/lib/motion";
    import { cn } from "@/lib/utils";
    import { cva } from "class-variance-authority";
    import { ChevronRightIcon } from "lucide-react";
    import { type HTMLMotionProps, LayoutGroup, motion, useReducedMotion } from "motion/react";
    import { DropdownMenu as DropdownMenuPrimitive, Slot } from "radix-ui";
    import {
      type ComponentProps,
      createContext,
      type FocusEvent,
      type FocusEventHandler,
      type ReactNode,
      useCallback,
      useContext,
      useEffect,
      useId,
      useMemo,
      useRef,
      useState,
    } from "react";
    
    /*
     * Dropdown Menu, built on Radix Dropdown Menu (the WAI-ARIA menu button: arrow keys, Home/End, typeahead,
     * sub-menus on the arrow keys, Escape). Context Menu builds on the pieces exported at the end, so the two
     * menus look and move the same.
     * - The surface is Select's: the same border, shadow and keyframes, opening from the trigger and closing back
     *   into it through the blur.
     * - One highlight glides between the items of each menu level (Select's layoutId pill, on spring.snappy).
     *   Radix focuses items on hover and on arrow keys, so it follows both. When the pointer leaves for a label, a
     *   separator or outside the menu, it fades out where it was. Back on a row while it's still fading, it glides
     *   on from there; once it's gone, it fades in at the new row instead of flying over from the old one.
     * - A menu and its sub-menus share that one highlight: it stays on a sub-menu's trigger while its sub-menu is
     *   open, then travels across into the sub-menu's row (and back), never a second one. The chevron nudges towards it.
     * - On a destructive item the highlight takes a red tint, which fades in and out as it glides.
     * - Checkbox items draw their check in and erase it backwards (Checkbox's motion); radio items grow their dot.
     *   Both marks sit at the end of the row, as in Select, so labels line up with plain items.
     * - With reduced motion the highlight jumps and the marks appear at once.
     */
    
    type Tone = "default" | "destructive";
    
    type Highlight = {
      /** The row the highlight sits in, or null when there's none */
      row: string | null;
      /** False while it fades out in the row it was last in */
      shown: boolean;
      /** The row's tone, and the tone of the row it came from, so the red tint fades rather than snaps */
      tone: Tone;
      from: Tone;
      /**
       * Counts appearances from nothing. Each one has its own layoutId, so the highlight fades in where it appears
       * rather than gliding over from where it last faded out.
       */
      appearance: number;
      /** The first row of an appearance fades the highlight in; the rows after it glide it */
      fresh: boolean;
    };
    
    type Level = {
      highlight: Highlight;
      take: (row: string, tone: Tone) => void;
      release: (row: string) => void;
      /** The fade-out has finished: remove the highlight */
      settle: () => void;
      /** The row is gone: remove its highlight without a fade */
      drop: (row: string) => void;
      reduce: boolean;
    };
    
    const LevelContext = createContext<Level | null>(null);
    
    /**
     * One menu level (the menu, or a sub-menu) and its one highlight. It lives inside the content, which Radix
     * remounts on every opening, so each opening starts clean.
     */
    function MenuLevel({ children }: { children: ReactNode }) {
      const parent = useContext(LevelContext);
      const groupId = useId();
      const reduce = useReducedMotion() ?? false;
      const [highlight, setHighlight] = useState<Highlight>({
        row: null,
        shown: false,
        tone: "default",
        from: "default",
        appearance: 0,
        fresh: false,
      });
    
      const take = useCallback((row: string, tone: Tone) => {
        setHighlight((current) => {
          if (current.row === row && current.shown && current.tone === tone) return current;
          const appears = current.row === null;
          return {
            row,
            shown: true,
            tone,
            from: appears ? tone : current.tone,
            appearance: current.appearance + (appears ? 1 : 0),
            fresh: appears,
          };
        });
      }, []);
    
      const release = useCallback(
        (row: string) => {
          setHighlight((current) => {
            if (current.row !== row) return current;
            // With reduced motion there's no fade to wait for
            if (reduce) return { ...current, row: null, shown: false };
            return current.shown ? { ...current, shown: false } : current;
          });
        },
        [reduce],
      );
    
      const settle = useCallback(() => {
        setHighlight((current) => (current.shown ? current : { ...current, row: null }));
      }, []);
    
      // A row that unmounts (a sub-menu that closed) while it holds the highlight takes it away at once: its
      // fade-out would never finish, and the next row would glide in from a highlight that no longer exists
      const drop = useCallback((row: string) => {
        setHighlight((current) => (current.row === row ? { ...current, row: null, shown: false } : current));
      }, []);
    
      const level = useMemo(
        () => ({ highlight, take, release, settle, drop, reduce }),
        [highlight, take, release, settle, drop, reduce],
      );
    
      // A sub-menu joins its parent's level instead of starting its own: the menu has one highlight, so moving
      // from a trigger into its sub-menu carries the same highlight across, and the trigger doesn't keep another
      if (parent) return <>{children}</>;
    
      return (
        <LevelContext.Provider value={level}>
          <LayoutGroup id={groupId}>{children}</LayoutGroup>
        </LevelContext.Provider>
      );
    }
    
    /**
     * The highlight, inside the row that has it. Rows are not stacking contexts: it joins the surface's own layer
     * (the surface is `isolate`), so it passes behind every row's text, whichever way it glides.
     */
    function MenuHighlight({ row }: { row: string }) {
      const level = useContext(LevelContext);
      if (!level || level.highlight.row !== row) return null;
      const { highlight, reduce, settle } = level;
      const fade = reduce ? { duration: 0 } : { duration: duration.fast, ease: ease.out };
      // Two fills, so the red tint crossfades with the neutral one instead of snapping as the highlight leaves
      const fill = (tone: Tone) => ({
        initial: { opacity: highlight.from === tone ? 1 : 0 },
        animate: { opacity: highlight.tone === tone ? 1 : 0 },
        transition: fade,
      });
    
      return (
        <motion.span
          layoutId={`menu-highlight-${highlight.appearance}`}
          data-slot="menu-highlight"
          aria-hidden
          className="pointer-events-none absolute inset-0 -z-10 overflow-hidden rounded-md"
          initial={highlight.fresh ? { opacity: 0 } : false}
          animate={{ opacity: highlight.shown ? 1 : 0 }}
          transition={reduce ? { duration: 0 } : { ...spring.snappy, opacity: fade }}
          onAnimationComplete={() => {
            if (!highlight.shown) settle();
          }}
        >
          <motion.span className="absolute inset-0 bg-accent" {...fill("default")} />
          <motion.span
            className="absolute inset-0 bg-destructive/10 dark:bg-destructive/20"
            {...fill("destructive")}
          />
        </motion.span>
      );
    }
    
    /**
     * Connects a row to its level's highlight: the row takes it when Radix focuses it (hover or arrow keys) and
     * lets go when focus leaves the level's rows. `keep` holds on to it: a sub-menu's trigger while its sub-menu
     * is open.
     */
    function useMenuRow({
      tone = "default",
      keep = false,
      onFocus,
      onBlur,
    }: {
      tone?: Tone;
      keep?: boolean;
      onFocus?: FocusEventHandler<HTMLDivElement>;
      onBlur?: FocusEventHandler<HTMLDivElement>;
    }) {
      const level = useContext(LevelContext);
      const row = useId();
      const focused = useRef(false);
      const release = level?.release;
      const drop = level?.drop;
    
      // A sub-menu that closes while its trigger doesn't have focus (the pointer left for the menu's edge) lets go
      useEffect(() => {
        if (!keep && !focused.current) release?.(row);
      }, [keep, release, row]);
    
      useEffect(() => () => drop?.(row), [drop, row]);
    
      return {
        onFocus: (event: FocusEvent<HTMLDivElement>) => {
          focused.current = true;
          level?.take(row, tone);
          onFocus?.(event);
        },
        onBlur: (event: FocusEvent<HTMLDivElement>) => {
          focused.current = false;
          // Moving to another row, of this menu or its sub-menu: that row takes the highlight, so it glides instead of fading
          const next = event.relatedTarget;
          const toRow =
            next instanceof Element && next.matches("[role^=menuitem]") && next.closest("[role=menu]");
          if (!toRow && !keep) level?.release(row);
          onBlur?.(event);
        },
        highlight: <MenuHighlight row={row} />,
      };
    }
    
    /** Whether the nearest sub-menu is open, for its trigger */
    const MenuSubContext = createContext(false);
    
    /** A sub-menu's open state, controlled or not, so its trigger knows to keep the highlight while it's open */
    function useMenuSub({
      open,
      defaultOpen = false,
      onOpenChange,
    }: {
      open?: boolean;
      defaultOpen?: boolean;
      onOpenChange?: (open: boolean) => void;
    }) {
      const [uncontrolled, setUncontrolled] = useState(defaultOpen);
      const current = open ?? uncontrolled;
      return {
        open: current,
        onOpenChange: (next: boolean) => {
          if (open === undefined) setUncontrolled(next);
          onOpenChange?.(next);
        },
      };
    }
    
    /** The same check as Checkbox, Copy Button and Loading Button, and a dash for indeterminate */
    const CHECK = "M5 12.5l4.5 4.5L19 7.5";
    const DASH = "M7 12h10";
    
    /** A mark that draws itself in and erases the way it came. Its opacity goes with it, so a round cap never leaves a dot. */
    function Mark({ d, shown, reduce }: { d: string; shown: boolean; reduce: boolean }) {
      return (
        <motion.path
          d={d}
          stroke="currentColor"
          strokeWidth="2"
          strokeLinecap="round"
          strokeLinejoin="round"
          initial={false}
          animate={{ pathLength: shown ? 1 : 0, opacity: shown ? 1 : 0 }}
          transition={
            reduce
              ? { duration: 0 }
              : shown
                ? { pathLength: { duration: duration.base, ease: ease.out }, opacity: { duration: 0 } }
                : { duration: duration.fast, ease: ease.in }
          }
        />
      );
    }
    
    /** A checkbox item's mark. Nothing draws when a menu opens; only a change does. */
    function MenuCheck({ checked }: { checked?: boolean | "indeterminate" }) {
      const reduce = useContext(LevelContext)?.reduce ?? false;
      return (
        <svg viewBox="0 0 24 24" fill="none" aria-hidden className="size-(--size-icon-md) text-current">
          <Mark d={CHECK} shown={checked === true} reduce={reduce} />
          <Mark d={DASH} shown={checked === "indeterminate"} reduce={reduce} />
        </svg>
      );
    }
    
    /** The class names both menus share */
    const menuClasses = {
      // Select's floating surface. Each menu adds Radix's transform origin and available height for its own type.
      surface:
        "relative isolate z-(--z-popover) min-w-32 overflow-x-hidden overflow-y-auto rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-md outline-hidden data-[state=open]:animate-popover-in data-[state=closed]:animate-popover-out",
      label: "px-2 py-1.5 text-xs text-muted-foreground data-inset:ps-8",
      separator: "pointer-events-none -mx-1 my-1 h-px bg-border",
      shortcut: "ms-auto ps-4 text-xs text-muted-foreground",
      // Checkbox and radio items leave room at the end for their mark, like Select's options
      choice: "pe-8",
      indicator: "pointer-events-none absolute end-2 flex size-(--size-icon-md) items-center justify-center",
      // A fill, so it only fades (no blur) as it grows in from half size
      dot: "size-2 rounded-full bg-current transition duration-(--duration-fast) ease-out data-[state=unchecked]:scale-50 data-[state=unchecked]:opacity-0",
      subTrigger: "group/menu-sub-trigger data-[state=open]:text-accent-foreground",
      // The chevron nudges towards the sub-menu while it's open
      chevron:
        "ms-auto transition-transform duration-(--duration-fast) ease-out group-data-[state=open]/menu-sub-trigger:translate-x-0.5 rtl:rotate-180 rtl:group-data-[state=open]/menu-sub-trigger:-translate-x-0.5",
    };
    
    const menuItemVariants = cva(
      [
        "relative flex cursor-default items-center gap-2 rounded-md py-1.5 ps-2 pe-2 text-sm outline-hidden select-none data-inset:ps-8",
        "data-highlighted:text-accent-foreground",
        "data-disabled:pointer-events-none data-disabled:opacity-(--opacity-disabled)",
        "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-(--size-icon-md) [&_svg:not([class*='text-'])]:text-muted-foreground",
      ],
      {
        variants: {
          variant: {
            default: "",
            destructive:
              "text-destructive data-highlighted:text-destructive [&_svg:not([class*='text-'])]:text-destructive",
          },
        },
        defaultVariants: { variant: "default" },
      },
    );
    
    /**
     * The floating surface of a menu or sub-menu, and its level of the highlight. Radix renders it through
     * `asChild`, so the surface is the motion element itself: with layoutScroll the highlight stays true while a
     * long menu scrolls.
     */
    function MenuSurface({
      className,
      children,
      ...props
    }: Omit<HTMLMotionProps<"div">, "children"> & { children?: ReactNode }) {
      return (
        <motion.div layoutScroll className={cn(menuClasses.surface, className)} {...props}>
          <MenuLevel>{children}</MenuLevel>
        </motion.div>
      );
    }
    
    function DropdownMenu(props: ComponentProps<typeof DropdownMenuPrimitive.Root>) {
      return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
    }
    
    function DropdownMenuPortal(props: ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
      return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
    }
    
    function DropdownMenuTrigger(props: ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
      return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
    }
    
    function DropdownMenuContent({
      className,
      sideOffset = 4,
      children,
      ...props
    }: ComponentProps<typeof DropdownMenuPrimitive.Content>) {
      return (
        <DropdownMenuPrimitive.Portal>
          <DropdownMenuPrimitive.Content
            data-slot="dropdown-menu-content"
            sideOffset={sideOffset}
            {...props}
            asChild
          >
            <MenuSurface
              className={cn(
                "max-h-(--radix-dropdown-menu-content-available-height) origin-(--radix-dropdown-menu-content-transform-origin)",
                className,
              )}
            >
              {children}
            </MenuSurface>
          </DropdownMenuPrimitive.Content>
        </DropdownMenuPrimitive.Portal>
      );
    }
    
    function DropdownMenuGroup(props: ComponentProps<typeof DropdownMenuPrimitive.Group>) {
      return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
    }
    
    function DropdownMenuLabel({
      className,
      inset,
      ...props
    }: ComponentProps<typeof DropdownMenuPrimitive.Label> & { inset?: boolean }) {
      return (
        <DropdownMenuPrimitive.Label
          data-slot="dropdown-menu-label"
          data-inset={inset || undefined}
          className={cn(menuClasses.label, className)}
          {...props}
        />
      );
    }
    
    function DropdownMenuItem({
      className,
      inset,
      variant = "default",
      children,
      onFocus,
      onBlur,
      ...props
    }: ComponentProps<typeof DropdownMenuPrimitive.Item> & {
      /** Lines the label up with items that start with an icon */
      inset?: boolean;
      /** Destructive is red, and tints the highlight red as it lands */
      variant?: Tone;
    }) {
      const row = useMenuRow({ tone: variant, onFocus, onBlur });
      return (
        <DropdownMenuPrimitive.Item
          data-slot="dropdown-menu-item"
          data-inset={inset || undefined}
          data-variant={variant}
          className={cn(menuItemVariants({ variant }), className)}
          onFocus={row.onFocus}
          onBlur={row.onBlur}
          {...props}
        >
          {row.highlight}
          {/* With asChild the child (a link) becomes the item and the highlight goes inside it */}
          <Slot.Slottable>{children}</Slot.Slottable>
        </DropdownMenuPrimitive.Item>
      );
    }
    
    function DropdownMenuCheckboxItem({
      className,
      children,
      checked,
      onFocus,
      onBlur,
      ...props
    }: ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
      const row = useMenuRow({ onFocus, onBlur });
      return (
        <DropdownMenuPrimitive.CheckboxItem
          data-slot="dropdown-menu-checkbox-item"
          checked={checked}
          className={cn(menuItemVariants(), menuClasses.choice, className)}
          onFocus={row.onFocus}
          onBlur={row.onBlur}
          {...props}
        >
          {row.highlight}
          <span className={menuClasses.indicator}>
            <MenuCheck checked={checked} />
          </span>
          {children}
        </DropdownMenuPrimitive.CheckboxItem>
      );
    }
    
    function DropdownMenuRadioGroup(props: ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
      return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
    }
    
    function DropdownMenuRadioItem({
      className,
      children,
      onFocus,
      onBlur,
      ...props
    }: ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
      const row = useMenuRow({ onFocus, onBlur });
      return (
        <DropdownMenuPrimitive.RadioItem
          data-slot="dropdown-menu-radio-item"
          className={cn(menuItemVariants(), menuClasses.choice, className)}
          onFocus={row.onFocus}
          onBlur={row.onBlur}
          {...props}
        >
          {row.highlight}
          <span className={menuClasses.indicator}>
            {/* Always mounted, so the dot can grow in and shrink away rather than appear */}
            <DropdownMenuPrimitive.ItemIndicator forceMount className={menuClasses.dot} />
          </span>
          {children}
        </DropdownMenuPrimitive.RadioItem>
      );
    }
    
    function DropdownMenuSeparator({
      className,
      ...props
    }: ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
      return (
        <DropdownMenuPrimitive.Separator
          data-slot="dropdown-menu-separator"
          className={cn(menuClasses.separator, className)}
          {...props}
        />
      );
    }
    
    /** A shortcut hint at the end of an item. It only shows the keys; binding them is up to you. */
    function DropdownMenuShortcut({ className, ...props }: ComponentProps<"span">) {
      return (
        <span data-slot="dropdown-menu-shortcut" className={cn(menuClasses.shortcut, className)} {...props} />
      );
    }
    
    function DropdownMenuSub({
      open,
      defaultOpen,
      onOpenChange,
      ...props
    }: ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
      const sub = useMenuSub({ open, defaultOpen, onOpenChange });
      return (
        <MenuSubContext.Provider value={sub.open}>
          <DropdownMenuPrimitive.Sub
            data-slot="dropdown-menu-sub"
            open={sub.open}
            onOpenChange={sub.onOpenChange}
            {...props}
          />
        </MenuSubContext.Provider>
      );
    }
    
    function DropdownMenuSubTrigger({
      className,
      inset,
      children,
      onFocus,
      onBlur,
      ...props
    }: ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & { inset?: boolean }) {
      const open = useContext(MenuSubContext);
      const row = useMenuRow({ keep: open, onFocus, onBlur });
      return (
        <DropdownMenuPrimitive.SubTrigger
          data-slot="dropdown-menu-sub-trigger"
          data-inset={inset || undefined}
          className={cn(menuItemVariants(), menuClasses.subTrigger, className)}
          onFocus={row.onFocus}
          onBlur={row.onBlur}
          {...props}
        >
          {row.highlight}
          {children}
          <ChevronRightIcon aria-hidden className={menuClasses.chevron} />
        </DropdownMenuPrimitive.SubTrigger>
      );
    }
    
    function DropdownMenuSubContent({
      className,
      sideOffset = 4,
      // The surface's padding and border, so the sub-menu's first item lines up with its trigger
      alignOffset = -5,
      children,
      ...props
    }: ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
      return (
        // In a portal, so the menu's own scrolling and its keyframes' transform can't clip or carry it
        <DropdownMenuPrimitive.Portal>
          <DropdownMenuPrimitive.SubContent
            data-slot="dropdown-menu-sub-content"
            sideOffset={sideOffset}
            alignOffset={alignOffset}
            {...props}
            asChild
          >
            <MenuSurface
              className={cn(
                "max-h-(--radix-dropdown-menu-content-available-height) origin-(--radix-dropdown-menu-content-transform-origin) shadow-lg",
                className,
              )}
            >
              {children}
            </MenuSurface>
          </DropdownMenuPrimitive.SubContent>
        </DropdownMenuPrimitive.Portal>
      );
    }
    
    // The pieces Context Menu builds on (context-menu.tsx), so the two menus never drift apart
    export {
      DropdownMenu,
      DropdownMenuCheckboxItem,
      DropdownMenuContent,
      DropdownMenuGroup,
      DropdownMenuItem,
      DropdownMenuLabel,
      DropdownMenuPortal,
      DropdownMenuRadioGroup,
      DropdownMenuRadioItem,
      DropdownMenuSeparator,
      DropdownMenuShortcut,
      DropdownMenuSub,
      DropdownMenuSubContent,
      DropdownMenuSubTrigger,
      DropdownMenuTrigger,
      MenuCheck,
      MenuSubContext,
      MenuSurface,
      menuClasses,
      menuItemVariants,
      useMenuRow,
      useMenuSub,
    };
  5. 5

    Update the import paths to match your project.

Examples

Checkbox and radio items

Checkbox items draw their check in and erase it backwards; radio items grow their dot. This menu stays open while you choose: call event.preventDefault() in onSelect.

options.tsx
"use client";

import { Button } from "@/components/ui/button";
import {
  DropdownMenu,
  DropdownMenuCheckboxItem,
  DropdownMenuContent,
  DropdownMenuLabel,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useState } from "react";

const panels = [
  { value: "top", label: "Top" },
  { value: "bottom", label: "Bottom" },
  { value: "right", label: "Right" },
];

/** View options: settings switched on and off, and one choice from a set. The menu stays open while you choose. */
export default function DropdownMenuOptions() {
  const [statusBar, setStatusBar] = useState(true);
  const [minimap, setMinimap] = useState(false);
  const [panel, setPanel] = useState("bottom");
  const keepOpen = (event: Event) => event.preventDefault();

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="outline">View</Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent className="w-52">
        <DropdownMenuLabel>Show</DropdownMenuLabel>
        <DropdownMenuCheckboxItem checked={statusBar} onCheckedChange={setStatusBar} onSelect={keepOpen}>
          Status bar
        </DropdownMenuCheckboxItem>
        <DropdownMenuCheckboxItem checked={minimap} onCheckedChange={setMinimap} onSelect={keepOpen}>
          Minimap
        </DropdownMenuCheckboxItem>
        <DropdownMenuCheckboxItem disabled>Activity bar</DropdownMenuCheckboxItem>
        <DropdownMenuSeparator />
        <DropdownMenuLabel>Panel position</DropdownMenuLabel>
        <DropdownMenuRadioGroup value={panel} onValueChange={setPanel}>
          {panels.map(({ value, label }) => (
            <DropdownMenuRadioItem key={value} value={value} onSelect={keepOpen}>
              {label}
            </DropdownMenuRadioItem>
          ))}
        </DropdownMenuRadioGroup>
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

Icon trigger and icons

An icon button opens a row's actions, and items can start with an icon. The highlight takes a red tint on a destructive item.

icons.tsx
import { Button } from "@/components/ui/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuShortcut,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ArchiveIcon, CopyIcon, EllipsisIcon, PencilIcon, Trash2Icon } from "lucide-react";

/** A row's actions behind an icon button, each item led by an icon */
export default function DropdownMenuIcons() {
  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="outline" size="icon" aria-label="More actions">
          <EllipsisIcon />
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent className="w-48" align="end">
        <DropdownMenuItem>
          <PencilIcon aria-hidden />
          Edit
          <DropdownMenuShortcut>⌘E</DropdownMenuShortcut>
        </DropdownMenuItem>
        <DropdownMenuItem>
          <CopyIcon aria-hidden />
          Duplicate
          <DropdownMenuShortcut>⌘D</DropdownMenuShortcut>
        </DropdownMenuItem>
        <DropdownMenuItem>
          <ArchiveIcon aria-hidden />
          Archive
        </DropdownMenuItem>
        <DropdownMenuSeparator />
        <DropdownMenuItem variant="destructive">
          <Trash2Icon aria-hidden />
          Delete
          <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

API reference

DropdownMenu

The root. Also accepts every Radix Dropdown Menu Root prop.

PropTypeDefaultDescription
open?boolean–Whether the menu is open, when you control it.
defaultOpen?booleanfalseOpens the menu at first.
onOpenChange?(open: boolean) => void–Called when the menu opens or closes.
modal?booleantrueWhile open, the rest of the page can't be scrolled or clicked, and screen readers only see the menu.
dir?"ltr" | "rtl"–Reading direction. In rtl, sub-menus open to the left.

DropdownMenuTrigger

The button that opens the menu. Enter, Space and ArrowDown open it from the keyboard and focus the first item.

PropTypeDefaultDescription
asChild?booleanfalseUse your own button, for example Button, instead of a plain one.
disabled?booleanfalsePrevents opening.

DropdownMenuContent

The menu. It opens from the trigger and closes back into it through the blur, and flips when there's no room. It scrolls when it's taller than the space left. Renders in a portal.

PropTypeDefaultDescription
align?"start" | "center" | "end""center"How it lines up with the trigger.
side?"top" | "right" | "bottom" | "left""bottom"The side of the trigger it opens on, when there's room.
sideOffset?number4Gap between the trigger and the menu, in px.
alignOffset?number0Shifts it along the trigger, in px.
loop?booleanfalseArrow keys wrap from the last item to the first, and back.

DropdownMenuItem

An action. One highlight glides to the item under the pointer or the keyboard, and fades out when the pointer leaves the items.

PropTypeDefaultDescription
variant?"default" | "destructive""default"Destructive is red, and the highlight takes a red tint as it lands on it.
inset?booleanfalseIndents the label to line up with items that start with an icon.
disabled?booleanfalseSkips the item.
onSelect?(event: Event) => void–Called when the item is picked with a click, Enter or Space. The menu then closes; call event.preventDefault() to keep it open.
textValue?string–The text typeahead matches, when the item's content isn't plain text.

DropdownMenuCheckboxItem

An item that switches a setting on and off. Its check draws itself in at the end of the row.

PropTypeDefaultDescription
checked?boolean | "indeterminate"falseWhether it's on. Indeterminate draws a dash.
onCheckedChange?(checked: boolean) => void–Called with the new state when it's picked.
disabled?booleanfalseSkips the item.
onSelect?(event: Event) => void–Call event.preventDefault() to keep the menu open while switching.

DropdownMenuRadioGroup

Holds radio items, one of which is picked.

PropTypeDefaultDescription
value?string–The picked item's value.
onValueChange?(value: string) => void–Called with the new value when another item is picked.

DropdownMenuRadioItem

One choice in a radio group. Its dot grows in at the end of the row.

PropTypeDefaultDescription
valuestring–The value this item picks.
disabled?booleanfalseSkips the item.
onSelect?(event: Event) => void–Call event.preventDefault() to keep the menu open while choosing.

DropdownMenuLabel

A heading for the items below it. It isn't focusable.

PropTypeDefaultDescription
inset?booleanfalseIndents it to line up with items that start with an icon.

DropdownMenuGroup

Groups related items (role="group").

PropTypeDefaultDescription
childrenReactNode–The items, often after a DropdownMenuLabel.

DropdownMenuSeparator

A line between groups.

PropTypeDefaultDescription
className?string–Extra classes, merged with cn.

DropdownMenuShortcut

A shortcut hint at the end of an item.

PropTypeDefaultDescription
childrenReactNode–The keys, for example ⌘K. It only shows them: bind the keys yourself.

DropdownMenuSub

A sub-menu: wraps its trigger and its content.

PropTypeDefaultDescription
open?boolean–Whether the sub-menu is open, when you control it.
defaultOpen?booleanfalseOpens it at first.
onOpenChange?(open: boolean) => void–Called when it opens or closes.

DropdownMenuSubTrigger

The item that opens a sub-menu, on hover, a click, Enter, Space or ArrowRight. It keeps the highlight while its sub-menu is open, and its chevron nudges towards it.

PropTypeDefaultDescription
inset?booleanfalseIndents the label to line up with items that start with an icon.
disabled?booleanfalseSkips the item.

DropdownMenuSubContent

The sub-menu. It opens beside its trigger with its first item level with it, and has its own highlight. ArrowLeft goes back to the trigger; Escape closes the whole menu. Renders in a portal.

PropTypeDefaultDescription
sideOffset?number4Gap between the menu and the sub-menu, in px.
alignOffset?number-5Shifts it along the trigger, in px. The default lines its first item up with the trigger.
loop?booleanfalseArrow keys wrap from the last item to the first, and back.

DropdownMenuPortal

Renders its children into document.body. DropdownMenuContent and DropdownMenuSubContent already use it.

PropTypeDefaultDescription
container?HTMLElement | nulldocument.bodyWhere to render instead.

Related components

  • Right-click here
    Context Menu

    An accessible React context menu for Next.js: a right-click menu with a gliding highlight, shortcuts, checkbox and radio items and sub-menus, built on Radix.

  • Select

    An accessible React select with a gliding highlight, groups, labels and a scrollable list, built on Radix Select.

  • Button

    An accessible React button for Next.js and Tailwind CSS with six variants, three sizes, icons and a soft tint on hover and press, built on a native button.