ContextMenu
Overview
The ContextMenu is a floating menu surface opened on right-click (or long-press) to present actions relevant to the element under the cursor. It shares the same menu surface as the Dropdown and Select panels, and adds the parts a contextual menu needs: keyboard-shortcut hints, checkbox & radio rows, a destructive action style, and submenu triggers.
It is composed of six recipe parts: useContextMenuRecipe() for the panel container, useContextMenuItemRecipe() for clickable rows (with inset and destructive options), useContextMenuSeparatorRecipe() for dividers, useContextMenuLabelRecipe() for group headings, useContextMenuShortcutRecipe() for trailing keyboard hints, and useContextMenuSubTriggerRecipe() for rows that open a submenu. Each composable creates a fully configured recipe with color, variant (where applicable), and size options — plus compound variants that handle the color-variant combinations and interactive states automatically.
The ContextMenu recipes integrate directly with the default design tokens preset and generate type-safe utility classes at build time with zero runtime CSS.
Why use the ContextMenu recipe?
The ContextMenu recipe helps you:
- Ship faster with sensible defaults: Get 3 colors, 3 visual styles, and 3 sizes out of the box with a single set of composable calls.
- Compose rich menus: Six coordinated recipes share the same color and variant axes, so panels, items, shortcuts, and submenu triggers stay internally consistent.
- Cover every row type:
insetrows align with checkbox & radio rows,destructiverecolors high-risk actions, and the sub-trigger paints an open-state highlight — all through variant axes. - Customize without forking: Override base styles, default variants, or filter out options you don't need — all through the options API.
- Stay type-safe: Full TypeScript support means your editor catches invalid color, variant, or size values at compile time.
- Integrate with your tokens: Every value references the design tokens preset, so theme changes propagate automatically.
Usage
Register the recipes
Add the ContextMenu recipes to a local Styleframe instance. The global styleframe.config.ts provides design tokens and utilities, while the component-level file registers the recipes themselves:
import { styleframe } from 'virtual:styleframe';
import {
useContextMenuRecipe,
useContextMenuItemRecipe,
useContextMenuSeparatorRecipe,
useContextMenuLabelRecipe,
useContextMenuShortcutRecipe,
useContextMenuSubTriggerRecipe,
} from '@styleframe/theme';
const s = styleframe();
const contextMenu = useContextMenuRecipe(s);
const contextMenuItem = useContextMenuItemRecipe(s);
const contextMenuSeparator = useContextMenuSeparatorRecipe(s);
const contextMenuLabel = useContextMenuLabelRecipe(s);
const contextMenuShortcut = useContextMenuShortcutRecipe(s);
const contextMenuSubTrigger = useContextMenuSubTriggerRecipe(s);
export default s;
Build the component
Import the recipe runtime functions from the virtual module and pass variant props to compute class names. Checkbox & radio rows reuse contextMenuItem with inset and add a .context-menu-item-indicator slot for the check:
import {
contextMenu,
contextMenuItem,
contextMenuSeparator,
contextMenuLabel,
contextMenuShortcut,
contextMenuSubTrigger,
type ContextMenuProps,
} from "virtual:styleframe";
interface ContextMenuComponentProps extends ContextMenuProps {
children?: React.ReactNode;
}
export function ContextMenu({
color = "neutral",
variant = "solid",
size = "md",
children,
}: ContextMenuComponentProps) {
return (
<div role="menu" className={contextMenu({ color, variant, size })}>
{children}
</div>
);
}
export function ContextMenuItem({
color = "neutral",
variant = "solid",
size = "md",
inset,
destructive,
children,
}: ContextMenuComponentProps & { inset?: boolean; destructive?: boolean }) {
return (
<button
type="button"
role="menuitem"
className={contextMenuItem({
color,
variant,
size,
inset: inset ? "true" : "false",
destructive: destructive ? "true" : "false",
})}
>
{children}
</button>
);
}
export function ContextMenuShortcut({
color = "neutral",
size = "md",
children,
}: Omit<ContextMenuProps, "variant"> & { children?: React.ReactNode }) {
return <span className={contextMenuShortcut({ color, size })}>{children}</span>;
}
See it in action
Colors
The ContextMenu recipe includes 3 color variants: light, dark, and neutral. Like other surface recipes (Card, Modal, Popover, Dropdown), the ContextMenu uses neutral-spectrum colors designed for content surfaces rather than status communication. Each color is combined with every visual style variant through compound variants, so you get consistent, predictable styling across all combinations — including hover, focus, and active states plus dark mode overrides.
The neutral color adapts automatically: it uses a light appearance in light mode and a dark appearance in dark mode, making it the safest default for general-purpose menus.
Color Reference
| Color | Token | Use Case |
|---|---|---|
light | @color.white / @color.gray-* | Light surfaces, stays light in dark mode |
dark | @color.gray-900 | Dark surfaces, stays dark in light mode |
neutral | Adaptive (light ↔ dark) | Default color, adapts to the current color scheme |
neutral as your default context menu color. It adapts automatically to the user's color scheme, so you don't need to manage light and dark variants separately.Variants
Three visual style variants control how the panel and its rows are rendered. Each variant is combined with the selected color through compound variants, so you always get the correct surface background, text color, border color, and interactive states. Pass the same variant to the container and each row so item hover, focus, and submenu open backgrounds match the surrounding surface.
Solid
Filled panel with a subtle border. The most prominent style, ideal for primary menus and authoritative actions.
Soft
Light tinted panel with no visible border. A gentle, borderless style that works well for menus embedded in dense layouts.
Subtle
Light tinted panel with a matching border. Combines the softness of the soft variant with added visual definition from a border.
Sizes
Three size variants from sm to lg control the panel's inner padding and border radius, each row's padding and font size, and the label's and shortcut's font size.
Size Reference
| Size | Panel Padding | Panel Radius | Item Padding (V / H) | Item Font Size |
|---|---|---|---|---|
sm | @0.125 | @border-radius.sm | @0.25 / @0.5 | @font-size.xs |
md | @0.25 | @border-radius.md | @0.375 / @0.625 | @font-size.sm |
lg | @0.375 | @border-radius.lg | @0.5 / @0.75 | @font-size.md |
size prop must be passed to the panel, each row, the label, and each shortcut individually. The separator does not have a size variant — it's always a single-pixel rule regardless of size.Anatomy
The ContextMenu recipe is composed of six independent recipes that work together to form a contextual menu surface:
| Part | Recipe | Role |
|---|---|---|
| Panel | useContextMenuRecipe() | Outer wrapper with background, border, border radius, shadow, z-index: @z-index.dropdown, and a max-width cap (@12–@18) so long labels wrap instead of stretching the menu |
| Item | useContextMenuItemRecipe() | Clickable row with hover, focus, active, and disabled states; inset reserves a leading gutter and destructive recolors high-risk actions |
| Separator | useContextMenuSeparatorRecipe() | Horizontal divider between row groups |
| Label | useContextMenuLabelRecipe() | Uppercase group heading with muted text color and an inset option |
| Shortcut | useContextMenuShortcutRecipe() | Trailing, muted keyboard-hint text pushed to the row's far edge |
| Sub-trigger | useContextMenuSubTriggerRecipe() | Row that opens a submenu, with an open state that paints the hover surface |
Checkbox and radio rows do not have their own recipe: render a contextMenuItem with inset set to "true" and place a .context-menu-item-indicator element inside it. The setup callback registers that slot as an absolutely positioned, vertically centered gutter on the row's leading edge, so the check or dot reveals without shifting the label.
<!-- A checkbox row: inset item + indicator slot -->
<button type="button" role="menuitemcheckbox" aria-checked="true" class="contextMenuItem({ inset: 'true' })">
<span class="context-menu-item-indicator" aria-hidden="true">✔</span>
Show Bookmarks
</button>
<!-- A submenu trigger: row + trailing chevron -->
<button type="button" role="menuitem" aria-haspopup="true" class="contextMenuSubTrigger(...)">
More Tools
<span class="context-menu-sub-trigger-icon" aria-hidden="true">›</span>
</button>
The color and variant props should be passed consistently to the panel, each row, and the sub-trigger so that hover, focus, and open backgrounds render correctly against the surrounding surface. The separator accepts only color; the label and shortcut accept color and size but not variant, since they are text-only.
Accessibility
- Use the right menu roles. The panel uses
role="menu", plain rows userole="menuitem", checkbox rows userole="menuitemcheckbox"witharia-checked, and radio rows userole="menuitemradio"witharia-checked. The separator usesrole="separator"and the label usesrole="presentation"to stay out of the item tab order.
<!-- Correct: menu with a labelled group, a checkbox row, and a separated destructive action -->
<div role="menu" aria-label="Page actions" class="...">
<div role="presentation" class="...">View</div>
<button type="button" role="menuitemcheckbox" aria-checked="true" class="...">Show Grid</button>
<hr role="separator" class="..." />
<button type="button" role="menuitem" class="...">Delete</button>
</div>
- Open on the contextmenu event. A context menu is triggered by right-click or long-press. Handle the
contextmenuevent (and callpreventDefault()), and also open it via the keyboardMenukey orShift+F10on the focused element. - Implement keyboard navigation. The menu must support
ArrowUp/ArrowDownto move between rows,ArrowRight/ArrowLeftto open / close submenus,Home/Endto jump to the first / last row,EnterorSpaceto activate the focused row, andEscapeto close the menu and return focus to the originating element. - Mark submenu triggers. A sub-trigger needs
aria-haspopup="true"andaria-expandedtoggling between"true"and"false"; pass the same boolean to the recipe'sopenaxis so the highlight matches the expanded state. - Use roving tabindex on rows. Only one row should be in the tab order at a time (
tabindex="0"); the rest usetabindex="-1"and receive focus programmatically via arrow keys. - Keep disabled rows perceivable. Disabled rows should retain
aria-disabled="true"and remain focusable so keyboard users can perceive them, but not activatable. The recipe's&:disabledstyles applycursor: not-allowed,opacity: 0.75, andpointer-events: none. - Don't rely on color alone for destructive rows. The
destructivestyle uses error-colored text and hover background; pair it with a clear label (e.g. "Delete") so the intent is conveyed without color. Default tokens meet WCAG AA 4.5:1 contrast — verify any overrides with the WebAIM Contrast Checker.
Customization
Overriding Defaults
Each context menu composable accepts an optional second argument to override any part of the recipe configuration. Overrides are deep-merged with the defaults, so you only need to specify the properties you want to change:
import { styleframe } from 'virtual:styleframe';
import {
useContextMenuRecipe,
useContextMenuItemRecipe,
} from '@styleframe/theme';
const s = styleframe();
const contextMenu = useContextMenuRecipe(s, {
base: {
boxShadow: '@box-shadow.lg',
minWidth: '@14',
},
defaultVariants: {
color: 'neutral',
variant: 'subtle',
size: 'lg',
},
});
const contextMenuItem = useContextMenuItemRecipe(s, {
defaultVariants: {
color: 'neutral',
variant: 'subtle',
size: 'lg',
inset: 'false',
destructive: 'false',
},
});
export default s;
Filtering Variants
If you only need a subset of the available variants, use the filter option to limit which values are generated. This reduces the output CSS and keeps your component API focused:
import { styleframe } from 'virtual:styleframe';
import { useContextMenuRecipe, useContextMenuItemRecipe } from '@styleframe/theme';
const s = styleframe();
// Only generate the neutral color with solid and subtle styles
const contextMenu = useContextMenuRecipe(s, {
filter: {
color: ['neutral'],
variant: ['solid', 'subtle'],
},
});
const contextMenuItem = useContextMenuItemRecipe(s, {
filter: {
color: ['neutral'],
variant: ['solid', 'subtle'],
},
});
export default s;
inset and destructive compounds (which don't reference color) are preserved, so inset and destructive rows keep working after filtering.API Reference
useContextMenuRecipe(s, options?)
Creates the context menu panel recipe with background, border, border radius, shadow, and z-index styling.
Parameters:
| Parameter | Type | Description |
|---|---|---|
s | Styleframe | The Styleframe instance |
options | DeepPartial<RecipeConfig> | Optional overrides for the recipe configuration |
options.base | VariantDeclarationsBlock | Custom base styles for the panel |
options.variants | Variants | Custom variant definitions for the recipe |
options.defaultVariants | Record<keyof Variants, string> | Default variant values for the recipe |
options.compoundVariants | CompoundVariant[] | Custom compound variant definitions for the recipe |
options.filter | Record<string, string[]> | Limit which variant values are generated |
Variants:
| Variant | Options | Default |
|---|---|---|
color | light, dark, neutral | neutral |
variant | solid, soft, subtle | solid |
size | sm, md, lg | md |
useContextMenuItemRecipe(s, options?)
Creates the context menu item recipe with hover, focus, active, and disabled states, plus two booleans: inset (reserves a leading indicator gutter) and destructive (recolors the row with error tones, layered last so it wins over the surface color). The setup callback registers the .context-menu-item-indicator leading slot for checkbox & radio rows.
Variants:
| Variant | Options | Default |
|---|---|---|
color | light, dark, neutral | neutral |
variant | solid, soft, subtle | solid |
size | sm, md, lg | md |
inset | true, false | false |
destructive | true, false | false |
useContextMenuSeparatorRecipe(s, options?)
Creates the separator recipe for dividers between row groups. Accepts only the color axis.
Variants:
| Variant | Options | Default |
|---|---|---|
color | light, dark, neutral | neutral |
useContextMenuLabelRecipe(s, options?)
Creates the label recipe for uppercase group headings with muted text color. Accepts color, size, and inset (to align with checkbox/radio rows), but not variant.
Variants:
| Variant | Options | Default |
|---|---|---|
color | light, dark, neutral | neutral |
size | sm, md, lg | md |
inset | true, false | false |
useContextMenuShortcutRecipe(s, options?)
Creates the shortcut recipe for the trailing, muted keyboard-hint text. Pushed to the row's far edge with margin-left: auto. Accepts color and size.
Variants:
| Variant | Options | Default |
|---|---|---|
color | light, dark, neutral | neutral |
size | sm, md, lg | md |
useContextMenuSubTriggerRecipe(s, options?)
Creates the sub-trigger recipe for rows that open a submenu. Shares the item surface and adds an open boolean that paints the row with its hover background while the submenu is open. The setup callback registers the .context-menu-sub-trigger-icon trailing chevron slot.
Variants:
| Variant | Options | Default |
|---|---|---|
color | light, dark, neutral | neutral |
variant | solid, soft, subtle | solid |
size | sm, md, lg | md |
open | true, false | false |
Best Practices
- Pass
colorandvariantconsistently: The panel, each row, and the sub-trigger need the samecolorandvariantvalues so hover, focus, and open backgrounds render correctly against the surrounding surface. - Use
insetwhen a menu mixes indicator rows with plain rows: Inset reserves the leading gutter so labels stay aligned whether or not a row shows a check or dot. - Reserve
destructivefor high-risk actions: Apply it to actions like "Delete" and place them at the end of the menu, preceded by a separator. - Pair shortcuts with the actions they trigger: Put a
ContextMenuShortcutat the end of an item to surface its keyboard accelerator; keep the hint text short (e.g. "⌘C"). - Use
neutralfor general-purpose menus: The neutral color adapts to light and dark mode automatically, making it the safest default. - Filter what you don't need: If your component only uses one color, pass a
filteroption to reduce generated CSS. - Override defaults at the recipe level: Set your most common variant combination as
defaultVariantsso component consumers write less code.
FAQ
shortcut for keyboard hints, a sub-trigger for submenus, an indicator gutter for checkbox & radio rows (inset), and a destructive row style. Use Dropdown for a menu opened by clicking a trigger button, and ContextMenu for a menu opened by right-clicking content.contextMenuItem with inset set to "true" and place a <span class="context-menu-item-indicator"> inside it holding the check (for role="menuitemcheckbox") or dot (for role="menuitemradio"). The setup callback positions that slot in the leading gutter; reveal it based on the row's checked state.contextmenu event, positioning the menu at the cursor, portaling, focus trapping, and open/close state are application concerns. Pair the ContextMenu recipe with a floating-UI library (Floating UI, Radix primitives, or Reka UI) and apply these recipes to the rendered elements.light, dark, and neutral to provide surface variations that work across all menu contexts. For a high-risk action, use the item's destructive axis, which recolors a single row with error tones rather than tinting the whole menu.open boolean axis with 9 compound variants (3 colors × 3 variants) that paint the row with the same background as its hover state. Pass open: 'true' while the submenu is expanded (alongside aria-expanded="true") so the trigger stays highlighted, matching the hovered-row appearance for the current color and variant.The panel uses 9 compound variants (3 colors × 3 variants). The item uses 11 (the same 9 surface combinations plus a standalone inset rule and a standalone destructive rule appended last so it overrides the surface color). The sub-trigger uses 18 (9 surface + 9 open-state). The separator, label, and shortcut use 3 each (one per color).
Toggle Group
A layout component for arranging a set of toggles with shared orientation and spacing. Supports horizontal and vertical layouts and three gap sizes through the recipe system.
Drawer
An edge-anchored, slide-out panel composed of overlay, container, header, body, and footer sections. Anchors to any screen edge and supports multiple colors, visual styles, and sizes through the recipe system.