diff --git a/apps/www/src/components/playground/context-menu-examples.tsx b/apps/www/src/components/playground/context-menu-examples.tsx
new file mode 100644
index 000000000..1e3a69ff3
--- /dev/null
+++ b/apps/www/src/components/playground/context-menu-examples.tsx
@@ -0,0 +1,73 @@
+'use client';
+
+import { ContextMenu, Flex, Text } from '@raystack/apsara';
+import PlaygroundLayout from './playground-layout';
+
+export function ContextMenuExamples() {
+ return (
+
+
+
+
+ Right click here
+
+
+ Profile
+ Settings
+
+ Logout
+
+
+
+
+ With icons
+
+
+ 📝>}>Edit
+ 📋>} trailingIcon={<>⌘C>}>
+ Copy
+
+
+ 🗑️>}>Delete
+
+
+
+
+ With groups
+
+
+
+ Actions
+ New File
+ New Folder
+
+
+
+ Sort By
+ Name
+ Date
+
+
+
+
+
+ );
+}
diff --git a/apps/www/src/components/playground/index.ts b/apps/www/src/components/playground/index.ts
index 884a9271b..2b99f2041 100644
--- a/apps/www/src/components/playground/index.ts
+++ b/apps/www/src/components/playground/index.ts
@@ -13,6 +13,7 @@ export * from './collapsible-examples';
export * from './combobox-examples';
export * from './command-examples';
export * from './container-examples';
+export * from './context-menu-examples';
export * from './data-table-examples';
export * from './dialog-examples';
export * from './drawer-examples';
diff --git a/apps/www/src/content/docs/components/context-menu/demo.ts b/apps/www/src/content/docs/components/context-menu/demo.ts
new file mode 100644
index 000000000..9643b2f17
--- /dev/null
+++ b/apps/www/src/content/docs/components/context-menu/demo.ts
@@ -0,0 +1,214 @@
+'use client';
+
+import { getPropsString } from '@/lib/utils';
+
+export const getCode = (props: any) => {
+ const contentProps = props.autocomplete
+ ? ' searchPlaceholder="Search..."'
+ : '';
+ return `
+
+
+ Right click here
+
+
+
+ Assign member...
+ Subscribe...
+ Rename...
+
+
+
+ Actions
+
+
+ Export
+
+
+ CSV
+ PDF
+
+
+ Copy
+
+ ⌘⇧D
+
+ }>
+ Delete...
+
+
+
+ `;
+};
+
+export const playground = {
+ type: 'playground',
+ controls: {
+ autocomplete: {
+ type: 'checkbox',
+ defaultValue: false
+ }
+ },
+ getCode
+};
+
+export const basicDemo = {
+ type: 'code',
+ code: `
+
+
+ Right click here
+
+
+ Profile
+ Settings
+
+ Logout
+
+ `
+};
+
+export const iconsDemo = {
+ type: 'code',
+ code: `
+
+
+ Right click here
+
+
+ 📝>}>Edit
+ 📋>} trailingIcon={<>⌘C>}>Copy
+
+ 🗑️>}>Delete
+
+ `
+};
+
+export const customDemo = {
+ type: 'code',
+ code: `
+
+
+ Right click here
+
+
+
+ Actions
+ New File
+ New Folder
+
+
+
+ Sort By
+ Name
+ Date
+
+
+ `
+};
+
+export const submenuDemo = {
+ type: 'code',
+ code: `
+
+
+ Right click here
+
+
+ Profile
+ Settings
+
+ Logout
+
+ Sub Menu
+
+ Sub Menu Item 1
+ Sub Menu Item 2
+ Sub Menu Item 3
+
+
+
+ `
+};
+
+export const autocompleteDemo = {
+ type: 'code',
+ tabs: [
+ {
+ name: 'Default Autocomplete',
+ code: `
+
+
+ Right click here
+
+
+
+ Heading
+ Assign member...
+ Subscribe...
+ Rename...
+
+
+
+ Actions
+
+ Export
+
+ All (.zip)
+
+ CSV
+
+ All
+ 3 Months
+ 6 Months
+
+
+
+ PDF
+
+ All
+ 3 Months
+ 6 Months
+
+
+
+
+ Custom Label
+
+ ⌘⇧D
+
+ }>
+ Delete...
+
+
+
+ `
+ },
+ {
+ name: 'Searchable Submenu',
+ code: `
+
+
+ Right click here
+
+
+ Copy
+ Delete...
+
+ Sub Menu
+
+ Sub Menu Item 1
+ Sub Menu Item 2
+ Sub Menu Item 3
+
+
+
+ `
+ }
+ ]
+};
diff --git a/apps/www/src/content/docs/components/context-menu/index.mdx b/apps/www/src/content/docs/components/context-menu/index.mdx
new file mode 100644
index 000000000..d570dfbec
--- /dev/null
+++ b/apps/www/src/content/docs/components/context-menu/index.mdx
@@ -0,0 +1,135 @@
+---
+title: Context Menu
+description: Displays a menu activated by right-click or long press, such as a set of actions or functions.
+source: packages/raystack/components/context-menu
+tag: new
+---
+
+import {
+ playground,
+ basicDemo,
+ iconsDemo,
+ customDemo,
+ submenuDemo,
+ autocompleteDemo,
+} from "./demo.ts";
+
+
+
+## Usage
+
+```tsx
+import { ContextMenu } from '@raystack/apsara'
+```
+
+## API Reference
+
+The ContextMenu component is composed of several parts, each with their own props.
+
+### Root
+
+The root element is the parent component that holds the context menu. Using the `autocomplete` prop, you can enable search functionality.
+
+
+
+### Trigger
+
+The area that opens the context menu on right-click or long press. Renders a `
` element.
+
+
+
+### Content
+
+The container that holds the menu items. When autocomplete is enabled, renders an inline search input inside the popup.
+
+
+
+### Item
+
+Individual clickable options within the menu. Built on top of [Base UI ContextMenu.Item](https://base-ui.com/react/components/context-menu).
+
+Renders as a `role='option'` when used in an autocomplete menu. By default, the item's `children` text content is used for matching, which can be overridden by passing a `value` prop.
+
+
+
+### Group
+
+A way to group related menu items together. When filtering is active, the group wrapper is removed and items are rendered flat.
+
+
+
+### Label
+
+Renders a label in a menu group. This component should be wrapped with ContextMenu.Group so the `aria-labelledby` is correctly set on the group element. Hidden when filtering is active.
+
+
+
+### Separator
+
+Visual divider between menu items or groups. Hidden when filtering is active.
+
+
+
+### EmptyState
+
+Placeholder content when there are no menu items to display.
+
+
+
+### Submenu
+
+Wraps a submenu root. Use with `ContextMenu.SubmenuTrigger` and `ContextMenu.SubmenuContent` to create nested menus.
+
+Supports its own `autocomplete` prop to enable search functionality within the submenu independently from the parent menu.
+
+
+
+### SubmenuTrigger
+
+The trigger item for a submenu. Renders with a trailing chevron icon by default. Accepts `leadingIcon` and `trailingIcon` props.
+
+When inside a searchable parent menu, the `value` prop can be used for autocomplete matching.
+
+
+
+### SubmenuContent
+
+The content container for a submenu. Shares the same API as `ContextMenu.Content` with a default `sideOffset` of `2`.
+
+
+
+## Examples
+
+### Basic Usage
+
+A simple context menu with basic functionality. Right-click the trigger area to open.
+
+
+
+### With Icons
+
+You can add icons to the menu items. Supports both leading and trailing icons.
+
+
+
+### With Groups and Labels
+
+Organize related menu items into sections with descriptive headers.
+
+
+
+### Submenu
+
+Use `ContextMenu.Submenu`, `ContextMenu.SubmenuTrigger`, and `ContextMenu.SubmenuContent` to create nested menus with multiple levels.
+
+
+
+### Autocomplete
+
+To enable autocomplete, pass the `autocomplete` prop to the ContextMenu root element. Each menu instance will manage its own autocomplete behavior.
+
+By default (`autocompleteMode="auto"`), items are automatically filtered as the user types. The filter matches against the item's `value` prop or its `children` text content.
+
+For submenus, you can independently enable autocomplete by passing `autocomplete` to `ContextMenu.Submenu`.
+
+
diff --git a/apps/www/src/content/docs/components/context-menu/props.ts b/apps/www/src/content/docs/components/context-menu/props.ts
new file mode 100644
index 000000000..fc51c9856
--- /dev/null
+++ b/apps/www/src/content/docs/components/context-menu/props.ts
@@ -0,0 +1,209 @@
+import { CSSProperties, ReactElement, ReactNode } from 'react';
+
+export interface ContextMenuRootProps {
+ /** Enables search functionality within the menu */
+ autocomplete?: boolean;
+
+ /** Controls the autocomplete behavior mode
+ * - "auto": Automatically filters items as user types
+ * - "manual": Requires explicit filtering through onInputValueChange callback
+ * @default "auto"
+ */
+ autocompleteMode?: 'auto' | 'manual';
+
+ /** Current search input value (controlled) */
+ inputValue?: string;
+
+ /** Initial search input value (uncontrolled)
+ * @default ""
+ */
+ defaultInputValue?: string;
+
+ /** Callback fired when the search input value changes */
+ onInputValueChange?: (value: string) => void;
+
+ /** Control the open state of the menu */
+ open?: boolean;
+
+ /** Whether the menu is open by default (uncontrolled)
+ * @default false
+ */
+ defaultOpen?: boolean;
+
+ /** Callback fired when the menu is opened or closed */
+ onOpenChange?: (open: boolean) => void;
+
+ /** Whether the menu should loop focus when navigating with keyboard
+ * @default false
+ */
+ loopFocus?: boolean;
+
+ /** Whether the component should ignore user interaction
+ * @default false
+ */
+ disabled?: boolean;
+}
+
+export interface ContextMenuTriggerProps {
+ /** Render a custom element as the trigger using Base UI's render prop pattern */
+ render?: ReactElement;
+
+ /** Additional CSS class names */
+ className?: string;
+}
+
+export interface ContextMenuContentProps {
+ /** Placeholder text for the autocomplete search input
+ * @default "Search..."
+ */
+ searchPlaceholder?: string;
+
+ /**
+ * The distance between the popup and the anchor element.
+ * @default 4
+ */
+ sideOffset?: number;
+
+ /**
+ * The side of the anchor element to place the popup.
+ * @default "bottom"
+ */
+ side?: 'top' | 'bottom' | 'left' | 'right';
+
+ /**
+ * The alignment of the popup relative to the anchor element.
+ * @default "start"
+ */
+ align?: 'start' | 'center' | 'end';
+
+ /** Render a custom element using Base UI's render prop pattern */
+ render?: ReactElement;
+
+ /** Inline styles */
+ style?: CSSProperties;
+
+ /** Additional CSS class names */
+ className?: string;
+}
+
+export interface ContextMenuItemProps {
+ /** Icon element to display before item text */
+ leadingIcon?: ReactNode;
+
+ /** Icon element to display after item text */
+ trailingIcon?: ReactNode;
+
+ /** Whether the item is disabled */
+ disabled?: boolean;
+
+ /** Value of the item used for autocomplete matching. If not provided, `children` text content is used. */
+ value?: string;
+
+ /** Additional CSS class names */
+ className?: string;
+
+ /** Render a custom element using Base UI's render prop pattern */
+ render?: ReactElement;
+}
+
+export interface ContextMenuGroupProps {
+ /** Additional CSS class names */
+ className?: string;
+}
+
+export interface ContextMenuLabelProps {
+ /** Additional CSS class names */
+ className?: string;
+}
+
+export interface ContextMenuSeparatorProps {
+ /** Additional CSS class names */
+ className?: string;
+}
+
+export interface ContextMenuEmptyStateProps {
+ /** React nodes to render in empty state */
+ children?: ReactNode;
+
+ /** Additional CSS class names */
+ className?: string;
+}
+
+export interface ContextMenuSubMenuProps {
+ /** Enables search functionality within the submenu */
+ autocomplete?: boolean;
+
+ /** Controls the autocomplete behavior mode for the submenu
+ * - "auto": Automatically filters items as user types
+ * - "manual": Requires explicit filtering through onInputValueChange callback
+ * @default "auto"
+ */
+ autocompleteMode?: 'auto' | 'manual';
+
+ /** Current search input value (controlled) */
+ inputValue?: string;
+
+ /** Initial search input value (uncontrolled)
+ * @default ""
+ */
+ defaultInputValue?: string;
+
+ /** Callback fired when the search input value changes */
+ onInputValueChange?: (value: string) => void;
+
+ /** Control the open state of the submenu */
+ open?: boolean;
+
+ /** Whether the submenu is open by default (uncontrolled)
+ * @default false
+ */
+ defaultOpen?: boolean;
+
+ /** Callback fired when the submenu is opened or closed */
+ onOpenChange?: (open: boolean) => void;
+}
+
+export interface ContextMenuSubTriggerProps {
+ /** Icon element to display before trigger text */
+ leadingIcon?: ReactNode;
+
+ /** Icon element to display after trigger text. Defaults to a chevron right icon. */
+ trailingIcon?: ReactNode;
+
+ /** Value used for autocomplete matching when inside a searchable parent menu */
+ value?: string;
+}
+
+export interface ContextMenuSubContentProps {
+ /** Placeholder text for the autocomplete search input
+ * @default "Search..."
+ */
+ searchPlaceholder?: string;
+
+ /**
+ * The distance between the popup and the anchor element.
+ * @default 2
+ */
+ sideOffset?: number;
+
+ /**
+ * The side of the anchor element to place the popup.
+ * @default "bottom"
+ */
+ side?: 'top' | 'bottom' | 'left' | 'right';
+
+ /**
+ * The alignment of the popup relative to the anchor element.
+ * @default "start"
+ */
+ align?: 'start' | 'center' | 'end';
+
+ /** Render a custom element using Base UI's render prop pattern */
+ render?: ReactElement;
+
+ /** Inline styles */
+ style?: CSSProperties;
+
+ /** Additional CSS class names */
+ className?: string;
+}
diff --git a/packages/raystack/components/context-menu/__tests__/context-menu.test.tsx b/packages/raystack/components/context-menu/__tests__/context-menu.test.tsx
new file mode 100644
index 000000000..bb7e2b360
--- /dev/null
+++ b/packages/raystack/components/context-menu/__tests__/context-menu.test.tsx
@@ -0,0 +1,147 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { ReactNode } from 'react';
+import { describe, expect, it, vi } from 'vitest';
+import { ContextMenu } from '../context-menu';
+
+// Mock scrollIntoView for test environment
+Object.defineProperty(Element.prototype, 'scrollIntoView', {
+ value: vi.fn(),
+ writable: true
+});
+
+// String constants
+const TRIGGER_TEXT = 'Right click here';
+const MENU_ITEMS = [
+ { id: 'profile', label: 'Profile' },
+ { id: 'settings', label: 'Settings' },
+ { id: 'billing', label: 'Billing' },
+ { id: 'team', label: 'Team' },
+ { id: 'logout', label: 'Logout' }
+];
+
+interface BasicContextMenuProps {
+ onClick?: (value: string) => void;
+ onOpenChange?: (open: boolean) => void;
+ children?: ReactNode;
+}
+
+const BasicContextMenu = ({
+ onClick,
+ children,
+ ...props
+}: BasicContextMenuProps) => {
+ return (
+
+ {TRIGGER_TEXT}
+
+ {MENU_ITEMS.map(item => (
+ onClick?.(item.id)}>
+ {item.label}
+
+ ))}
+ {children}
+
+
+ );
+};
+
+const renderAndOpenContextMenu = async (element: React.ReactElement) => {
+ const { getByText } = render(element);
+ const trigger = getByText(TRIGGER_TEXT);
+ await fireEvent.contextMenu(trigger);
+};
+
+describe('ContextMenu', () => {
+ describe('Basic Rendering', () => {
+ it('renders trigger', () => {
+ render(
);
+ expect(screen.getByText(TRIGGER_TEXT)).toBeInTheDocument();
+ });
+
+ it('renders with custom className on trigger', () => {
+ render(
+
+
+ Custom Trigger
+
+
+ Menu Item
+
+
+ );
+
+ const trigger = screen.getByText('Custom Trigger');
+ expect(trigger).toHaveClass('custom-trigger');
+ });
+
+ it('does not show content initially', () => {
+ render(
);
+ MENU_ITEMS.forEach(item => {
+ expect(screen.queryByText(item.label)).not.toBeInTheDocument();
+ });
+ });
+
+ it('shows content when right-clicked', async () => {
+ await renderAndOpenContextMenu(
);
+
+ expect(screen.getByRole('menu')).toBeInTheDocument();
+ MENU_ITEMS.forEach(item => {
+ expect(screen.getByText(item.label)).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('Trigger Interaction', () => {
+ it('opens menu on right-click (contextmenu event)', async () => {
+ await renderAndOpenContextMenu(
);
+
+ expect(screen.getByRole('menu')).toBeInTheDocument();
+ expect(screen.getByText(MENU_ITEMS[0].label)).toBeInTheDocument();
+ });
+ });
+
+ describe('Menu Items', () => {
+ it('handles item clicks with onClick', async () => {
+ const onClick = vi.fn();
+
+ await renderAndOpenContextMenu(
);
+
+ const item = screen.getByText(MENU_ITEMS[0].label);
+ fireEvent.click(item);
+
+ expect(onClick).toHaveBeenCalled();
+ });
+
+ it('supports disabled items', async () => {
+ const onClick = vi.fn();
+
+ await renderAndOpenContextMenu(
+
+
+ Disabled Item
+
+
+ );
+
+ const disabledItem = screen.getByTestId('disabled-item');
+ expect(disabledItem).toHaveAttribute('aria-disabled', 'true');
+ });
+ });
+
+ describe('Controlled State', () => {
+ it('calls onOpenChange when state changes', async () => {
+ const onOpenChange = vi.fn();
+
+ render(
);
+
+ const trigger = screen.getByText(TRIGGER_TEXT);
+ fireEvent.contextMenu(trigger);
+
+ expect(onOpenChange).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/packages/raystack/components/context-menu/context-menu-content.tsx b/packages/raystack/components/context-menu/context-menu-content.tsx
new file mode 100644
index 000000000..bc869a638
--- /dev/null
+++ b/packages/raystack/components/context-menu/context-menu-content.tsx
@@ -0,0 +1,202 @@
+'use client';
+
+import {
+ Autocomplete as AutocompletePrimitive,
+ ContextMenu as ContextMenuPrimitive
+} from '@base-ui/react';
+import { cx } from 'class-variance-authority';
+import { forwardRef, KeyboardEvent, useCallback, useRef } from 'react';
+import styles from '../menu/menu.module.css';
+import { useMenuContext } from '../menu/menu-root';
+import {
+ dispatchKeyboardEvent,
+ isElementSubMenuOpen,
+ isElementSubMenuTrigger,
+ KEYCODES
+} from '../menu/utils';
+
+export interface ContextMenuContentProps
+ extends Omit<
+ ContextMenuPrimitive.Positioner.Props,
+ 'render' | 'className' | 'style'
+ >,
+ ContextMenuPrimitive.Popup.Props {
+ searchPlaceholder?: string;
+}
+
+export const ContextMenuContent = forwardRef<
+ HTMLDivElement,
+ ContextMenuContentProps
+>(
+ (
+ {
+ className,
+ children,
+ searchPlaceholder = 'Search...',
+ render,
+ finalFocus,
+ style,
+ sideOffset = 4,
+ align = 'start',
+ onFocus,
+ ...positionerProps
+ },
+ ref
+ ) => {
+ const {
+ autocomplete,
+ inputValue,
+ onInputValueChange,
+ inputRef,
+ isInitialRender,
+ parent
+ } = useMenuContext();
+
+ const focusInput = useCallback(() => {
+ if (document?.activeElement !== inputRef?.current)
+ inputRef?.current?.focus();
+ }, [inputRef]);
+ const highlightedItem = useRef<
+ [index: number, reason: 'keyboard' | 'pointer' | 'none']
+ >([-1, 'none']);
+ const containerRef = useRef
(null);
+
+ const highlightFirstItem = useCallback(() => {
+ if (!isInitialRender?.current) return;
+ isInitialRender.current = false;
+ const item = containerRef.current?.querySelector('[role="option"]');
+ if (!item) return;
+ item.dispatchEvent(new PointerEvent('mousemove', { bubbles: true }));
+ }, [isInitialRender]);
+
+ const checkAndOpenSubMenu = useCallback(() => {
+ if (highlightedItem.current[0] === -1) return;
+ const items = containerRef.current?.querySelectorAll('[role="option"]');
+ const item = items?.[highlightedItem.current[0]];
+ if (!item || !isElementSubMenuTrigger(item)) return;
+ dispatchKeyboardEvent(item, KEYCODES.ARROW_RIGHT);
+ }, []);
+
+ const checkAndCloseSubMenu = useCallback(
+ (e: KeyboardEvent) => {
+ if (highlightedItem.current[0] === -1) return;
+ const items = containerRef.current?.querySelectorAll('[role="option"]');
+ const item = items?.[highlightedItem.current[0]];
+ if (
+ !item ||
+ !isElementSubMenuTrigger(item) ||
+ !isElementSubMenuOpen(item)
+ )
+ return;
+ dispatchKeyboardEvent(item, KEYCODES.ESCAPE);
+ e.stopPropagation();
+ },
+ []
+ );
+
+ const blurStaleMenuItem = useCallback((index: number) => {
+ const items = containerRef.current?.querySelectorAll('[role="option"]');
+ const item = items?.[index];
+ if (
+ !item ||
+ !isElementSubMenuTrigger(item) ||
+ !isElementSubMenuOpen(item)
+ )
+ return;
+ dispatchKeyboardEvent(item, KEYCODES.ESCAPE);
+ item.dispatchEvent(new PointerEvent('pointerout', { bubbles: true }));
+ }, []);
+
+ return (
+
+
+ {
+ focusInput();
+ e.stopPropagation();
+ highlightFirstItem();
+ onFocus?.(e);
+ }
+ : undefined
+ }
+ >
+ {autocomplete ? (
+ onInputValueChange?.(value)}
+ autoHighlight={!!inputValue?.length}
+ mode='none'
+ loopFocus={false}
+ onItemHighlighted={(value, eventDetails) => {
+ if (
+ highlightedItem.current[1] === 'pointer' &&
+ eventDetails.reason === 'keyboard'
+ ) {
+ blurStaleMenuItem(highlightedItem.current[0]);
+ }
+ highlightedItem.current = [
+ eventDetails.index,
+ eventDetails.reason
+ ];
+ }}
+ >
+ {
+ focusInput();
+ }}
+ onKeyDown={e => {
+ if (e.key === 'ArrowLeft') return;
+ if (e.key === 'Escape') return checkAndCloseSubMenu(e);
+ if (e.key === 'ArrowRight' || e.key === 'Enter')
+ checkAndOpenSubMenu();
+ e.stopPropagation();
+ }}
+ tabIndex={-1}
+ />
+
+ {children}
+
+
+ ) : (
+ children
+ )}
+
+
+
+ );
+ }
+);
+ContextMenuContent.displayName = 'ContextMenu.Content';
+
+export const ContextMenuSubContent = forwardRef<
+ HTMLDivElement,
+ ContextMenuContentProps
+>(({ ...props }, ref) => (
+
+));
+ContextMenuSubContent.displayName = 'ContextMenu.SubContent';
diff --git a/packages/raystack/components/context-menu/context-menu-item.tsx b/packages/raystack/components/context-menu/context-menu-item.tsx
new file mode 100644
index 000000000..ad0d2ea05
--- /dev/null
+++ b/packages/raystack/components/context-menu/context-menu-item.tsx
@@ -0,0 +1,60 @@
+'use client';
+
+import {
+ Autocomplete as AutocompletePrimitive,
+ ContextMenu as ContextMenuPrimitive
+} from '@base-ui/react';
+import { forwardRef } from 'react';
+import { Cell, CellBaseProps } from '../menu/cell';
+import { useMenuContext } from '../menu/menu-root';
+import { getMatch } from '../menu/utils';
+
+export interface ContextMenuItemProps
+ extends ContextMenuPrimitive.Item.Props,
+ CellBaseProps {
+ value?: string;
+}
+
+export const ContextMenuItem = forwardRef(
+ ({ children, value, leadingIcon, trailingIcon, render, ...props }, ref) => {
+ const { autocomplete, inputValue, shouldFilter } = useMenuContext();
+
+ const cell = render ?? (
+ |
+ );
+
+ // In auto mode, hide items that don't match the search value
+ if (shouldFilter && !getMatch(value, children, inputValue)) {
+ return null;
+ }
+
+ if (autocomplete) {
+ return (
+ }
+ {...props}
+ >
+ {children}
+
+ );
+ }
+
+ return (
+ {
+ e.stopPropagation();
+ e.preventDefault();
+ e.preventBaseUIHandler();
+ }}
+ >
+ {children}
+
+ );
+ }
+);
+ContextMenuItem.displayName = 'ContextMenu.Item';
diff --git a/packages/raystack/components/context-menu/context-menu-misc.tsx b/packages/raystack/components/context-menu/context-menu-misc.tsx
new file mode 100644
index 000000000..e5728580f
--- /dev/null
+++ b/packages/raystack/components/context-menu/context-menu-misc.tsx
@@ -0,0 +1,80 @@
+'use client';
+
+import { ContextMenu as ContextMenuPrimitive } from '@base-ui/react';
+import { cx } from 'class-variance-authority';
+import { Fragment, forwardRef, HTMLAttributes, ReactNode } from 'react';
+import styles from '../menu/menu.module.css';
+import { useMenuContext } from '../menu/menu-root';
+
+export type ContextMenuGroupProps = ContextMenuPrimitive.Group.Props;
+export const ContextMenuGroup = forwardRef<
+ HTMLDivElement,
+ ContextMenuGroupProps
+>(({ className, children, ...props }, ref) => {
+ const { shouldFilter } = useMenuContext();
+
+ if (shouldFilter) {
+ return {children};
+ }
+
+ return (
+
+ {children}
+
+ );
+});
+ContextMenuGroup.displayName = 'ContextMenu.Group';
+
+export type ContextMenuLabelProps = ContextMenuPrimitive.GroupLabel.Props;
+export const ContextMenuLabel = forwardRef<
+ HTMLDivElement,
+ ContextMenuLabelProps
+>(({ className, ...props }, ref) => {
+ const { shouldFilter } = useMenuContext();
+
+ if (shouldFilter) {
+ return null;
+ }
+
+ return (
+
+ );
+});
+ContextMenuLabel.displayName = 'ContextMenu.Label';
+
+export const ContextMenuSeparator = forwardRef<
+ HTMLDivElement,
+ HTMLAttributes
+>(({ className, ...props }, ref) => {
+ const { shouldFilter } = useMenuContext();
+
+ if (shouldFilter) {
+ return null;
+ }
+
+ return (
+
+ );
+});
+ContextMenuSeparator.displayName = 'ContextMenu.Separator';
+
+export const ContextMenuEmptyState = forwardRef<
+ HTMLDivElement,
+ HTMLAttributes & {
+ children: ReactNode;
+ }
+>(({ className, children, ...props }, ref) => (
+
+ {children}
+
+));
+ContextMenuEmptyState.displayName = 'ContextMenu.EmptyState';
diff --git a/packages/raystack/components/context-menu/context-menu-root.tsx b/packages/raystack/components/context-menu/context-menu-root.tsx
new file mode 100644
index 000000000..331b9ca55
--- /dev/null
+++ b/packages/raystack/components/context-menu/context-menu-root.tsx
@@ -0,0 +1,189 @@
+'use client';
+
+import { ContextMenu as ContextMenuPrimitive } from '@base-ui/react';
+import { useCallback, useRef, useState } from 'react';
+import { MenuContext, useMenuContext } from '../menu/menu-root';
+
+export interface NormalContextMenuRootProps
+ extends ContextMenuPrimitive.Root.Props {
+ autocomplete?: false;
+ autocompleteMode?: never;
+ inputValue?: never;
+ onInputValueChange?: never;
+ defaultInputValue?: never;
+}
+
+export interface AutocompleteContextMenuRootProps
+ extends ContextMenuPrimitive.Root.Props {
+ autocomplete: true;
+ autocompleteMode?: 'auto' | 'manual';
+ inputValue?: string;
+ onInputValueChange?: (value: string) => void;
+ defaultInputValue?: string;
+}
+
+export type ContextMenuRootProps =
+ | NormalContextMenuRootProps
+ | AutocompleteContextMenuRootProps;
+
+export const ContextMenuRoot = ({
+ autocomplete,
+ autocompleteMode = 'auto',
+ inputValue: providedInputValue,
+ onInputValueChange,
+ defaultInputValue = '',
+ open: providedOpen,
+ onOpenChange,
+ defaultOpen = false,
+ ...props
+}: ContextMenuRootProps) => {
+ const [internalInputValue, setInternalInputValue] =
+ useState(defaultInputValue);
+
+ const [internalOpen, setInternalOpen] = useState(defaultOpen);
+ const open = providedOpen ?? internalOpen;
+ const inputRef = useRef(null);
+ const contentRef = useRef(null);
+ const isInitialRender = useRef(true);
+
+ const inputValue = providedInputValue ?? internalInputValue;
+
+ const setValue = useCallback(
+ (value: string) => {
+ setInternalInputValue(value);
+ onInputValueChange?.(value);
+ },
+ [onInputValueChange]
+ );
+
+ const handleOpenChange: ContextMenuPrimitive.Root.Props['onOpenChange'] =
+ useCallback(
+ (
+ value: boolean,
+ eventDetails: ContextMenuPrimitive.Root.ChangeEventDetails
+ ) => {
+ if (!value && autocomplete) {
+ setValue('');
+ isInitialRender.current = true;
+ }
+ setInternalOpen(value);
+ onOpenChange?.(value, eventDetails);
+ },
+ [onOpenChange, setValue, autocomplete]
+ );
+
+ return (
+
+
+
+ );
+};
+ContextMenuRoot.displayName = 'ContextMenu';
+
+export interface NormalContextMenuSubMenuProps
+ extends ContextMenuPrimitive.SubmenuRoot.Props {
+ autocomplete?: false;
+ autocompleteMode?: never;
+ inputValue?: never;
+ onInputValueChange?: never;
+ defaultInputValue?: never;
+}
+
+export interface AutocompleteContextMenuSubMenuProps
+ extends ContextMenuPrimitive.SubmenuRoot.Props {
+ autocomplete: true;
+ autocompleteMode?: 'auto' | 'manual';
+ inputValue?: string;
+ onInputValueChange?: (value: string) => void;
+ defaultInputValue?: string;
+}
+
+export type ContextMenuSubMenuProps =
+ | NormalContextMenuSubMenuProps
+ | AutocompleteContextMenuSubMenuProps;
+
+export const ContextMenuSubMenu = ({
+ autocomplete,
+ autocompleteMode = 'auto',
+ inputValue: providedInputValue,
+ onInputValueChange,
+ defaultInputValue = '',
+ open: providedOpen,
+ onOpenChange,
+ defaultOpen = false,
+ ...props
+}: ContextMenuSubMenuProps) => {
+ const [internalInputValue, setInternalInputValue] =
+ useState(defaultInputValue);
+ const [internalOpen, setInternalOpen] = useState(defaultOpen);
+ const open = providedOpen ?? internalOpen;
+ const parentContext = useMenuContext();
+ const inputRef = useRef(null);
+ const isInitialRender = useRef(true);
+ const contentRef = useRef(null);
+ const inputValue = providedInputValue ?? internalInputValue;
+
+ const setValue = useCallback(
+ (value: string) => {
+ setInternalInputValue(value);
+ onInputValueChange?.(value);
+ },
+ [onInputValueChange]
+ );
+
+ const handleOpenChange: ContextMenuPrimitive.SubmenuRoot.Props['onOpenChange'] =
+ useCallback(
+ (
+ value: boolean,
+ eventDetails: ContextMenuPrimitive.SubmenuRoot.ChangeEventDetails
+ ) => {
+ if (!value && autocomplete) {
+ setValue('');
+ isInitialRender.current = true;
+ }
+ setInternalOpen(value);
+ onOpenChange?.(value, eventDetails);
+ },
+ [onOpenChange, setValue, autocomplete]
+ );
+
+ return (
+
+
+
+ );
+};
+ContextMenuSubMenu.displayName = 'ContextMenu.SubMenu';
diff --git a/packages/raystack/components/context-menu/context-menu-trigger.tsx b/packages/raystack/components/context-menu/context-menu-trigger.tsx
new file mode 100644
index 000000000..a09345bfd
--- /dev/null
+++ b/packages/raystack/components/context-menu/context-menu-trigger.tsx
@@ -0,0 +1,91 @@
+'use client';
+
+import {
+ Autocomplete as AutocompletePrimitive,
+ ContextMenu as ContextMenuPrimitive
+} from '@base-ui/react';
+import { forwardRef } from 'react';
+import { TriangleRightIcon } from '~/icons';
+import { Cell, CellBaseProps } from '../menu/cell';
+import { useMenuContext } from '../menu/menu-root';
+import { getMatch } from '../menu/utils';
+
+export interface ContextMenuTriggerProps
+ extends ContextMenuPrimitive.Trigger.Props {}
+
+export const ContextMenuTrigger = forwardRef<
+ HTMLDivElement,
+ ContextMenuTriggerProps
+>(({ children, ...props }, ref) => {
+ return (
+
+ {children}
+
+ );
+});
+ContextMenuTrigger.displayName = 'ContextMenu.Trigger';
+
+export interface ContextMenuSubTriggerProps
+ extends ContextMenuPrimitive.SubmenuTrigger.Props,
+ CellBaseProps {
+ value?: string;
+}
+
+export const ContextMenuSubTrigger = forwardRef<
+ HTMLDivElement,
+ ContextMenuSubTriggerProps
+>(
+ (
+ {
+ children,
+ value,
+ trailingIcon = ,
+ leadingIcon,
+ ...props
+ },
+ ref
+ ) => {
+ const { parent, inputRef } = useMenuContext();
+
+ if (
+ parent?.shouldFilter &&
+ !getMatch(value, children, parent?.inputValue)
+ ) {
+ return null;
+ }
+
+ const cell = | ;
+ return (
+ {
+ if (document?.activeElement !== parent?.inputRef?.current)
+ parent?.inputRef?.current?.focus();
+ props?.onPointerEnter?.(e);
+ }}
+ onKeyDown={e => {
+ requestAnimationFrame(() => {
+ inputRef?.current?.focus();
+ });
+ props?.onKeyDown?.(e);
+ }}
+ />
+ ) : (
+ cell
+ )
+ }
+ {...props}
+ role={parent?.autocomplete ? 'option' : 'menuitem'}
+ data-slot='menu-subtrigger'
+ >
+ {children}
+
+ );
+ }
+);
+ContextMenuSubTrigger.displayName = 'ContextMenu.SubTrigger';
diff --git a/packages/raystack/components/context-menu/context-menu.tsx b/packages/raystack/components/context-menu/context-menu.tsx
new file mode 100644
index 000000000..15ce97fce
--- /dev/null
+++ b/packages/raystack/components/context-menu/context-menu.tsx
@@ -0,0 +1,29 @@
+import {
+ ContextMenuContent,
+ ContextMenuSubContent
+} from './context-menu-content';
+import { ContextMenuItem } from './context-menu-item';
+import {
+ ContextMenuEmptyState,
+ ContextMenuGroup,
+ ContextMenuLabel,
+ ContextMenuSeparator
+} from './context-menu-misc';
+import { ContextMenuRoot, ContextMenuSubMenu } from './context-menu-root';
+import {
+ ContextMenuSubTrigger,
+ ContextMenuTrigger
+} from './context-menu-trigger';
+
+export const ContextMenu = Object.assign(ContextMenuRoot, {
+ Trigger: ContextMenuTrigger,
+ Content: ContextMenuContent,
+ Item: ContextMenuItem,
+ Group: ContextMenuGroup,
+ Label: ContextMenuLabel,
+ Separator: ContextMenuSeparator,
+ EmptyState: ContextMenuEmptyState,
+ Submenu: ContextMenuSubMenu,
+ SubmenuTrigger: ContextMenuSubTrigger,
+ SubmenuContent: ContextMenuSubContent
+});
diff --git a/packages/raystack/components/context-menu/index.ts b/packages/raystack/components/context-menu/index.ts
new file mode 100644
index 000000000..f628d09c8
--- /dev/null
+++ b/packages/raystack/components/context-menu/index.ts
@@ -0,0 +1 @@
+export { ContextMenu } from './context-menu';
diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx
index 03d3e7308..23dead7d6 100644
--- a/packages/raystack/index.tsx
+++ b/packages/raystack/index.tsx
@@ -19,6 +19,7 @@ export * from './components/color-picker';
export { Combobox } from './components/combobox';
export { Command } from './components/command';
export { Container } from './components/container';
+export { ContextMenu } from './components/context-menu';
export { CopyButton } from './components/copy-button';
export {
DataTable,