Context Menu

Displays a menu at the pointer, triggered by a right click.

"use client"

import * as React from "react"
import { cva } from "class-variance-authority"
import { cn } from "cn"
import {
  composeRenderProps,
  Header as HeaderPrimitive,
  MenuItem as MenuItemPrimitive,
  Menu as MenuPrimitive,
  MenuSection as MenuSectionPrimitive,
  MenuTrigger as MenuTriggerPrimitive,
  Popover as PopoverPrimitive,
  Separator as SeparatorPrimitive,
  SelectionIndicator,
  SubmenuTrigger as SubmenuTriggerPrimitive,
  type MenuItemProps as MenuItemPrimitiveProps,
  type MenuSectionProps as MenuSectionPrimitiveProps,
} from "@animate-ui/react/primitives/react-aria"

import { ArrowRight01Icon, Tick02Icon } from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"

import { Pressable } from "@animate-ui/react/primitives/react-aria"
import { AnimateUIProvider } from "@animate-ui/react/provider"

function ContextMenu({
  "data-slot": dataSlot = "context-menu-content",
  placement = "bottom start",
  offset = 4,
  crossOffset = 0,
  className,
  children,
  ...props
}: Omit<
  React.ComponentProps<typeof MenuPrimitive<object>>,
  "children" | "className"
> &
  Pick<
    React.ComponentProps<typeof PopoverPrimitive>,
    "placement" | "offset" | "crossOffset"
  > & {
    "data-slot"?: string
    className?: string
    children?: React.ReactNode
  }) {
  return (
    <PopoverPrimitive
      data-slot={dataSlot}
      placement={placement}
      offset={offset}
      crossOffset={crossOffset}
      className={cn(
        "ring-foreground/10 bg-popover text-popover-foreground min-w-36 rounded-lg p-1 shadow-md ring-1 animate-none! relative bg-popover/70 before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground! **:data-[slot$=-item]:data-focused:bg-foreground/10 z-50 w-(--trigger-width) origin-(--trigger-anchor-point) overflow-x-hidden overflow-y-auto outline-none data-exiting:overflow-hidden",
        className
      )}
    >
      <MenuPrimitive
        className="max-h-[inherit] overflow-x-hidden overflow-y-auto outline-hidden"
        {...props}
      >
        {children}
      </MenuPrimitive>
    </PopoverPrimitive>
  )
}

function ContextMenuTrigger({
  ...props
}: Omit<React.ComponentProps<typeof MenuTriggerPrimitive>, "trigger">) {
  return (
    <MenuTriggerPrimitive
      data-slot="context-menu"
      trigger="contextMenu"
      {...props}
    />
  )
}

function ContextMenuGroup({
  ...props
}: Omit<MenuSectionPrimitiveProps<object>, "children"> & {
  children?: React.ReactNode
}) {
  return <MenuSectionPrimitive data-slot="context-menu-group" {...props} />
}

function ContextMenuLabel({
  className,
  inset,
  ...props
}: React.ComponentProps<typeof HeaderPrimitive> & {
  inset?: boolean
}) {
  return (
    <HeaderPrimitive
      data-slot="context-menu-label"
      data-inset={inset}
      className={cn("text-muted-foreground px-1.5 py-1 text-xs font-medium data-inset:pl-7", className)}
      {...props}
    />
  )
}

const contextMenuItemVariants = cva(
  "group/context-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
  {
    variants: {
      selectionMode: {
        none: "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive focus:*:[svg]:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4",
        single: "focus:bg-accent focus:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4",
        multiple: "focus:bg-accent focus:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4",
      },
    },
  }
)

function ContextMenuItem({
  className,
  inset,
  variant = "default",
  children,
  ...props
}: MenuItemPrimitiveProps<object> & {
  inset?: boolean
  variant?: "default" | "destructive"
}) {
  return (
    <MenuItemPrimitive
      data-slot="context-menu-item"
      data-inset={inset}
      data-variant={variant}
      textValue={typeof children === "string" ? children : props.textValue}
      className={composeRenderProps(className, (className, { selectionMode }) =>
        cn(contextMenuItemVariants({ selectionMode }), className)
      )}
      {...props}
    >
      {composeRenderProps(
        children,
        (children, { selectionMode }) => (
          <>
            {selectionMode !== "none" ? (
              <SelectionIndicator
                className="absolute right-2 pointer-events-none"
                data-slot={
                  selectionMode === "single"
                    ? "context-menu-radio-item-indicator"
                    : "context-menu-checkbox-item-indicator"
                }
              >
                <HugeiconsIcon icon={Tick02Icon} strokeWidth={2}
                />
              </SelectionIndicator>
            ) : null}
            {children}
          </>
        )
      )}
    </MenuItemPrimitive>
  )
}

