Context Menu

Displays a menu of actions triggered by a right click.

Basic
Right click here
With Icons
Right click here
With Sides
inline start
left
top
bottom
right
inline end
With Shortcuts
Right click here
With Submenu
Right click here
With Groups, Labels & Separators
Right click here
With Checkboxes
Right click here
With Radio Group
Right click here
With Destructive Items
Right click here
In Dialog
With Inset
Right click here

Installation

Install the dependencies

pnpm add cn github:sveltery/base

Copy the component

Copy the following files into src/lib/components/ui/context-menu.

src/lib/components/ui/context-menu/ContextMenuCheckboxItem.svelte
<script lang="ts">
  import type { ComponentProps } from 'svelte';
  import { ContextMenu as Primitive } from '@sveltery/base/context-menu';
  import { cn } from 'cn';
  import CheckIcon from '@lucide/svelte/icons/check';
  let { children, class: classProp, checked = $bindable(false), inset, ...props }: ComponentProps<typeof Primitive.CheckboxItem> & { inset?: boolean } = $props();
</script>
<Primitive.CheckboxItem
  data-slot="context-menu-checkbox-item"
  data-inset={inset}
  bind:checked
  {...props}
  class={cn("focus:bg-accent focus:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0", classProp)}
>
  <span class="absolute right-2 pointer-events-none">
    <Primitive.CheckboxItemIndicator>
      <CheckIcon />
    </Primitive.CheckboxItemIndicator>
  </span>
  {@render children?.()}
</Primitive.CheckboxItem>
src/lib/components/ui/context-menu/ContextMenuContent.svelte
<script lang="ts">
  import type { ComponentProps } from 'svelte';
  import { ContextMenu as Primitive } from '@sveltery/base/context-menu';
  import { cn } from 'cn';
  let {
    children,
    class: classProp,
    align = 'start',
    alignOffset = 4,
    side = 'right',
    sideOffset = 0,
    ...props
  }: ComponentProps<typeof Primitive.Popup> & Pick<ComponentProps<typeof Primitive.Positioner>, 'align' | 'alignOffset' | 'side' | 'sideOffset'> = $props();
</script>
<Primitive.Portal>
  <Primitive.Positioner {align} {alignOffset} {side} {sideOffset} class="isolate z-50 outline-none">
    <Primitive.Popup data-slot="context-menu-content" {...props} class={cn('data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-36 rounded-lg p-1 shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-50 max-h-(--available-height) origin-(--transform-origin) overflow-x-hidden overflow-y-auto outline-none', classProp)}>
      {@render children?.()}
    </Primitive.Popup>
  </Primitive.Positioner>
</Primitive.Portal>
src/lib/components/ui/context-menu/ContextMenuGroup.svelte
<script lang="ts">
  import type { ComponentProps } from 'svelte';
  import { ContextMenu as Primitive } from '@sveltery/base/context-menu';
  let { children, ...props }: ComponentProps<typeof Primitive.Group> = $props();
</script>
<Primitive.Group data-slot="context-menu-group" {children} {...props} />
src/lib/components/ui/context-menu/ContextMenuItem.svelte
<script lang="ts">
  import type { ComponentProps } from 'svelte';
  import { ContextMenu as Primitive } from '@sveltery/base/context-menu';
  import { cn } from 'cn';
  let {
    children,
    class: classProp,
    inset,
    variant = 'default',
    ...props
  }: ComponentProps<typeof Primitive.Item> & { inset?: boolean; variant?: 'default' | 'destructive' } = $props();
</script>
<Primitive.Item
  data-slot="context-menu-item"
  data-inset={inset}
  data-variant={variant}
  {children}
  {...props}
  class={cn("focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive focus:*:[svg]:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 group/context-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0", classProp)}
/>
src/lib/components/ui/context-menu/ContextMenuLabel.svelte
<script lang="ts">
  import type { ComponentProps } from 'svelte';
  import { ContextMenu as Primitive } from '@sveltery/base/context-menu';
  import { cn } from 'cn';
  let { children, class: classProp, inset, ...props }: ComponentProps<typeof Primitive.GroupLabel> & { inset?: boolean } = $props();
</script>
<Primitive.GroupLabel data-slot="context-menu-label" data-inset={inset} {children} {...props} class={cn('text-muted-foreground px-1.5 py-1 text-xs font-medium data-inset:pl-7', classProp)} />
src/lib/components/ui/context-menu/ContextMenuPortal.svelte
<script lang="ts">
  import type { ComponentProps } from 'svelte';
  import { ContextMenu as Primitive } from '@sveltery/base/context-menu';
  let { children, ...props }: ComponentProps<typeof Primitive.Portal> = $props();
</script>
<Primitive.Portal data-slot="context-menu-portal" {...props}>
  {@render children?.()}
