Styleframe Logo
Overlays

ContextMenu

A right-click menu surface for contextual actions. Composed of six coordinated recipes (panel, item, separator, label, shortcut, sub-trigger) with inset, destructive, checkbox/radio, and submenu support across three colors, three visual styles, and three sizes.

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: inset rows align with checkbox & radio rows, destructive recolors 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:

src/components/context-menu.styleframe.ts
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:

src/components/ContextMenu.tsx
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

ColorTokenUse Case
light@color.white / @color.gray-*Light surfaces, stays light in dark mode
dark@color.gray-900Dark surfaces, stays dark in light mode
neutralAdaptive (light ↔ dark)Default color, adapts to the current color scheme
Pro tip: Use 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

SizePanel PaddingPanel RadiusItem 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
Good to know: The 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:

PartRecipeRole
PaneluseContextMenuRecipe()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
ItemuseContextMenuItemRecipe()Clickable row with hover, focus, active, and disabled states; inset reserves a leading gutter and destructive recolors high-risk actions
SeparatoruseContextMenuSeparatorRecipe()Horizontal divider between row groups
LabeluseContextMenuLabelRecipe()Uppercase group heading with muted text color and an inset option
ShortcutuseContextMenuShortcutRecipe()Trailing, muted keyboard-hint text pushed to the row's far edge
Sub-triggeruseContextMenuSubTriggerRecipe()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.

Pro tip: You don't have to use every part. A context menu with only items is valid for simple action lists. Add labels to name a group, separators to break groups apart, shortcuts to surface keyboard accelerators, and sub-triggers when a row opens a nested menu.

Accessibility

  • Use the right menu roles. The panel uses role="menu", plain rows use role="menuitem", checkbox rows use role="menuitemcheckbox" with aria-checked, and radio rows use role="menuitemradio" with aria-checked. The separator uses role="separator" and the label uses role="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 contextmenu event (and call preventDefault()), and also open it via the keyboard Menu key or Shift+F10 on the focused element.
  • Implement keyboard navigation. The menu must support ArrowUp / ArrowDown to move between rows, ArrowRight / ArrowLeft to open / close submenus, Home / End to jump to the first / last row, Enter or Space to activate the focused row, and Escape to close the menu and return focus to the originating element.
  • Mark submenu triggers. A sub-trigger needs aria-haspopup="true" and aria-expanded toggling between "true" and "false"; pass the same boolean to the recipe's open axis 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 use tabindex="-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 &:disabled styles apply cursor: not-allowed, opacity: 0.75, and pointer-events: none.
  • Don't rely on color alone for destructive rows. The destructive style 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:

src/components/context-menu.styleframe.ts
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:

src/components/context-menu.styleframe.ts
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;
Good to know: Filtering removes color/variant compound variants that reference filtered-out values and adjusts default variants accordingly. The standalone 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:

ParameterTypeDescription
sStyleframeThe Styleframe instance
optionsDeepPartial<RecipeConfig>Optional overrides for the recipe configuration
options.baseVariantDeclarationsBlockCustom base styles for the panel
options.variantsVariantsCustom variant definitions for the recipe
options.defaultVariantsRecord<keyof Variants, string>Default variant values for the recipe
options.compoundVariantsCompoundVariant[]Custom compound variant definitions for the recipe
options.filterRecord<string, string[]>Limit which variant values are generated

Variants:

VariantOptionsDefault
colorlight, dark, neutralneutral
variantsolid, soft, subtlesolid
sizesm, md, lgmd

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:

VariantOptionsDefault
colorlight, dark, neutralneutral
variantsolid, soft, subtlesolid
sizesm, md, lgmd
insettrue, falsefalse
destructivetrue, falsefalse

useContextMenuSeparatorRecipe(s, options?)

Creates the separator recipe for dividers between row groups. Accepts only the color axis.

Variants:

VariantOptionsDefault
colorlight, dark, neutralneutral

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:

VariantOptionsDefault
colorlight, dark, neutralneutral
sizesm, md, lgmd
insettrue, falsefalse

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:

VariantOptionsDefault
colorlight, dark, neutralneutral
sizesm, md, lgmd

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:

VariantOptionsDefault
colorlight, dark, neutralneutral
variantsolid, soft, subtlesolid
sizesm, md, lgmd
opentrue, falsefalse

Learn more about recipes →

Best Practices

  • Pass color and variant consistently: The panel, each row, and the sub-trigger need the same color and variant values so hover, focus, and open backgrounds render correctly against the surrounding surface.
  • Use inset when 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 destructive for 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 ContextMenuShortcut at the end of an item to surface its keyboard accelerator; keep the hint text short (e.g. "⌘C").
  • Use neutral for 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 filter option to reduce generated CSS.
  • Override defaults at the recipe level: Set your most common variant combination as defaultVariants so component consumers write less code.

FAQ