function ContextMenuSub({
  ...props
}: React.ComponentProps<typeof SubmenuTriggerPrimitive>) {
  return <SubmenuTriggerPrimitive data-slot="context-menu-sub" {...props} />
}

function ContextMenuSubTrigger({
  className,
  inset,
  children,
  ...props
}: MenuItemPrimitiveProps<object> & {
  inset?: boolean
}) {
  return (
    <MenuItemPrimitive
      data-slot="context-menu-sub-trigger"
      data-inset={inset}
      textValue={typeof children === "string" ? children : props.textValue}
      className={cn(
        "focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 flex cursor-default items-center outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0",
        className
      )}
      {...props}
    >
      {composeRenderProps(children, (children) => (
        <>
          {children}
          <HugeiconsIcon icon={ArrowRight01Icon} strokeWidth={2}
            className="ml-auto"
          />
        </>
      ))}
    </MenuItemPrimitive>
  )
}

function ContextMenuSubContent({
  placement = "end top",
  crossOffset = -3,
  offset = 0,
  className,
  ...props
}: React.ComponentProps<typeof ContextMenu>) {
  return (
    <ContextMenu
      data-slot="context-menu-sub-content"
      className={cn(
        "bg-popover text-popover-foreground min-w-32 rounded-lg border p-1 shadow-lg animate-none! relative bg-popover/70 before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground! w-auto",
        className
      )}
      placement={placement}
      crossOffset={crossOffset}
      offset={offset}
      {...props}
    />
  )
}

function ContextMenuSeparator({
  className,
  ...props
}: React.ComponentProps<typeof SeparatorPrimitive>) {
  return (
    <SeparatorPrimitive
      data-slot="context-menu-separator"
      className={cn("bg-border -mx-1 my-1 h-px", className)}
      {...props}
    />
  )
}

function ContextMenuShortcut({
  className,
  ...props
}: React.ComponentProps<"span">) {
  return (
    <span
      data-slot="context-menu-shortcut"
      className={cn("text-muted-foreground group-focus/context-menu-item:text-accent-foreground ml-auto text-xs tracking-widest", className)}
      {...props}
    />
  )
}

export {
  ContextMenu,
  ContextMenuTrigger,
  ContextMenuItem,
  ContextMenuLabel,
  ContextMenuSeparator,
  ContextMenuShortcut,
  ContextMenuGroup,
  ContextMenuSub,
  ContextMenuSubContent,
  ContextMenuSubTrigger,
}

