Animated Context Menu
Composable context-menu primitives with a pointer-origin clip morph, a gliding active row, checkbox and radio choices, keyboard navigation, typeahead, and long-press support.
Preview
"use client";
import {
Check,
Copy,
Download,
Eye,
Pencil,
Trash2,
} from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useState } from "react";
import {
ContextMenu,
ContextMenuCheckboxItem,
ContextMenuContent,
ContextMenuItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuTrigger,
} from "@/components/motion/context-menu";
import { SPRING_SWAP } from "@/lib/ease";
export function ContextMenuPreview() {
const reduce = useReducedMotion() ?? false;
const [message, setMessage] = useState<string | null>(null);
const [offline, setOffline] = useState(false);
return (
<div className="flex min-h-[360px] w-full items-center justify-center">
<ContextMenu>
<ContextMenuTrigger>
<button
type="button"
className="group flex flex-col items-center outline-none"
>
<div className="relative h-24 w-32 transition-transform duration-150 group-active:scale-[0.98] group-focus-visible:rounded-2xl group-focus-visible:ring-2 group-focus-visible:ring-foreground/20 group-focus-visible:ring-offset-4 group-focus-visible:ring-offset-background">
<div className="absolute left-1 top-1 h-7 w-14 rounded-t-[10px] bg-[#d4a84f] dark:bg-[#a77d2f]" />
<div className="absolute inset-x-0 bottom-0 top-5 rounded-[14px] bg-[#e7bb61] shadow-[0_14px_24px_-16px_rgba(90,58,8,0.75)] dark:bg-[#bd8d36]" />
<div className="absolute inset-x-0 bottom-0 top-9 rounded-[14px] bg-[#efc86f] dark:bg-[#cb9a41]" />
<div className="absolute inset-x-5 bottom-4 h-px bg-black/10 dark:bg-white/10" />
</div>
<span className="mt-4 text-sm font-medium text-foreground">
Right click on me
</span>
<div className="mt-1 h-4">
<AnimatePresence mode="wait" initial={false}>
{message ? (
<motion.span
key={message}
initial={
reduce
? { opacity: 0 }
: { opacity: 0, y: 3, filter: "blur(2px)" }
}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={
reduce
? { opacity: 0 }
: { opacity: 0, y: -2, filter: "blur(2px)" }
}
transition={reduce ? { duration: 0.1 } : SPRING_SWAP}
className="flex items-center gap-1 text-[10px] text-muted-foreground"
>
<Check aria-hidden="true" className="h-3 w-3 text-success" />
{message}
</motion.span>
) : (
<span className="text-[10px] text-muted-foreground">
or long-press · Shift + F10
</span>
)}
</AnimatePresence>
</div>
</button>
</ContextMenuTrigger>
<ContextMenuContent ariaLabel="Folder actions" className="w-60">
<ContextMenuLabel>Project files</ContextMenuLabel>
<ContextMenuItem
textValue="Open"
onSelect={() => setMessage("Folder opened")}
>
<Eye aria-hidden="true" className="h-4 w-4" />
Open
<ContextMenuShortcut>↵</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem
textValue="Rename"
onSelect={() => setMessage("Ready to rename")}
>
<Pencil aria-hidden="true" className="h-4 w-4" />
Rename
<ContextMenuShortcut>R</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem
textValue="Duplicate"
onSelect={() => setMessage("Folder duplicated")}
>
<Copy aria-hidden="true" className="h-4 w-4" />
Duplicate
<ContextMenuShortcut>⌘D</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem
textValue="Download"
onSelect={() => setMessage("Download started")}
>
<Download aria-hidden="true" className="h-4 w-4" />
Download
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuCheckboxItem
textValue="Keep offline"
checked={offline}
closeOnSelect={false}
onCheckedChange={(checked) => {
setOffline(checked);
setMessage(checked ? "Available offline" : "Online only");
}}
>
Keep offline
</ContextMenuCheckboxItem>
<ContextMenuSeparator />
<ContextMenuItem
tone="destructive"
textValue="Move to trash"
onSelect={() => setMessage("Moved to trash")}
>
<Trash2 aria-hidden="true" className="h-4 w-4" />
Move to trash
<ContextMenuShortcut>⌘⌫</ContextMenuShortcut>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</div>
);
}
"use client";
// beui.dev/components/motion/context-menu
import { Check } from "lucide-react";
import {
AnimatePresence,
motion,
useReducedMotion,
} from "motion/react";
import {
cloneElement,
createContext,
isValidElement,
type ReactElement,
type KeyboardEvent as ReactKeyboardEvent,
type MouseEvent as ReactMouseEvent,
type ReactNode,
type PointerEvent as ReactPointerEvent,
type Ref,
useCallback,
useContext,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { EASE_OUT, SPRING_LAYOUT, SPRING_PANEL } from "@/lib/ease";
import { holdSelection, TOUCH_GESTURE_CONTENT_CLASS } from "@/lib/touch";
import { cn } from "@/lib/utils";
type OpenModality = "pointer" | "keyboard" | "touch";
type MenuPoint = { x: number; y: number };
const VIEWPORT_PADDING = 8;
const LONG_PRESS_DELAY = 520;
const LONG_PRESS_TOLERANCE = 10;
const MORPH_DURATION = 0.3;
type TriggerElementProps = React.HTMLAttributes<HTMLElement> & {
ref?: Ref<HTMLElement>;
};
interface ContextMenuContextValue {
open: boolean;
setOpen: (open: boolean) => void;
openAt: (point: MenuPoint, modality: OpenModality) => void;
point: MenuPoint;
modality: OpenModality;
invocation: number;
menuId: string;
triggerRef: React.MutableRefObject<HTMLElement | null>;
contentRef: React.MutableRefObject<HTMLDivElement | null>;
activeId: string | null;
setActiveId: (id: string | null) => void;
reduce: boolean;
}
const ContextMenuContext = createContext<ContextMenuContextValue | null>(null);
function useContextMenuContext(component: string) {
const context = useContext(ContextMenuContext);
if (!context) {
throw new Error(`${component} must be used within <ContextMenu>`);
}
return context;
}
function assignRef<T>(ref: Ref<T> | undefined, value: T | null) {
if (typeof ref === "function") {
ref(value);
} else if (ref) {
ref.current = value;
}
}
function getEnabledItems(container: HTMLElement | null) {
if (!container) return [];
return Array.from(
container.querySelectorAll<HTMLElement>(
'[data-context-menu-item="true"]:not([data-disabled="true"])',
),
);
}
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
function collapsedClip(
origin: MenuPoint,
size: { width: number; height: number },
) {
const half = 8;
const top = clamp(origin.y - half, 0, size.height);
const right = clamp(size.width - origin.x - half, 0, size.width);
const bottom = clamp(size.height - origin.y - half, 0, size.height);
const left = clamp(origin.x - half, 0, size.width);
return `inset(${top}px ${right}px ${bottom}px ${left}px round 10px)`;
}
export interface ContextMenuProps {
children: ReactNode;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
className?: string;
}
export function ContextMenu({
children,
open: controlledOpen,
defaultOpen = false,
onOpenChange,
className,
}: ContextMenuProps) {
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const [point, setPoint] = useState<MenuPoint>({ x: 0, y: 0 });
const [modality, setModality] = useState<OpenModality>("pointer");
const [invocation, setInvocation] = useState(0);
const [activeId, setActiveId] = useState<string | null>(null);
const controlled = controlledOpen !== undefined;
const open = controlled ? controlledOpen : internalOpen;
const triggerRef = useRef<HTMLElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
const menuId = useId();
const reduce = useReducedMotion() ?? false;
const setOpen = useCallback(
(next: boolean) => {
if (!controlled) setInternalOpen(next);
onOpenChange?.(next);
if (!next) setActiveId(null);
},
[controlled, onOpenChange],
);
const openAt = useCallback(
(nextPoint: MenuPoint, nextModality: OpenModality) => {
setPoint(nextPoint);
setModality(nextModality);
setInvocation((current) => current + 1);
setActiveId(null);
setOpen(true);
},
[setOpen],
);
useEffect(() => {
if (!open) return;
const onPointerDown = (event: PointerEvent) => {
if (!contentRef.current?.contains(event.target as Node)) setOpen(false);
};
const onWindowChange = () => setOpen(false);
window.addEventListener("pointerdown", onPointerDown);
window.addEventListener("resize", onWindowChange);
window.addEventListener("scroll", onWindowChange);
return () => {
window.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("resize", onWindowChange);
window.removeEventListener("scroll", onWindowChange);
};
}, [open, setOpen]);
const value = useMemo<ContextMenuContextValue>(
() => ({
open,
setOpen,
openAt,
point,
modality,
invocation,
menuId,
triggerRef,
contentRef,
activeId,
setActiveId,
reduce,
}),
[
open,
setOpen,
openAt,
point,
modality,
invocation,
menuId,
activeId,
reduce,
],
);
return (
<ContextMenuContext.Provider value={value}>
<div className={cn("contents", className)}>{children}</div>
</ContextMenuContext.Provider>
);
}
export interface ContextMenuTriggerProps {
children: ReactElement<TriggerElementProps>;
disabled?: boolean;
className?: string;
}
export function ContextMenuTrigger({
children,
disabled = false,
className,
}: ContextMenuTriggerProps) {
const context = useContextMenuContext("ContextMenuTrigger");
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const touchOrigin = useRef<MenuPoint | null>(null);
const releaseSelection = useRef<(() => void) | null>(null);
const cancelLongPress = useCallback(() => {
if (longPressTimer.current) {
clearTimeout(longPressTimer.current);
longPressTimer.current = null;
}
touchOrigin.current = null;
}, []);
// Held for the whole press, not just the timer: a gesture that turned into a
// drag must not paint a selection under the finger either.
const endPress = useCallback(() => {
cancelLongPress();
releaseSelection.current?.();
releaseSelection.current = null;
}, [cancelLongPress]);
useEffect(() => endPress, [endPress]);
if (!isValidElement(children)) {
throw new Error("<ContextMenuTrigger> requires a single React element");
}
const childProps = children.props;
const childRef = children.props.ref;
const onPointerDown = (event: ReactPointerEvent<HTMLElement>) => {
childProps.onPointerDown?.(event);
// A pen presses the same way a finger does and gets no `contextmenu` out
// of the platform for it, so it holds to open too. A mouse has the right
// button and is left to `onContextMenu`.
const pressToOpen =
event.pointerType === "touch" || event.pointerType === "pen";
if (event.defaultPrevented || disabled || !pressToOpen) return;
// `pointer-coarse:select-none` misses this press on a laptop whose mouse
// is the primary pointer and whose touchscreen is not, and the platform's
// own long-press selection then claims the gesture and cancels ours. The
// press is the only thing that knows which input is on the glass, so it
// takes selection away itself — for its own duration, and no longer.
releaseSelection.current?.();
releaseSelection.current = holdSelection(event.currentTarget);
const origin = { x: event.clientX, y: event.clientY };
touchOrigin.current = origin;
longPressTimer.current = setTimeout(() => {
context.openAt(origin, "touch");
longPressTimer.current = null;
touchOrigin.current = null;
}, LONG_PRESS_DELAY);
};
const onPointerMove = (event: ReactPointerEvent<HTMLElement>) => {
childProps.onPointerMove?.(event);
const origin = touchOrigin.current;
if (
origin &&
Math.hypot(event.clientX - origin.x, event.clientY - origin.y) >
LONG_PRESS_TOLERANCE
) {
cancelLongPress();
}
};
const onKeyDown = (event: ReactKeyboardEvent<HTMLElement>) => {
childProps.onKeyDown?.(event);
if (event.defaultPrevented || disabled) return;
if (event.key !== "ContextMenu" && !(event.shiftKey && event.key === "F10"))
return;
event.preventDefault();
const rect = event.currentTarget.getBoundingClientRect();
context.openAt(
{ x: rect.left + Math.min(24, rect.width / 2), y: rect.top + rect.height / 2 },
"keyboard",
);
};
return cloneElement(children, {
ref: (node: HTMLElement | null) => {
context.triggerRef.current = node;
assignRef(childRef, node);
},
"aria-controls": context.open ? context.menuId : undefined,
"aria-haspopup": "menu",
"aria-expanded": context.open,
// The long press is ours: without this iOS runs its own on the same
// gesture and drops the selection callout and its handles on top of the
// menu we just opened. Only the press gesture is ours though — the child
// is the consumer's content, so a mouse can still select the text in it
// and right-click the selection. `touch-none` stays off too: the page
// still has to scroll from the trigger.
className: cn(TOUCH_GESTURE_CONTENT_CLASS, childProps.className, className),
onContextMenu: (event: ReactMouseEvent<HTMLElement>) => {
childProps.onContextMenu?.(event);
if (event.defaultPrevented || disabled) return;
event.preventDefault();
endPress();
context.openAt({ x: event.clientX, y: event.clientY }, "pointer");
},
onKeyDown,
onPointerDown,
onPointerMove,
onPointerUp: (event: ReactPointerEvent<HTMLElement>) => {
childProps.onPointerUp?.(event);
endPress();
},
onPointerCancel: (event: ReactPointerEvent<HTMLElement>) => {
childProps.onPointerCancel?.(event);
endPress();
},
});
}
export interface ContextMenuContentProps {
children: ReactNode;
className?: string;
ariaLabel?: string;
}
export function ContextMenuContent({
children,
className,
ariaLabel = "Context menu",
}: ContextMenuContentProps) {
const context = useContextMenuContext("ContextMenuContent");
const [mounted, setMounted] = useState(false);
const [position, setPosition] = useState<MenuPoint>(context.point);
const [origin, setOrigin] = useState<MenuPoint>({ x: 0, y: 0 });
const [size, setSize] = useState({ width: 0, height: 0 });
const [morphReady, setMorphReady] = useState(false);
const typeahead = useRef("");
const typeaheadTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => setMounted(true), []);
useLayoutEffect(() => {
if (!context.open) {
setMorphReady(false);
return;
}
const content = context.contentRef.current;
if (!content) return;
content.dataset.invocation = String(context.invocation);
const rect = content.getBoundingClientRect();
const left = Math.max(
VIEWPORT_PADDING,
Math.min(
Math.max(context.point.x, VIEWPORT_PADDING),
window.innerWidth - rect.width - VIEWPORT_PADDING,
),
);
const top = Math.max(
VIEWPORT_PADDING,
Math.min(
Math.max(context.point.y, VIEWPORT_PADDING),
window.innerHeight - rect.height - VIEWPORT_PADDING,
),
);
setPosition({ x: left, y: top });
setSize({ width: rect.width, height: rect.height });
setOrigin({
x: clamp(context.point.x - left, 12, Math.max(12, rect.width - 12)),
y: clamp(context.point.y - top, 12, Math.max(12, rect.height - 12)),
});
setMorphReady(false);
if (context.reduce || context.modality === "keyboard") {
setMorphReady(true);
return;
}
// Let the measured collapsed clip paint once before expanding it. Without
// this preparation frame, the first invocation can batch both states and
// appear at full size without the morph.
let openFrame = 0;
const prepareFrame = requestAnimationFrame(() => {
openFrame = requestAnimationFrame(() => setMorphReady(true));
});
return () => {
cancelAnimationFrame(prepareFrame);
cancelAnimationFrame(openFrame);
};
}, [
context.open,
context.point,
context.contentRef,
context.invocation,
context.modality,
context.reduce,
]);
useEffect(() => {
if (!context.open) return;
const frame = requestAnimationFrame(() => {
const first = getEnabledItems(context.contentRef.current)[0];
first?.focus({ preventScroll: true });
});
return () => cancelAnimationFrame(frame);
}, [context.open, context.contentRef]);
useEffect(
() => () => {
if (typeaheadTimer.current) clearTimeout(typeaheadTimer.current);
},
[],
);
const moveFocus = (direction: 1 | -1) => {
const items = getEnabledItems(context.contentRef.current);
if (items.length === 0) return;
const current = items.indexOf(document.activeElement as HTMLElement);
const next = current < 0 ? 0 : (current + direction + items.length) % items.length;
items[next]?.focus();
};
const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
if (event.key === "Escape") {
event.preventDefault();
context.setOpen(false);
context.triggerRef.current?.focus();
return;
}
if (event.key === "Tab") {
context.setOpen(false);
return;
}
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
moveFocus(event.key === "ArrowDown" ? 1 : -1);
return;
}
if (event.key === "Home" || event.key === "End") {
event.preventDefault();
const items = getEnabledItems(context.contentRef.current);
items[event.key === "Home" ? 0 : items.length - 1]?.focus();
return;
}
if (
event.key.length === 1 &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey
) {
typeahead.current += event.key.toLocaleLowerCase();
if (typeaheadTimer.current) clearTimeout(typeaheadTimer.current);
typeaheadTimer.current = setTimeout(() => {
typeahead.current = "";
}, 500);
const match = getEnabledItems(context.contentRef.current).find((item) =>
(item.dataset.label ?? item.textContent ?? "")
.trim()
.toLocaleLowerCase()
.startsWith(typeahead.current),
);
match?.focus();
}
};
if (!mounted) return null;
const visualOpen = context.open && morphReady;
const clipHidden = collapsedClip(origin, size);
const clipShown = "inset(0px 0px 0px 0px round 12px)";
return createPortal(
<div
data-context-menu-portal=""
aria-hidden={!context.open}
inert={!context.open}
style={{ left: position.x, top: position.y }}
className={cn(
"fixed z-[100] [filter:drop-shadow(0_18px_28px_rgba(0,0,0,0.2))]",
context.open ? "pointer-events-auto" : "pointer-events-none",
)}
>
<motion.div
ref={context.contentRef}
id={context.menuId}
role="menu"
aria-label={ariaLabel}
data-morph-ready={morphReady ? "true" : "false"}
tabIndex={-1}
initial={false}
animate={{
opacity: visualOpen ? 1 : 0,
clipPath:
context.reduce || context.modality === "keyboard" || visualOpen
? clipShown
: clipHidden,
}}
transition={
context.modality === "keyboard"
? { duration: 0 }
: context.reduce
? { duration: 0.1, ease: EASE_OUT }
: {
clipPath: {
duration: MORPH_DURATION,
ease: EASE_OUT,
},
opacity: {
duration: MORPH_DURATION,
ease: EASE_OUT,
},
}
}
onKeyDown={onKeyDown}
onContextMenu={(event) => event.preventDefault()}
className={cn(
"min-w-56 overflow-hidden rounded-xl border border-border bg-card p-1.5 text-foreground outline-none",
className,
)}
>
{children}
</motion.div>
</div>,
document.body,
);
}
type ContextMenuItemTone = "default" | "destructive";
export interface ContextMenuItemProps {
children: ReactNode;
onSelect?: () => void;
disabled?: boolean;
closeOnSelect?: boolean;
tone?: ContextMenuItemTone;
inset?: boolean;
className?: string;
textValue?: string;
}
function ContextMenuItemBase({
children,
onSelect,
disabled = false,
closeOnSelect = true,
tone = "default",
inset = false,
className,
textValue,
role = "menuitem",
ariaChecked,
}: ContextMenuItemProps & {
role?: "menuitem" | "menuitemcheckbox" | "menuitemradio";
ariaChecked?: boolean;
}) {
const context = useContextMenuContext("ContextMenuItem");
const id = useId();
const active = context.activeId === id;
const checkedProps =
role === "menuitem" ? {} : { "aria-checked": ariaChecked };
return (
<button
type="button"
id={id}
role={role}
{...checkedProps}
disabled={disabled}
data-context-menu-item="true"
data-disabled={disabled ? "true" : undefined}
data-label={textValue}
tabIndex={-1}
onFocus={() => context.setActiveId(id)}
onPointerMove={(event) => {
if (!disabled && event.pointerType !== "touch") event.currentTarget.focus();
}}
onClick={() => {
if (disabled) return;
onSelect?.();
if (closeOnSelect) context.setOpen(false);
}}
className={cn(
"relative isolate flex w-full select-none items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-[13px] outline-none",
"focus-visible:ring-2 focus-visible:ring-foreground/15",
"disabled:pointer-events-none disabled:opacity-40",
inset && "pl-8",
tone === "destructive" ? "text-destructive" : "text-foreground",
className,
)}
>
{active ? (
<motion.span
layoutId={`${context.menuId}-active`}
className={cn(
"absolute inset-0 -z-10 rounded-lg",
tone === "destructive"
? "bg-destructive/10"
: "bg-foreground/[0.065]",
)}
transition={context.reduce ? { duration: 0 } : SPRING_LAYOUT}
/>
) : null}
{children}
</button>
);
}
export function ContextMenuItem(props: ContextMenuItemProps) {
return <ContextMenuItemBase {...props} />;
}
export interface ContextMenuCheckboxItemProps
extends Omit<ContextMenuItemProps, "onSelect"> {
checked: boolean;
onCheckedChange?: (checked: boolean) => void;
}
export function ContextMenuCheckboxItem({
checked,
onCheckedChange,
children,
...props
}: ContextMenuCheckboxItemProps) {
const context = useContextMenuContext("ContextMenuCheckboxItem");
return (
<ContextMenuItemBase
{...props}
role="menuitemcheckbox"
ariaChecked={checked}
onSelect={() => onCheckedChange?.(!checked)}
>
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
<AnimatePresence initial={false}>
{checked ? (
<motion.span
key="check"
initial={context.reduce ? false : { opacity: 0, scale: 0.75 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: context.reduce ? 1 : 0.75 }}
transition={context.reduce ? { duration: 0.08 } : SPRING_PANEL}
>
<Check aria-hidden="true" className="h-3.5 w-3.5" strokeWidth={2.4} />
</motion.span>
) : null}
</AnimatePresence>
</span>
{children}
</ContextMenuItemBase>
);
}
interface ContextMenuRadioGroupContextValue {
value: string;
onValueChange?: (value: string) => void;
}
const ContextMenuRadioGroupContext =
createContext<ContextMenuRadioGroupContextValue | null>(null);
export interface ContextMenuRadioGroupProps {
value: string;
onValueChange?: (value: string) => void;
children: ReactNode;
className?: string;
}
export function ContextMenuRadioGroup({
value,
onValueChange,
children,
className,
}: ContextMenuRadioGroupProps) {
const context = useMemo(
() => ({ value, onValueChange }),
[value, onValueChange],
);
return (
<ContextMenuRadioGroupContext.Provider value={context}>
<div className={className}>{children}</div>
</ContextMenuRadioGroupContext.Provider>
);
}
export interface ContextMenuRadioItemProps
extends Omit<ContextMenuItemProps, "onSelect"> {
value: string;
}
export function ContextMenuRadioItem({
value,
children,
...props
}: ContextMenuRadioItemProps) {
const group = useContext(ContextMenuRadioGroupContext);
if (!group) {
throw new Error(
"ContextMenuRadioItem must be used within <ContextMenuRadioGroup>",
);
}
const checked = group.value === value;
return (
<ContextMenuItemBase
{...props}
role="menuitemradio"
ariaChecked={checked}
onSelect={() => group.onValueChange?.(value)}
>
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
<span
className={cn(
"h-1.5 w-1.5 rounded-full bg-current transition-opacity",
checked ? "opacity-100" : "opacity-0",
)}
/>
</span>
{children}
</ContextMenuItemBase>
);
}
export interface ContextMenuLabelProps {
children: ReactNode;
inset?: boolean;
className?: string;
}
export function ContextMenuLabel({
children,
inset = false,
className,
}: ContextMenuLabelProps) {
return (
<div
className={cn(
"px-2.5 pb-1 pt-1.5 text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground",
inset && "pl-8",
className,
)}
>
{children}
</div>
);
}
export interface ContextMenuSeparatorProps {
className?: string;
}
export function ContextMenuSeparator({
className,
}: ContextMenuSeparatorProps) {
return (
<hr className={cn("-mx-1 my-1 h-px border-0 bg-border", className)} />
);
}
export interface ContextMenuShortcutProps {
children: ReactNode;
className?: string;
}
export function ContextMenuShortcut({
children,
className,
}: ContextMenuShortcutProps) {
return (
<span
aria-hidden="true"
className={cn(
"ml-auto pl-4 text-[10px] font-medium tracking-wide text-muted-foreground",
className,
)}
>
{children}
</span>
);
}
Install
Add it with the shadcn CLI, or copy the source manually.
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
// Shared touch primitives. iOS and iPadOS run their own gestures on top of the
// page — the long-press selection callout and the selection it drags in with
// it — and they win: once the platform claims a touch it cancels ours
// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own
// their gesture have to opt out.
//
// What the two classes below cover, precisely:
// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:
// it is not a property other engines have, so it is inert everywhere else.
// - `user-select: none` stops the long-press selection on every engine,
// Android included, and stops a drag from painting a selection under the
// cursor. It is inherited, so it reaches every descendant — which is why the
// two classes differ only in whether they apply it unconditionally.
// What neither covers:
// - Chrome for Android's long-press menu on a link or an image. No CSS
// suppresses it; a gesture surface that wraps one needs its own
// `onContextMenu` with `preventDefault()`.
// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is
// not inherited and plain divs and buttons are not drag sources, so setting
// it on the surface does nothing — the child itself needs `draggable={false}`.
/**
* Classes for a surface that *is* the control: a thumb, a drum, a stage, a
* handle, a hold button. Selection is suppressed on every input, because a
* drag that highlights the control's own label is wrong on a mouse too.
* Compose with `touch-none` when the surface also owns the scroll axis — leave
* it off when the page must still scroll from there.
*/
export const TOUCH_GESTURE_CLASS = "select-none [-webkit-touch-callout:none]";
/**
* The same opt-out for a gesture surface that wraps content the consumer owns:
* a scroller, a context-menu trigger, a sheet header, a list row. Selection is
* suppressed only where the platform runs its own press gestures — a coarse
* pointer — so a mouse user can still select and copy that content. If the
* gesture itself would paint a selection under the cursor, add `select-none`
* for the duration of the gesture rather than reaching for
* `TOUCH_GESTURE_CLASS`.
*
* `pointer: coarse` describes the *primary* pointer and nothing else, so a
* hybrid machine reads it wrong in both directions: a tablet with a mouse
* plugged in keeps touch as primary and loses mouse selection, and a laptop
* with a touchscreen keeps the mouse as primary and leaves selection live
* under a finger. No media query can answer per interaction — the query is
* about the device, and the question is about the gesture in progress. The
* default stays here because it is right on the machines that are one thing or
* the other, and losing a selection is a nuisance; where the miss costs a
* *gesture* instead, the surface pairs it with `holdSelection` on the press.
*/
export const TOUCH_GESTURE_CONTENT_CLASS =
"[-webkit-touch-callout:none] pointer-coarse:select-none";
/**
* Suppress selection on `element` for as long as a gesture is running on it,
* whatever the primary pointer of the machine happens to be. Returns the
* release. Inline, so it wins over the class above and is gone again the
* moment the gesture ends.
*
* For the press gestures a native selection would otherwise steal — a
* long-press that opens a menu. Elsewhere prefer the classes: a surface that
* takes selection away for the whole session is a surface whose text nobody
* can copy.
*/
export function holdSelection(element: HTMLElement) {
element.style.setProperty("user-select", "none");
element.style.setProperty("-webkit-user-select", "none");
return () => {
element.style.removeProperty("user-select");
element.style.removeProperty("-webkit-user-select");
};
}
/**
* Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer
* is already gone by the time the handler runs — routine on iOS, where the
* system can claim the touch first — and an uncaught throw takes the rest of
* the handler, the gesture included, down with it. Touch pointers carry
* implicit capture anyway, so losing it is never fatal.
*/
export function capturePointer(element: Element, pointerId: number) {
try {
element.setPointerCapture(pointerId);
} catch {
// Pointer is no longer active — implicit capture still applies on touch.
}
}
/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */
export function releasePointer(element: Element, pointerId: number) {
try {
if (element.hasPointerCapture(pointerId)) {
element.releasePointerCapture(pointerId);
}
} catch {
// Capture was already dropped by the browser.
}
}
/**
* Whether this event came from a pointer that is *hovering*: not a touch, and
* not currently pressed. Which input the user is holding right now is not
* something a device capability can answer — a touchscreen laptop hovers and
* taps, and iPadOS reports a fine hovering pointer for a finger — so both
* paths stay live and each handler branches on the event it was given.
*
* A pen resting on the glass is making contact, not hovering: `buttons` is the
* tell, and it sends a pen tap down the same route a finger takes.
*
* This answers what an *enter* asks. A leave is the other half of a pair and
* has to be read against the enter that started it — `useHoverGesture` in
* `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it
* rather than asking this question twice.
*/
export const isHoveringPointer = (event: {
pointerType: string;
buttons: number;
}) => event.pointerType !== "touch" && event.buttons === 0;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// beui.dev/components/motion/context-menu
import { Check } from "lucide-react";
import {
AnimatePresence,
motion,
useReducedMotion,
} from "motion/react";
import {
cloneElement,
createContext,
isValidElement,
type ReactElement,
type KeyboardEvent as ReactKeyboardEvent,
type MouseEvent as ReactMouseEvent,
type ReactNode,
type PointerEvent as ReactPointerEvent,
type Ref,
useCallback,
useContext,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { EASE_OUT, SPRING_LAYOUT, SPRING_PANEL } from "@/lib/ease";
import { holdSelection, TOUCH_GESTURE_CONTENT_CLASS } from "@/lib/touch";
import { cn } from "@/lib/utils";
type OpenModality = "pointer" | "keyboard" | "touch";
type MenuPoint = { x: number; y: number };
const VIEWPORT_PADDING = 8;
const LONG_PRESS_DELAY = 520;
const LONG_PRESS_TOLERANCE = 10;
const MORPH_DURATION = 0.3;
type TriggerElementProps = React.HTMLAttributes<HTMLElement> & {
ref?: Ref<HTMLElement>;
};
interface ContextMenuContextValue {
open: boolean;
setOpen: (open: boolean) => void;
openAt: (point: MenuPoint, modality: OpenModality) => void;
point: MenuPoint;
modality: OpenModality;
invocation: number;
menuId: string;
triggerRef: React.MutableRefObject<HTMLElement | null>;
contentRef: React.MutableRefObject<HTMLDivElement | null>;
activeId: string | null;
setActiveId: (id: string | null) => void;
reduce: boolean;
}
const ContextMenuContext = createContext<ContextMenuContextValue | null>(null);
function useContextMenuContext(component: string) {
const context = useContext(ContextMenuContext);
if (!context) {
throw new Error(`${component} must be used within <ContextMenu>`);
}
return context;
}
function assignRef<T>(ref: Ref<T> | undefined, value: T | null) {
if (typeof ref === "function") {
ref(value);
} else if (ref) {
ref.current = value;
}
}
function getEnabledItems(container: HTMLElement | null) {
if (!container) return [];
return Array.from(
container.querySelectorAll<HTMLElement>(
'[data-context-menu-item="true"]:not([data-disabled="true"])',
),
);
}
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
function collapsedClip(
origin: MenuPoint,
size: { width: number; height: number },
) {
const half = 8;
const top = clamp(origin.y - half, 0, size.height);
const right = clamp(size.width - origin.x - half, 0, size.width);
const bottom = clamp(size.height - origin.y - half, 0, size.height);
const left = clamp(origin.x - half, 0, size.width);
return `inset(${top}px ${right}px ${bottom}px ${left}px round 10px)`;
}
export interface ContextMenuProps {
children: ReactNode;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
className?: string;
}
export function ContextMenu({
children,
open: controlledOpen,
defaultOpen = false,
onOpenChange,
className,
}: ContextMenuProps) {
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const [point, setPoint] = useState<MenuPoint>({ x: 0, y: 0 });
const [modality, setModality] = useState<OpenModality>("pointer");
const [invocation, setInvocation] = useState(0);
const [activeId, setActiveId] = useState<string | null>(null);
const controlled = controlledOpen !== undefined;
const open = controlled ? controlledOpen : internalOpen;
const triggerRef = useRef<HTMLElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
const menuId = useId();
const reduce = useReducedMotion() ?? false;
const setOpen = useCallback(
(next: boolean) => {
if (!controlled) setInternalOpen(next);
onOpenChange?.(next);
if (!next) setActiveId(null);
},
[controlled, onOpenChange],
);
const openAt = useCallback(
(nextPoint: MenuPoint, nextModality: OpenModality) => {
setPoint(nextPoint);
setModality(nextModality);
setInvocation((current) => current + 1);
setActiveId(null);
setOpen(true);
},
[setOpen],
);
useEffect(() => {
if (!open) return;
const onPointerDown = (event: PointerEvent) => {
if (!contentRef.current?.contains(event.target as Node)) setOpen(false);
};
const onWindowChange = () => setOpen(false);
window.addEventListener("pointerdown", onPointerDown);
window.addEventListener("resize", onWindowChange);
window.addEventListener("scroll", onWindowChange);
return () => {
window.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("resize", onWindowChange);
window.removeEventListener("scroll", onWindowChange);
};
}, [open, setOpen]);
const value = useMemo<ContextMenuContextValue>(
() => ({
open,
setOpen,
openAt,
point,
modality,
invocation,
menuId,
triggerRef,
contentRef,
activeId,
setActiveId,
reduce,
}),
[
open,
setOpen,
openAt,
point,
modality,
invocation,
menuId,
activeId,
reduce,
],
);
return (
<ContextMenuContext.Provider value={value}>
<div className={cn("contents", className)}>{children}</div>
</ContextMenuContext.Provider>
);
}
export interface ContextMenuTriggerProps {
children: ReactElement<TriggerElementProps>;
disabled?: boolean;
className?: string;
}
export function ContextMenuTrigger({
children,
disabled = false,
className,
}: ContextMenuTriggerProps) {
const context = useContextMenuContext("ContextMenuTrigger");
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const touchOrigin = useRef<MenuPoint | null>(null);
const releaseSelection = useRef<(() => void) | null>(null);
const cancelLongPress = useCallback(() => {
if (longPressTimer.current) {
clearTimeout(longPressTimer.current);
longPressTimer.current = null;
}
touchOrigin.current = null;
}, []);
// Held for the whole press, not just the timer: a gesture that turned into a
// drag must not paint a selection under the finger either.
const endPress = useCallback(() => {
cancelLongPress();
releaseSelection.current?.();
releaseSelection.current = null;
}, [cancelLongPress]);
useEffect(() => endPress, [endPress]);
if (!isValidElement(children)) {
throw new Error("<ContextMenuTrigger> requires a single React element");
}
const childProps = children.props;
const childRef = children.props.ref;
const onPointerDown = (event: ReactPointerEvent<HTMLElement>) => {
childProps.onPointerDown?.(event);
// A pen presses the same way a finger does and gets no `contextmenu` out
// of the platform for it, so it holds to open too. A mouse has the right
// button and is left to `onContextMenu`.
const pressToOpen =
event.pointerType === "touch" || event.pointerType === "pen";
if (event.defaultPrevented || disabled || !pressToOpen) return;
// `pointer-coarse:select-none` misses this press on a laptop whose mouse
// is the primary pointer and whose touchscreen is not, and the platform's
// own long-press selection then claims the gesture and cancels ours. The
// press is the only thing that knows which input is on the glass, so it
// takes selection away itself — for its own duration, and no longer.
releaseSelection.current?.();
releaseSelection.current = holdSelection(event.currentTarget);
const origin = { x: event.clientX, y: event.clientY };
touchOrigin.current = origin;
longPressTimer.current = setTimeout(() => {
context.openAt(origin, "touch");
longPressTimer.current = null;
touchOrigin.current = null;
}, LONG_PRESS_DELAY);
};
const onPointerMove = (event: ReactPointerEvent<HTMLElement>) => {
childProps.onPointerMove?.(event);
const origin = touchOrigin.current;
if (
origin &&
Math.hypot(event.clientX - origin.x, event.clientY - origin.y) >
LONG_PRESS_TOLERANCE
) {
cancelLongPress();
}
};
const onKeyDown = (event: ReactKeyboardEvent<HTMLElement>) => {
childProps.onKeyDown?.(event);
if (event.defaultPrevented || disabled) return;
if (event.key !== "ContextMenu" && !(event.shiftKey && event.key === "F10"))
return;
event.preventDefault();
const rect = event.currentTarget.getBoundingClientRect();
context.openAt(
{ x: rect.left + Math.min(24, rect.width / 2), y: rect.top + rect.height / 2 },
"keyboard",
);
};
return cloneElement(children, {
ref: (node: HTMLElement | null) => {
context.triggerRef.current = node;
assignRef(childRef, node);
},
"aria-controls": context.open ? context.menuId : undefined,
"aria-haspopup": "menu",
"aria-expanded": context.open,
// The long press is ours: without this iOS runs its own on the same
// gesture and drops the selection callout and its handles on top of the
// menu we just opened. Only the press gesture is ours though — the child
// is the consumer's content, so a mouse can still select the text in it
// and right-click the selection. `touch-none` stays off too: the page
// still has to scroll from the trigger.
className: cn(TOUCH_GESTURE_CONTENT_CLASS, childProps.className, className),
onContextMenu: (event: ReactMouseEvent<HTMLElement>) => {
childProps.onContextMenu?.(event);
if (event.defaultPrevented || disabled) return;
event.preventDefault();
endPress();
context.openAt({ x: event.clientX, y: event.clientY }, "pointer");
},
onKeyDown,
onPointerDown,
onPointerMove,
onPointerUp: (event: ReactPointerEvent<HTMLElement>) => {
childProps.onPointerUp?.(event);
endPress();
},
onPointerCancel: (event: ReactPointerEvent<HTMLElement>) => {
childProps.onPointerCancel?.(event);
endPress();
},
});
}
export interface ContextMenuContentProps {
children: ReactNode;
className?: string;
ariaLabel?: string;
}
export function ContextMenuContent({
children,
className,
ariaLabel = "Context menu",
}: ContextMenuContentProps) {
const context = useContextMenuContext("ContextMenuContent");
const [mounted, setMounted] = useState(false);
const [position, setPosition] = useState<MenuPoint>(context.point);
const [origin, setOrigin] = useState<MenuPoint>({ x: 0, y: 0 });
const [size, setSize] = useState({ width: 0, height: 0 });
const [morphReady, setMorphReady] = useState(false);
const typeahead = useRef("");
const typeaheadTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => setMounted(true), []);
useLayoutEffect(() => {
if (!context.open) {
setMorphReady(false);
return;
}
const content = context.contentRef.current;
if (!content) return;
content.dataset.invocation = String(context.invocation);
const rect = content.getBoundingClientRect();
const left = Math.max(
VIEWPORT_PADDING,
Math.min(
Math.max(context.point.x, VIEWPORT_PADDING),
window.innerWidth - rect.width - VIEWPORT_PADDING,
),
);
const top = Math.max(
VIEWPORT_PADDING,
Math.min(
Math.max(context.point.y, VIEWPORT_PADDING),
window.innerHeight - rect.height - VIEWPORT_PADDING,
),
);
setPosition({ x: left, y: top });
setSize({ width: rect.width, height: rect.height });
setOrigin({
x: clamp(context.point.x - left, 12, Math.max(12, rect.width - 12)),
y: clamp(context.point.y - top, 12, Math.max(12, rect.height - 12)),
});
setMorphReady(false);
if (context.reduce || context.modality === "keyboard") {
setMorphReady(true);
return;
}
// Let the measured collapsed clip paint once before expanding it. Without
// this preparation frame, the first invocation can batch both states and
// appear at full size without the morph.
let openFrame = 0;
const prepareFrame = requestAnimationFrame(() => {
openFrame = requestAnimationFrame(() => setMorphReady(true));
});
return () => {
cancelAnimationFrame(prepareFrame);
cancelAnimationFrame(openFrame);
};
}, [
context.open,
context.point,
context.contentRef,
context.invocation,
context.modality,
context.reduce,
]);
useEffect(() => {
if (!context.open) return;
const frame = requestAnimationFrame(() => {
const first = getEnabledItems(context.contentRef.current)[0];
first?.focus({ preventScroll: true });
});
return () => cancelAnimationFrame(frame);
}, [context.open, context.contentRef]);
useEffect(
() => () => {
if (typeaheadTimer.current) clearTimeout(typeaheadTimer.current);
},
[],
);
const moveFocus = (direction: 1 | -1) => {
const items = getEnabledItems(context.contentRef.current);
if (items.length === 0) return;
const current = items.indexOf(document.activeElement as HTMLElement);
const next = current < 0 ? 0 : (current + direction + items.length) % items.length;
items[next]?.focus();
};
const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
if (event.key === "Escape") {
event.preventDefault();
context.setOpen(false);
context.triggerRef.current?.focus();
return;
}
if (event.key === "Tab") {
context.setOpen(false);
return;
}
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
moveFocus(event.key === "ArrowDown" ? 1 : -1);
return;
}
if (event.key === "Home" || event.key === "End") {
event.preventDefault();
const items = getEnabledItems(context.contentRef.current);
items[event.key === "Home" ? 0 : items.length - 1]?.focus();
return;
}
if (
event.key.length === 1 &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey
) {
typeahead.current += event.key.toLocaleLowerCase();
if (typeaheadTimer.current) clearTimeout(typeaheadTimer.current);
typeaheadTimer.current = setTimeout(() => {
typeahead.current = "";
}, 500);
const match = getEnabledItems(context.contentRef.current).find((item) =>
(item.dataset.label ?? item.textContent ?? "")
.trim()
.toLocaleLowerCase()
.startsWith(typeahead.current),
);
match?.focus();
}
};
if (!mounted) return null;
const visualOpen = context.open && morphReady;
const clipHidden = collapsedClip(origin, size);
const clipShown = "inset(0px 0px 0px 0px round 12px)";
return createPortal(
<div
data-context-menu-portal=""
aria-hidden={!context.open}
inert={!context.open}
style={{ left: position.x, top: position.y }}
className={cn(
"fixed z-[100] [filter:drop-shadow(0_18px_28px_rgba(0,0,0,0.2))]",
context.open ? "pointer-events-auto" : "pointer-events-none",
)}
>
<motion.div
ref={context.contentRef}
id={context.menuId}
role="menu"
aria-label={ariaLabel}
data-morph-ready={morphReady ? "true" : "false"}
tabIndex={-1}
initial={false}
animate={{
opacity: visualOpen ? 1 : 0,
clipPath:
context.reduce || context.modality === "keyboard" || visualOpen
? clipShown
: clipHidden,
}}
transition={
context.modality === "keyboard"
? { duration: 0 }
: context.reduce
? { duration: 0.1, ease: EASE_OUT }
: {
clipPath: {
duration: MORPH_DURATION,
ease: EASE_OUT,
},
opacity: {
duration: MORPH_DURATION,
ease: EASE_OUT,
},
}
}
onKeyDown={onKeyDown}
onContextMenu={(event) => event.preventDefault()}
className={cn(
"min-w-56 overflow-hidden rounded-xl border border-border bg-card p-1.5 text-foreground outline-none",
className,
)}
>
{children}
</motion.div>
</div>,
document.body,
);
}
type ContextMenuItemTone = "default" | "destructive";
export interface ContextMenuItemProps {
children: ReactNode;
onSelect?: () => void;
disabled?: boolean;
closeOnSelect?: boolean;
tone?: ContextMenuItemTone;
inset?: boolean;
className?: string;
textValue?: string;
}
function ContextMenuItemBase({
children,
onSelect,
disabled = false,
closeOnSelect = true,
tone = "default",
inset = false,
className,
textValue,
role = "menuitem",
ariaChecked,
}: ContextMenuItemProps & {
role?: "menuitem" | "menuitemcheckbox" | "menuitemradio";
ariaChecked?: boolean;
}) {
const context = useContextMenuContext("ContextMenuItem");
const id = useId();
const active = context.activeId === id;
const checkedProps =
role === "menuitem" ? {} : { "aria-checked": ariaChecked };
return (
<button
type="button"
id={id}
role={role}
{...checkedProps}
disabled={disabled}
data-context-menu-item="true"
data-disabled={disabled ? "true" : undefined}
data-label={textValue}
tabIndex={-1}
onFocus={() => context.setActiveId(id)}
onPointerMove={(event) => {
if (!disabled && event.pointerType !== "touch") event.currentTarget.focus();
}}
onClick={() => {
if (disabled) return;
onSelect?.();
if (closeOnSelect) context.setOpen(false);
}}
className={cn(
"relative isolate flex w-full select-none items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-[13px] outline-none",
"focus-visible:ring-2 focus-visible:ring-foreground/15",
"disabled:pointer-events-none disabled:opacity-40",
inset && "pl-8",
tone === "destructive" ? "text-destructive" : "text-foreground",
className,
)}
>
{active ? (
<motion.span
layoutId={`${context.menuId}-active`}
className={cn(
"absolute inset-0 -z-10 rounded-lg",
tone === "destructive"
? "bg-destructive/10"
: "bg-foreground/[0.065]",
)}
transition={context.reduce ? { duration: 0 } : SPRING_LAYOUT}
/>
) : null}
{children}
</button>
);
}
export function ContextMenuItem(props: ContextMenuItemProps) {
return <ContextMenuItemBase {...props} />;
}
export interface ContextMenuCheckboxItemProps
extends Omit<ContextMenuItemProps, "onSelect"> {
checked: boolean;
onCheckedChange?: (checked: boolean) => void;
}
export function ContextMenuCheckboxItem({
checked,
onCheckedChange,
children,
...props
}: ContextMenuCheckboxItemProps) {
const context = useContextMenuContext("ContextMenuCheckboxItem");
return (
<ContextMenuItemBase
{...props}
role="menuitemcheckbox"
ariaChecked={checked}
onSelect={() => onCheckedChange?.(!checked)}
>
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
<AnimatePresence initial={false}>
{checked ? (
<motion.span
key="check"
initial={context.reduce ? false : { opacity: 0, scale: 0.75 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: context.reduce ? 1 : 0.75 }}
transition={context.reduce ? { duration: 0.08 } : SPRING_PANEL}
>
<Check aria-hidden="true" className="h-3.5 w-3.5" strokeWidth={2.4} />
</motion.span>
) : null}
</AnimatePresence>
</span>
{children}
</ContextMenuItemBase>
);
}
interface ContextMenuRadioGroupContextValue {
value: string;
onValueChange?: (value: string) => void;
}
const ContextMenuRadioGroupContext =
createContext<ContextMenuRadioGroupContextValue | null>(null);
export interface ContextMenuRadioGroupProps {
value: string;
onValueChange?: (value: string) => void;
children: ReactNode;
className?: string;
}
export function ContextMenuRadioGroup({
value,
onValueChange,
children,
className,
}: ContextMenuRadioGroupProps) {
const context = useMemo(
() => ({ value, onValueChange }),
[value, onValueChange],
);
return (
<ContextMenuRadioGroupContext.Provider value={context}>
<div className={className}>{children}</div>
</ContextMenuRadioGroupContext.Provider>
);
}
export interface ContextMenuRadioItemProps
extends Omit<ContextMenuItemProps, "onSelect"> {
value: string;
}
export function ContextMenuRadioItem({
value,
children,
...props
}: ContextMenuRadioItemProps) {
const group = useContext(ContextMenuRadioGroupContext);
if (!group) {
throw new Error(
"ContextMenuRadioItem must be used within <ContextMenuRadioGroup>",
);
}
const checked = group.value === value;
return (
<ContextMenuItemBase
{...props}
role="menuitemradio"
ariaChecked={checked}
onSelect={() => group.onValueChange?.(value)}
>
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
<span
className={cn(
"h-1.5 w-1.5 rounded-full bg-current transition-opacity",
checked ? "opacity-100" : "opacity-0",
)}
/>
</span>
{children}
</ContextMenuItemBase>
);
}
export interface ContextMenuLabelProps {
children: ReactNode;
inset?: boolean;
className?: string;
}
export function ContextMenuLabel({
children,
inset = false,
className,
}: ContextMenuLabelProps) {
return (
<div
className={cn(
"px-2.5 pb-1 pt-1.5 text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground",
inset && "pl-8",
className,
)}
>
{children}
</div>
);
}
export interface ContextMenuSeparatorProps {
className?: string;
}
export function ContextMenuSeparator({
className,
}: ContextMenuSeparatorProps) {
return (
<hr className={cn("-mx-1 my-1 h-px border-0 bg-border", className)} />
);
}
export interface ContextMenuShortcutProps {
children: ReactNode;
className?: string;
}
export function ContextMenuShortcut({
children,
className,
}: ContextMenuShortcutProps) {
return (
<span
aria-hidden="true"
className={cn(
"ml-auto pl-4 text-[10px] font-medium tracking-wide text-muted-foreground",
className,
)}
>
{children}
</span>
);
}
API Reference
ContextMenu
open?boolean—defaultOpen?booleanfalseonOpenChange?((open: boolean) => void)—className?string—ContextMenuTrigger
disabled?booleanfalseclassName?string—ContextMenuContent
className?string—ariaLabel?stringContext menuContextMenuItem
onSelect?(() => void)—disabled?boolean—closeOnSelect?boolean—tone?"default" | "destructive"—inset?boolean—className?string—textValue?string—ContextMenuCheckboxItem
checkedboolean—onCheckedChange?((checked: boolean) => void)—className?string—inset?boolean—disabled?boolean—closeOnSelect?boolean—tone?"default" | "destructive"—textValue?string—ContextMenuRadioGroup
valuestring—onValueChange?((value: string) => void)—className?string—ContextMenuRadioItem
valuestring—className?string—inset?boolean—disabled?boolean—closeOnSelect?boolean—tone?"default" | "destructive"—textValue?string—ContextMenuLabel
inset?booleanfalseclassName?string—ContextMenuSeparator
className?string—ContextMenuShortcut
className?string—Related components
Animated Color Selector
Composable color swatches with a spring-gliding selection ring, press feedback, native radio keyboard navigation, and controlled or uncontrolled state.
File Tree
Composable file and folder primitives with springing branches, a gliding selection, and complete keyboard navigation.
Popover
Gooey popover whose panel oozes out of the trigger through an SVG goo filter with a liquid neck that stretches and pinches, then crisp content fades in on top. The Morph variant reveals its full-size panel from the corner nearest the trigger on one synchronized spring clip. Click or hover trigger, controlled or uncontrolled.
Animated Context Menu is a composable right-click menu for React built with Framer Motion and Tailwind CSS. The menu grows out of the exact point where it was invoked through a clip morph, and a highlight glides between rows as you move through them. It supports plain items, checkbox and radio choices, labels, separators and keyboard shortcut hints, and opens on right-click or a long press on touch screens.
When to use it
- A file manager, kanban board or design canvas needs right-click actions on its items.
- A message list or table row needs contextual actions such as copy, rename and delete.
- A mobile web app needs long-press menus that behave like native context menus.
Accessibility
The trigger exposes aria-haspopup and aria-expanded, and the menu uses the menu role with menuitem, menuitemcheckbox and menuitemradio items that report aria-checked. It opens from the keyboard with the ContextMenu key or Shift and F10 and focuses the first enabled item. Arrow Up and Arrow Down move with wraparound, Home and End jump to the ends, typing jumps to a matching item, Escape closes and returns focus to the trigger, and Tab closes the menu. With reduced motion enabled, or when opened from the keyboard, the menu appears without the clip morph.
FAQ
- How do I make a custom right click menu in React?
- Wrap the target element in ContextMenuTrigger inside ContextMenu, then add ContextMenuContent with ContextMenuItem children. Each item takes an onSelect callback, and the menu opens at the cursor.
- Can the context menu have checkboxes or radio options?
- Yes. Use ContextMenuCheckboxItem with checked and onCheckedChange, or ContextMenuRadioGroup with value and onValueChange around ContextMenuRadioItem children. ContextMenuLabel, ContextMenuSeparator and ContextMenuShortcut help organize the list.
- Does the context menu work on mobile?
- Yes. A long press on the trigger opens the menu on touch devices, and moving the finger too far cancels it. You can also control it with open, defaultOpen and onOpenChange.
Updated