</Primitive.Portal>
src/lib/components/ui/context-menu/ContextMenuRadioGroup.svelte
<script lang="ts">
  import type { ComponentProps } from 'svelte';
  import { ContextMenu as Primitive } from '@sveltery/base/context-menu';
  let { children, value = $bindable(), ...props }: ComponentProps<typeof Primitive.RadioGroup> = $props();
</script>
<Primitive.RadioGroup data-slot="context-menu-radio-group" bind:value {children} {...props} />
src/lib/components/ui/context-menu/ContextMenuRadioItem.svelte
<script lang="ts">
  import type { ComponentProps } from 'svelte';
  import { ContextMenu as Primitive } from '@sveltery/base/context-menu';
  import { cn } from 'cn';
  import CheckIcon from '@lucide/svelte/icons/check';
  let { children, class: classProp, inset, ...props }: ComponentProps<typeof Primitive.RadioItem> & { inset?: boolean } = $props();
</script>
<Primitive.RadioItem
  data-slot="context-menu-radio-item"
  data-inset={inset}
  {...props}
  class={cn("focus:bg-accent focus:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0", classProp)}
>
  <span class="absolute right-2 pointer-events-none">
    <Primitive.RadioItemIndicator>
      <CheckIcon />
    </Primitive.RadioItemIndicator>
  </span>
  {@render children?.()}
</Primitive.RadioItem>
src/lib/components/ui/context-menu/ContextMenuSeparator.svelte
<script lang="ts">
  import type { ComponentProps } from 'svelte';
  import { ContextMenu as Primitive } from '@sveltery/base/context-menu';
  import { cn } from 'cn';
  let { class: classProp, ...props }: ComponentProps<typeof Primitive.Separator> = $props();
</script>
<Primitive.Separator data-slot="context-menu-separator" {...props} class={cn('bg-border -mx-1 my-1 h-px', classProp)} />
src/lib/components/ui/context-menu/ContextMenuShortcut.svelte
<script lang="ts">
  import type { HTMLAttributes } from 'svelte/elements';
  import { cn } from 'cn';
  let { children, class: classProp, ...props }: HTMLAttributes<HTMLSpanElement> = $props();
</script>
<span data-slot="context-menu-shortcut" {...props} class={cn('text-muted-foreground group-focus/context-menu-item:text-accent-foreground ml-auto text-xs tracking-widest', classProp)}>
  {@render children?.()}
</span>
src/lib/components/ui/context-menu/ContextMenuSubContent.svelte
<script lang="ts">
  import type { ComponentProps } from 'svelte';
  import ContextMenuContent from './ContextMenuContent.svelte';
  let props: ComponentProps<typeof ContextMenuContent> = $props();
</script>
<ContextMenuContent data-slot="context-menu-sub-content" class="shadow-lg" side="right" {...props} />
src/lib/components/ui/context-menu/ContextMenuSubTrigger.svelte
<script lang="ts">
  import type { ComponentProps } from 'svelte';
  import { ContextMenu as Primitive } from '@sveltery/base/context-menu';
  import { cn } from 'cn';
  import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
  let { children, class: classProp, inset, ...props }: ComponentProps<typeof Primitive.SubmenuTrigger> & { inset?: boolean } = $props();
</script>
<Primitive.SubmenuTrigger
  data-slot="context-menu-sub-trigger"
  data-inset={inset}
  {...props}
  class={cn("focus:bg-accent focus:text-accent-foreground data-popup-open:bg-accent data-popup-open:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 flex cursor-default items-center outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0", classProp)}
>
  {@render children?.()}
  <ChevronRightIcon class="ml-auto" />
</Primitive.SubmenuTrigger>
src/lib/components/ui/context-menu/ContextMenuTrigger.svelte
<script lang="ts">
  import type { ComponentProps } from 'svelte';
  import { ContextMenu as Primitive } from '@sveltery/base/context-menu';
  import { cn } from 'cn';
  let { children, class: classProp, ...props }: ComponentProps<typeof Primitive.Trigger> = $props();
</script>
<Primitive.Trigger data-slot="context-menu-trigger" {children} {...props} class={cn('select-none', classProp)} />
src/lib/components/ui/context-menu/index.ts
import { ContextMenu as Primitive } from '@sveltery/base/context-menu';

export const ContextMenu = Primitive.Root;
export const ContextMenuSub = Primitive.SubmenuRoot;
export { default as ContextMenuPortal } from './ContextMenuPortal.svelte';
export { default as ContextMenuTrigger } from './ContextMenuTrigger.svelte';
export { default as ContextMenuContent } from './ContextMenuContent.svelte';
export { default as ContextMenuGroup } from './ContextMenuGroup.svelte';
export { default as ContextMenuLabel } from './ContextMenuLabel.svelte';
export { default as ContextMenuItem } from './ContextMenuItem.svelte';
export { default as ContextMenuCheckboxItem } from './ContextMenuCheckboxItem.svelte';
export { default as ContextMenuRadioGroup } from './ContextMenuRadioGroup.svelte';
export { default as ContextMenuRadioItem } from './ContextMenuRadioItem.svelte';
export { default as ContextMenuSeparator } from './ContextMenuSeparator.svelte';
export { default as ContextMenuShortcut } from './ContextMenuShortcut.svelte';
export { default as ContextMenuSubTrigger } from './ContextMenuSubTrigger.svelte';
export { default as ContextMenuSubContent } from './ContextMenuSubContent.svelte';