export default function ContextMenuDemo({
  checkAnimation = "draw",
}: {
  checkAnimation?: "draw" | "pop"
}) {
  return (
    <AnimateUIProvider checkAnimation={checkAnimation}>
      <ContextMenuTrigger>
        <Pressable>
          <div
            role="button"
            className="flex aspect-video w-full max-w-xs items-center justify-center rounded-xl border border-dashed text-sm"
          >
            <span className="hidden pointer-fine:inline-block">
              Right click here
            </span>
            <span className="hidden pointer-coarse:inline-block">
              Long press here
            </span>
          </div>
        </Pressable>
        <ContextMenu className="w-48">
          <ContextMenuGroup>
            <ContextMenuItem>
              Back
              <ContextMenuShortcut>⌘[</ContextMenuShortcut>
            </ContextMenuItem>
            <ContextMenuItem isDisabled>
              Forward
              <ContextMenuShortcut>⌘]</ContextMenuShortcut>
            </ContextMenuItem>
            <ContextMenuItem>
              Reload
              <ContextMenuShortcut>⌘R</ContextMenuShortcut>
            </ContextMenuItem>
            <ContextMenuSub>
              <ContextMenuSubTrigger>More Tools</ContextMenuSubTrigger>
              <ContextMenuSubContent className="w-44">
                <ContextMenuGroup>
                  <ContextMenuItem>Save Page...</ContextMenuItem>
                  <ContextMenuItem>Create Shortcut...</ContextMenuItem>
                  <ContextMenuItem>Name Window...</ContextMenuItem>
                </ContextMenuGroup>
                <ContextMenuSeparator />
                <ContextMenuGroup>
                  <ContextMenuItem>Developer Tools</ContextMenuItem>
                </ContextMenuGroup>
                <ContextMenuSeparator />
                <ContextMenuGroup>
                  <ContextMenuItem variant="destructive">Delete</ContextMenuItem>
                </ContextMenuGroup>
              </ContextMenuSubContent>
            </ContextMenuSub>
          </ContextMenuGroup>
          <ContextMenuSeparator />
          <ContextMenuGroup
            selectionMode="multiple"
            defaultSelectedKeys={["bookmarks"]}
          >
            <ContextMenuItem id="bookmarks">Show Bookmarks</ContextMenuItem>
            <ContextMenuItem id="urls">Show Full URLs</ContextMenuItem>
          </ContextMenuGroup>
          <ContextMenuSeparator />
          <ContextMenuGroup
            selectionMode="single"
            defaultSelectedKeys={["pedro"]}
          >
            <ContextMenuLabel>People</ContextMenuLabel>
            <ContextMenuItem id="pedro">Pedro Duarte</ContextMenuItem>
            <ContextMenuItem id="colm">Colm Tuite</ContextMenuItem>
          </ContextMenuGroup>
        </ContextMenu>
      </ContextMenuTrigger>
    </AnimateUIProvider>
  )
}

Installation

Install the packages

npm install @animate-ui/react motion
pnpm add @animate-ui/react motion
yarn add @animate-ui/react motion
bun add @animate-ui/react motion

Add the shadcn/ui component

npx shadcn@latest add context-menu
pnpm dlx shadcn@latest add context-menu
yarn dlx shadcn@latest add context-menu
bun x shadcn@latest add context-menu

Animate it

The CLI finds your shadcn/ui components through components.json, swaps their primitive imports for Animate UI's and removes the CSS animation classes Motion replaces.

npx @animate-ui/cli@latest animate context-menu
pnpm dlx @animate-ui/cli@latest animate context-menu
yarn dlx @animate-ui/cli@latest animate context-menu
bun x @animate-ui/cli@latest animate context-menu

Usage

Import from Animate UI instead of react-aria-components: it re-exports all of React Aria, with an animated Popover, Menu, MenuSection, MenuItem and SelectionIndicator. Everything else stays the same.

import {
  Button,
  Menu,
  MenuItem,
  MenuTrigger,
  Popover,
} from '@animate-ui/react/primitives/react-aria';

export function CanvasMenu() {
  return (
    <MenuTrigger trigger="contextMenu">
      <Button>Right click here</Button>
      <Popover placement="bottom start">
        <Menu>
          <MenuItem>Undo</MenuItem>
          <MenuItem>Duplicate</MenuItem>
          <MenuItem>Delete</MenuItem>
        </Menu>
      </Popover>
    </MenuTrigger>
  );
}

Check animation

An item of a selection (selectionMode on the Menu or a MenuSection) shows its check in a SelectionIndicator: it draws itself when the item is selected, and erases itself when it's deselected (a single selection's while the new one draws). With animation="pop", it pops in from half its size and shrinks back out instead, and a single selection's check glides to the newly selected item:

import {
  Button,
  Menu,
  MenuItem,
  MenuSection,
  MenuTrigger,
  Popover,
  SelectionIndicator,
} from '@animate-ui/react/primitives/react-aria';

export function CanvasMenu() {
  return (
    <MenuTrigger trigger="contextMenu">
      <Button>Right click here</Button>
      <Popover placement="bottom start">
        <Menu>
          <MenuSection selectionMode="multiple" defaultSelectedKeys={['grid']}>
            <MenuItem id="grid">
              <SelectionIndicator animation="pop">
                <CheckIcon />
              </SelectionIndicator>
              Show grid
            </MenuItem>
          </MenuSection>
          <MenuSection selectionMode="single" defaultSelectedKeys={['fit']}>
            <MenuItem id="fit">
              <SelectionIndicator animation="pop">
                <CheckIcon />
              </SelectionIndicator>
              Zoom to fit
            </MenuItem>
            <MenuItem id="100">
              <SelectionIndicator animation="pop">
                <CheckIcon />
              </SelectionIndicator>
              Zoom to 100%
            </MenuItem>
          </MenuSection>
        </Menu>
      </Popover>
    </MenuTrigger>
  );
}

