Styled context menu
Five right-click menus: simple, icon, shortcut, section and danger. Each is self-contained (no radix), sized, token-driven, and opens at the cursor on right click.
Installation
The styled layer is free and installs like any other ai2 component.
Run the following command
npx shadcn@latest add @ai2/context-menu-styledDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install motion lucide-reactCopy the source
components/ui/context-menu-styled.tsx"use client"
import * as React from "react"
import { ClipboardPaste, Copy, Scissors, Share2, Star } from "lucide-react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { cn } from "@/lib/utils"
/* Context menu family: 5 decorative self-contained menus. NO radix or portal. Each
export renders a target area (children, by default a dashed token box saying
"Right-click here"); onContextMenu (preventDefault) opens a fixed menu at the
cursor (clientX/clientY written to state). It closes on an outside click (window
pointerdown), on Escape, and on an item selection. AnimatePresence fade+scale;
instant under reduced motion. Color comes ONLY from tokens, via alpha color-mix;
hover is bg-accent. The coordinates come from the contextmenu event, with no
randomness. */
export type StyledSize = "sm" | "md" | "lg" | "xl"
export type StyledContextMenuItem = {
label: React.ReactNode
icon?: React.ReactNode
onSelect?: () => void
shortcut?: React.ReactNode
group?: string
danger?: boolean
}
type ContextMenuProps = {
className?: string
size?: StyledSize
children?: React.ReactNode
items?: StyledContextMenuItem[]
}
const menuSize: Record<StyledSize, string> = {
sm: "w-48",
md: "w-56",
lg: "w-64",
xl: "w-72",
}
const targetBox =
"flex h-28 w-full select-none items-center justify-center rounded-xl border border-dashed border-border bg-[color-mix(in_oklab,var(--color-foreground)_3%,transparent)] px-6 text-center text-sm text-muted-foreground"
const menuBase =
"fixed z-50 origin-top-left overflow-hidden rounded-xl border border-border bg-popover p-1.5 text-sm text-popover-foreground shadow-lg outline-none"
const itemBase =
"flex w-full cursor-default items-center gap-2 rounded-md px-3 py-2 text-left text-sm text-popover-foreground outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 [&_svg]:size-4 [&_svg]:shrink-0 [&_i]:text-base [&_i]:leading-none"
const itemDanger =
"flex w-full cursor-default items-center gap-2 rounded-md px-3 py-2 text-left text-sm text-danger outline-none transition-colors hover:bg-[color-mix(in_oklab,var(--color-danger)_12%,transparent)] hover:text-danger focus-visible:bg-[color-mix(in_oklab,var(--color-danger)_12%,transparent)] focus-visible:ring-[3px] focus-visible:ring-danger/40 [&_svg]:size-4 [&_svg]:shrink-0 [&_i]:text-base [&_i]:leading-none"
const shortcutChip =
"ml-auto inline-flex h-5 items-center rounded border border-border bg-[color-mix(in_oklab,var(--color-foreground)_5%,transparent)] px-1.5 font-mono text-[11px] text-muted-foreground"
/* One row: icon plus label plus an optional shortcut chip; the danger tone is styled separately. onSelect runs on choice and the menu closes. */
function MenuItemButton({
item,
close,
}: {
item: StyledContextMenuItem
close: () => void
}) {
return (
<button
type="button"
role="menuitem"
onClick={() => {
item.onSelect?.()
close()
}}
className={item.danger ? itemDanger : itemBase}
>
{item.icon ? (
<span className="inline-flex shrink-0" aria-hidden>
{item.icon}
</span>
) : null}
<span className="flex-1 truncate">{item.label}</span>
{item.shortcut ? <kbd className={shortcutChip}>{item.shortcut}</kbd> : null}
</button>
)
}
/* Shared shell: the target area plus a fixed AnimatePresence menu at the cursor position. render(close) produces the menu body. It closes on outside pointerdown, Escape or item selection. */
function ContextMenuShell({
className,
size = "md",
children,
render,
}: {
className?: string
size?: StyledSize
children?: React.ReactNode
render: (close: () => void) => React.ReactNode
}) {
const [open, setOpen] = React.useState(false)
const [coords, setCoords] = React.useState({ x: 0, y: 0 })
const reduce = useReducedMotion()
const menuRef = React.useRef<HTMLDivElement>(null)
const close = React.useCallback(() => setOpen(false), [])
const onContextMenu = (e: React.MouseEvent) => {
e.preventDefault()
setCoords({ x: e.clientX, y: e.clientY })
setOpen(true)
}
React.useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false)
}
const onPointer = (e: PointerEvent) => {
const node = menuRef.current
if (node && e.target instanceof Node && !node.contains(e.target)) {
setOpen(false)
}
}
window.addEventListener("keydown", onKey)
window.addEventListener("pointerdown", onPointer)
return () => {
window.removeEventListener("keydown", onKey)
window.removeEventListener("pointerdown", onPointer)
}
}, [open])
const anim = reduce
? { initial: { opacity: 1 }, animate: { opacity: 1 }, exit: { opacity: 1 } }
: {
initial: { opacity: 0, scale: 0.96 },
animate: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0.96 },
}
return (
<div data-slot="styled-context-menu" className="w-full">
<div
data-slot="styled-context-menu-target"
onContextMenu={onContextMenu}
className={cn(targetBox, className)}
>
{children ?? "Right-click here"}
</div>
<AnimatePresence>
{open ? (
<motion.div
ref={menuRef}
role="menu"
data-slot="styled-context-menu-content"
style={{ left: coords.x, top: coords.y }}
className={cn(menuBase, menuSize[size])}
initial={anim.initial}
animate={anim.animate}
exit={anim.exit}
transition={reduce ? { duration: 0 } : { duration: 0.14, ease: "easeOut" }}
>
{render(close)}
</motion.div>
) : null}
</AnimatePresence>
</div>
)
}
/* Simple: sade dikey liste. */
export function SimpleContextMenu({ className, size = "md", children, items }: ContextMenuProps) {
const list = items ?? [
{ label: "Back" },
{ label: "Reload" },
{ label: "Save as..." },
{ label: "Print" },
]
return (
<ContextMenuShell className={className} size={size} render={(close) => (
<div className="flex flex-col">
{list.map((item, i) => (
<MenuItemButton key={i} item={item} close={close} />
))}
</div>
)}>
{children}
</ContextMenuShell>
)
}
/* Icon: her satirin basinda token ikon. */
export function IconContextMenu({ className, size = "md", children, items }: ContextMenuProps) {
const list = items ?? [
{ label: "Copy", icon: <Copy /> },
{ label: "Cut", icon: <Scissors /> },
{ label: "Paste", icon: <ClipboardPaste /> },
{ label: "Share", icon: <Share2 /> },
{ label: "Add to favorites", icon: <Star /> },
]
return (
<ContextMenuShell className={className} size={size} render={(close) => (
<div className="flex flex-col">
{list.map((item, i) => (
<MenuItemButton key={i} item={item} close={close} />
))}
</div>
)}>
{children}
</ContextMenuShell>
)
}
/* Shortcut: every row shows a right-aligned token keyboard-shortcut chip. */
export function ShortcutContextMenu({ className, size = "md", children, items }: ContextMenuProps) {
const list = items ?? [
{ label: "Cut", shortcut: "Ctrl X" },
{ label: "Copy", shortcut: "Ctrl C" },
{ label: "Paste", shortcut: "Ctrl V" },
{ label: "Select all", shortcut: "Ctrl A" },
]
return (
<ContextMenuShell className={className} size={size} render={(close) => (
<div className="flex flex-col">
{list.map((item, i) => (
<MenuItemButton key={i} item={item} close={close} />
))}
</div>
)}>
{children}
</ContextMenuShell>
)
}
/* Section: label baslikli + ayirici cizgili gruplanmis satirlar. */
export function SectionContextMenu({ className, size = "md", children, items }: ContextMenuProps) {
const list = items ?? [
{ label: "Undo", group: "Edit" },
{ label: "Redo", group: "Edit" },
{ label: "Cut", group: "Clipboard" },
{ label: "Copy", group: "Clipboard" },
{ label: "Paste", group: "Clipboard" },
]
const groups: { name: string; rows: StyledContextMenuItem[] }[] = []
for (const item of list) {
const name = item.group ?? ""
const last = groups[groups.length - 1]
if (last && last.name === name) last.rows.push(item)
else groups.push({ name, rows: [item] })
}
return (
<ContextMenuShell className={className} size={size} render={(close) => (
<div className="flex flex-col">
{groups.map((g, gi) => (
<div key={gi} className="flex flex-col">
{gi > 0 ? <div role="separator" className="my-1 h-px bg-border" /> : null}
{g.name ? (
<div className="px-3 pb-1 pt-2 text-xs font-medium text-muted-foreground">
{g.name}
</div>
) : null}
{g.rows.map((item, i) => (
<MenuItemButton key={i} item={item} close={close} />
))}
</div>
))}
</div>
)}>
{children}
</ContextMenuShell>
)
}
/* Danger: normal liste + ayirici ile ayrilmis son danger-token "Delete" satiri. */
export function DangerContextMenu({ className, size = "md", children, items }: ContextMenuProps) {
const list = items ?? [
{ label: "Open" },
{ label: "Rename" },
{ label: "Duplicate" },
{ label: "Delete", danger: true },
]
const normal = list.filter((i) => !i.danger)
const danger = list.filter((i) => i.danger)
return (
<ContextMenuShell className={className} size={size} render={(close) => (
<div className="flex flex-col">
{normal.map((item, i) => (
<MenuItemButton key={i} item={item} close={close} />
))}
{danger.length > 0 ? (
<>
<div role="separator" className="my-1 h-px bg-border" />
{danger.map((item, i) => (
<MenuItemButton key={i} item={item} close={close} />
))}
</>
) : null}
</div>
)}>
{children}
</ContextMenuShell>
)
}Manual installs skip the @ai2/tokens theme, so add the token CSS from the theming guide or the tone colors will be missing.
Variations
5 takes on the same idea. Each is its own export, and every one accepts a size prop (sm, md, lg, xl) aligned to the base Button scale.
Simple
A clean right-click list.
import { SimpleContextMenu } from "@/components/ui/context-menu-styled"
<SimpleContextMenu />Icon
Each item has a leading token icon.
import { IconContextMenu } from "@/components/ui/context-menu-styled"
<IconContextMenu />Shortcut
Items show a right-aligned shortcut chip.
import { ShortcutContextMenu } from "@/components/ui/context-menu-styled"
<ShortcutContextMenu />Section
Items grouped under token headers.
import { SectionContextMenu } from "@/components/ui/context-menu-styled"
<SectionContextMenu />Danger
A final danger-token destructive item set apart.
import { DangerContextMenu } from "@/components/ui/context-menu-styled"
<DangerContextMenu />ai2 Styled context menu: 5 styled variations on the token system
The ai2 Styled context menu are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around right-click menus. They are free and MIT licensed, and every color comes from a semantic token, so they theme with the rest of ai2 in light and dark.
Motion runs on framer-motion: framer-motion fades and scales the menu in at the cursor. The styled layer is opt-in, so the dependency only lands if you use it; the base components stay lean. Under reduced motion, the scale is skipped and the menu appears instantly.
What is in the ai2 Styled context menu?
5 exports in one file: Simple, Icon, Shortcut, Section and Danger. Each renders a native button and takes a size prop (sm, md, lg, xl) aligned to the base Button. They are separate from the base Button on purpose: the base keeps its clean variant, tone and size axes, while the styled layer carries the effects.
You own the file. Copy the one category file and you have all 5 variations, with no runtime dependency on ai2 itself.
Why use it
- On-system by construction: Every color resolves to an ai2 semantic token, so the buttons follow your theme in light and dark with no extra work.
- Effect without the sprawl: The decorations live in a dedicated styled file, so the base Button keeps its clean, predictable API.
- Accessible and honest: Each renders a real button element, keeps a visible focus ring, and respects prefers-reduced-motion.
Features
- Token-driven color: No hardcoded hex or oklch; the look recolors with your theme tokens.
- framer-motion: framer-motion fades and scales the menu in at the cursor.
- Reduced-motion aware: Under prefers-reduced-motion, the scale is skipped and the menu appears instantly.
- Size aligned to the base: Every variation takes sm, md, lg and xl matching the base Button height scale, so styled and base buttons line up in a row.
Production tips
- Use it for emphasis, not everywhere: Styled buttons draw the eye. Reserve them for the one action you want people to take on a screen, and use the base Button for the rest.
- Keep labels as verbs: The decoration adds weight, so a clear action label keeps the button scannable.
- Pick one variation per surface: The variations share a family; using two different ones in the same view competes for attention.
Works with the rest of ai2
The Styled context menu sit alongside the base Button and the rest of the @ai2 registry. They share the same token file, so a styled action next to a base button or a badge stays visually consistent in both modes.