Installation
Install the dependencies
pnpm add cn github:sveltery/baseCopy the component
Copy the following files into src/lib/components/ui/context-menu.
<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><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><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} /><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)}
/><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)} /><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><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} /><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><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)} /><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><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} /><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><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)} />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
└── ContextMenuItemCheckboxes 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>Submenus
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.