Update the import paths

Change the imports to match your project setup.

Usage

<script lang="ts">
  import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '$lib/components/ui/context-menu';
</script>
<ContextMenu>
  <ContextMenuTrigger>Right click here</ContextMenuTrigger>
  <ContextMenuContent>
    <ContextMenuItem>Profile</ContextMenuItem>
    <ContextMenuItem>Billing</ContextMenuItem>
    <ContextMenuItem>Team</ContextMenuItem>
    <ContextMenuItem>Subscription</ContextMenuItem>
  </ContextMenuContent>
</ContextMenu>

The menu opens at the pointer on a right click, or on a long press on touch screens. The arrow keys move the highlight, Enter or a click activates an item and closes the menu, and Escape or a press outside closes it. Bind open on ContextMenu to read or change the state from outside.

Composition

Use the following composition to build a ContextMenu.

ContextMenu
├── ContextMenuTrigger
└── ContextMenuContent
    ├── ContextMenuGroup
    │   ├── ContextMenuLabel
    │   ├── ContextMenuItem
    │   └── ContextMenuItem
    ├── ContextMenuSeparator
    ├── ContextMenuGroup
    │   ├── ContextMenuLabel
    │   ├── ContextMenuCheckboxItem
    │   └── ContextMenuCheckboxItem
    ├── ContextMenuSeparator
    ├── ContextMenuGroup
    │   ├── ContextMenuLabel
    │   └── ContextMenuRadioGroup
    │       ├── ContextMenuRadioItem
    │       └── ContextMenuRadioItem
    └── ContextMenuSub
        ├── ContextMenuSubTrigger
        └── ContextMenuSubContent
            └── ContextMenuGroup
                ├── ContextMenuItem
                └── ContextMenuItem

Checkboxes and radio groups

Bind checked on ContextMenuCheckboxItem and value on ContextMenuRadioGroup. Neither closes the menu when picked.

<script lang="ts">
  let showBookmarksBar = $state(true);
  let theme = $state('light');
</script>

<ContextMenuContent>
  <ContextMenuCheckboxItem bind:checked={showBookmarksBar}>Show Bookmarks Bar</ContextMenuCheckboxItem>
  <ContextMenuSeparator />
  <ContextMenuRadioGroup bind:value={theme}>
    <ContextMenuRadioItem value="light">Light</ContextMenuRadioItem>
    <ContextMenuRadioItem value="dark">Dark</ContextMenuRadioItem>
  </ContextMenuRadioGroup>
</ContextMenuContent>

Wrap a ContextMenuSubTrigger and a ContextMenuSubContent in ContextMenuSub. The submenu opens on hover, on Enter and on the arrow key toward its side, and the opposite arrow key closes it.

<ContextMenuSub>
  <ContextMenuSubTrigger>More Tools</ContextMenuSubTrigger>
  <ContextMenuSubContent>
    <ContextMenuItem>Save Page...</ContextMenuItem>
    <ContextMenuItem>Developer Tools</ContextMenuItem>
  </ContextMenuSubContent>
</ContextMenuSub>

Destructive items

Set variant="destructive" on ContextMenuItem to style an item as destructive.

Sides

Pass side to ContextMenuContent to choose where the menu sits against the pointer. It defaults to right. Use inline-start or inline-end to follow the text direction, which flips them in right-to-left layouts.

<ContextMenu>
  <ContextMenuTrigger>Right click here</ContextMenuTrigger>
  <ContextMenuContent side="inline-end">
    <ContextMenuItem>Profile</ContextMenuItem>
    <ContextMenuItem>Billing</ContextMenuItem>
  </ContextMenuContent>
</ContextMenu>

Trigger

ContextMenuTrigger renders a <div> that covers the area to listen on. Pass class to size and style it. To use another element, pass a render snippet and spread its props onto that element.

API reference

ContextMenu is styled on top of the Sveltery Base ContextMenu primitive, which owns pointer anchoring, long press, keyboard navigation, typeahead, focus handling and dismissal. ContextMenuContent renders the Base portal, positioner and popup, and takes the positioner's side, align, sideOffset and alignOffset props. ContextMenuItem, ContextMenuCheckboxItem, ContextMenuRadioItem, ContextMenuLabel and ContextMenuSubTrigger take inset, and ContextMenuItem also takes variant (default or destructive). See the Base UI Context Menu documentation for the behavior it ports.