The same demo with checkAnimation="pop" (its Controls switch it back to draw):

To pop every check of your app at once (checkboxes, menus, selects, comboboxes), set checkAnimation="pop" on an AnimateUIProvider instead.

Animation

React Aria's menus open in a Popover, so they get the animated Popover, which moves like Animate UI v1's Dropdown Menu content under a menu:

  • The menu fades and scales in from 0.9, from the pointer (--trigger-anchor-point), in 220ms, and closes the same way before it unmounts.
  • Submenus (SubmenuTrigger) open in a Popover too, and move like their menu.
  • MenuItem: one highlight glides behind the focused item (the one React Aria marks data-focused) on a spring (stiffness 500, damping 40), following the pointer and the arrow keys alike, like v1's menu highlight and the Radix UI and Base UI menus. Moving to another item, it carries on from where it is, at its speed: however fast the pointer or the arrow keys go, there's one highlight on screen. It only fades in (200ms, on CSS's ease) when it appears from nothing, and its color changes the same way when the item's highlighted background differs from the previous one's. It glides on the compositor (a Web Animation of transform), so a busy main thread, a submenu opening for instance, doesn't stall it. Into a submenu or back out of it, each menu clips its own part, as in v1: the previous surface glides along with the new one, the same box on screen, so the two make one highlight crossing from one menu into the other; the entering part fades in as it glides. It goes into submenus, whose Menu shares the highlight of its root menu, and stays on the last item when the pointer leaves the menu. It wears the item's own focused background (focus:bg-accent, data-focused:bg-accent…), read from your styles: the item's background is cleared while it's focused, or while its submenu is open; its text colors stay. The highlight is rendered through the item's render prop: your own render still works. The Menu is its own stacking context (isolation: isolate), so the highlight goes under the items' content, above the menu's background. Inside a padded Popover, the Menu reaches into the padding (a negative margin and as much padding, so its items don't move): it clips where the popover's padding ends, as the Radix UI and Base UI menus do, and the highlight crossing into a submenu isn't cut between the two.
  • SelectionIndicator (the check inside an item, the same as a ListBox's): an item's check draws itself like a Checkbox's check (its stroke and opacity in 0.3s) when the item is selected, and erases itself when it's deselected; with selectionMode="single" (on the Menu or a MenuSection), selecting another item erases the old check while the new one draws itself. Only selecting and deselecting animate: opening the menu shows its checks at rest, and closing it leaves them as they are. With animation="pop" (or checkAnimation="pop" on an AnimateUIProvider), checks pop instead: in from half their size out of a 2px blur (a spring, stiffness 500, damping 24), and back into their blur in 0.15s; a single selection's check glides from the item it leaves to the newly selected one, on the highlight's spring (stiffness 500, damping 40), between the items of its Menu or MenuSection. The demo's Controls switch between the two. Choosing an item that closes the menu (with the pointer or Enter) holds the menu still while its check changes (250ms while it draws itself, 150ms while it pops or glides), then it fades out; Escape and outside clicks don't wait. shadcn/ui renders the check as isSelected ? <Icon /> : null, which can't animate its leaving: render it in a SelectionIndicator on every item instead, as the demo does.
  • Under reduced motion, the menu only fades and doesn't hold; the highlight jumps from item to item instead of gliding (it still fades in when it appears); the checks only fade (a single selection's doesn't glide).

API Reference

Popover takes exactly the props of React Aria's Popover, plus transition and exitTransition (see the Popover page).

MenuTrigger, SubmenuTrigger and the other components take React Aria props only. They are not animated.

React Aria props only. It holds its items' highlight; a submenu's Menu shares its root menu's.

React Aria props only. With its own selectionMode="single", its items' check glides between them.

React Aria props only. It takes part in the highlight; it needs nothing more.

SelectionIndicator

React Aria props, plus animation and transition (see the Select page). Inside an item of Animate UI's Menu (or ListBox), it's animated; anywhere else, it's React Aria's own.

Built by Skyleen. The source code is available on GitHub.

Last updated: 10/18/2026

On this page