โ† Back to home

React

Use context-menu-kit with React

Add a headless, customizable context menu to your React app. You can use the default UI, customize each item, or fully control the menu body.

Installation

pnpm add @context-menu-kit/core @context-menu-kit/react

Import styles

The React package ships with a default CSS file. Import it once in your app.

import '@context-menu-kit/react/styles.css'

Live demo

Right click the areas below to try the React package directly in the docs.

Live demo

Right click different areas

This demo shows default UI, custom menu body, disabled items, and different menus for different trigger areas.

Last actionRight click any area below.No action yet
Default UI๐Ÿ“
File area

Uses the built-in renderer from @context-menu-kit/react.

index.tsxstyles.csspackage.json
Custom bodyโŒ˜
Editor area

Uses renderBody to fully control the menu UI.

const menu = createContextMenu()menu.open(event)menu.close()
Different menuโ—‡
Canvas area

Another trigger area with its own items and its own menu UI.

Examples

Each example is interactive. Right click the preview area, then copy the source code into your own React project.

Basic menu

Use the default renderer with a simple list of menu items.

Default UIRight click here

Use the built-in menu renderer.

Right click the card.
Source code
import { useState } from "react"
import { ContextMenu, ContextMenuProvider } from "@context-menu-kit/react"
import "@context-menu-kit/react/styles.css"

const items = [
  {
    id: "copy",
    label: "Copy",
    onClick: () => {}
  },
  {
    id: "rename",
    label: "Rename",
    onClick: () => {}
  },
  {
    id: "delete",
    label: "Delete",
    disabled: true
  }
]

export function BasicMenuDemo() {
  const [message, setMessage] = useState("Right click the card.")

  const menuItems = items.map((item) => ({
    ...item,
    onClick: () => {
      setMessage(`${item.label} clicked`)
    }
  }))

  return (
    <ContextMenuProvider>
      <style>{styles}</style>

      <div className="basic-demo">
        <ContextMenu items={menuItems}>
          <div className="basic-card">
            <span>Default UI</span>
            <strong>Right click here</strong>
            <p>Use the built-in menu renderer.</p>
          </div>
        </ContextMenu>

        <div className="basic-result">{message}</div>
      </div>
    </ContextMenuProvider>
  )
}

const styles = `
.basic-demo {
  display: grid;
  gap: 14px;
}

.basic-card {
  box-sizing: border-box;
  min-height: 180px;
  display: grid;
  align-content: center;
  gap: 8px;
  border: 1px solid #d9e3f0;
  border-radius: 20px;
  background: #ffffff;
  padding: 24px;
  cursor: context-menu;
  user-select: none;
}

.basic-card span {
  width: fit-content;
  border-radius: 999px;
  background: #eef3ff;
  padding: 6px 10px;
  color: #4969e8;
  font-size: 12px;
  font-weight: 700;
}

.basic-card strong {
  color: #172033;
  font-size: 24px;
}

.basic-card p {
  margin: 0;
  color: #52637a;
}

.basic-result {
  border-radius: 14px;
  background: #eef3ff;
  padding: 12px 14px;
  color: #172033;
  font-size: 14px;
}
`

Custom menu body

Use renderBody to fully control the menu UI.

Editorindex.tsx
const menu = createContextMenu()

menu.open(event)
menu.close()
Right click the editor.
Source code
import { useState } from "react"
import { ContextMenu, ContextMenuProvider, type ContextMenuItem } from "@context-menu-kit/react"
import "@context-menu-kit/react/styles.css"

interface IMata {
  shortcut: string
  description?: string
  disabled?: boolean
  danger?: boolean
}

function MenuBody({ items, close }: { items: ContextMenuItem<IMata>[]; close: () => void }) {
  return (
    <div className="custom-menu">
      {items.map((item) => (
        <button
          key={item.id}
          className={item?.data?.danger ? "custom-menu-item danger" : "custom-menu-item"}
          disabled={item.disabled}
          onClick={() => {
            if (item.disabled) return

            item.onClick?.(item)
            close()
          }}
        >
          <span>
            {item.label}
            {item.data ? <small>{item.data.description}</small> : null}
          </span>

          {item.data ? <kbd>{item.data.shortcut}</kbd> : null}
        </button>
      ))}
    </div>
  )
}

export function CustomBodyDemo() {
  const [message, setMessage] = useState("Right click the editor.")

  const items: ContextMenuItem<IMata>[] = [
    {
      id: "copy",
      label: "Copy",
      data: {
        description: "Copy selected text",
        shortcut: "โŒ˜C"
      },
      onClick: () => setMessage("Copy clicked")
    },
    {
      id: "paste",
      label: "Paste",
      data: {
        description: "Insert from clipboard",
        shortcut: "โŒ˜V"
      },

      onClick: () => setMessage("Paste clicked")
    },
    {
      id: "format",
      label: "Format document",
      data: {
        description: "Format current file",
        shortcut: "โ‡งโŒฅF"
      },
      onClick: () => setMessage("Format document clicked")
    },
    {
      id: "delete",
      label: "Delete line",
      data: {
        description: "Disabled example",
        shortcut: "โŒ˜D",
        disabled: true,
        danger: true
      }
    }
  ]

  return (
    <ContextMenuProvider>
      <style>{styles}</style>

      <div className="custom-demo">
        <ContextMenu
          items={items}
          renderBody={({ items, close }) => <MenuBody items={items} close={close} />}
        >
          <div className="editor-card">
            <div className="editor-header">
              <span>Editor</span>
              <small>index.tsx</small>
            </div>

            <pre>{`const menu = createContextMenu()

menu.open(event)
menu.close()`}</pre>
          </div>
        </ContextMenu>

        <div className="custom-result">{message}</div>
      </div>
    </ContextMenuProvider>
  )
}

const styles = `
.custom-demo {
  display: grid;
  gap: 14px;
}

.editor-card {
  box-sizing: border-box;
  min-height: 220px;
  border-radius: 22px;
  background: #111827;
  padding: 18px;
  cursor: context-menu;
  user-select: none;
  box-shadow: 0 20px 50px rgb(17 24 39 / 0.2);
}

.editor-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 18px;
  color: #dbe7ff;
}

.editor-header span {
  font-weight: 700;
}

.editor-header small {
  color: #8f9bb0;
}

.editor-card pre {
  margin: 0;
  border-radius: 16px;
  background: rgb(0 0 0 / 0.24);
  padding: 18px;
  color: #c8d7ee;
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
  font-size: 14px;
  line-height: 1.8;
}

.custom-result {
  border-radius: 14px;
  background: #eef3ff;
  padding: 12px 14px;
  color: #172033;
  font-size: 14px;
}

.custom-menu {
  min-width: 240px;
  display: grid;
  gap: 4px;
  border: 1px solid rgb(255 255 255 / 0.12);
  border-radius: 16px;
  background: #111827;
  padding: 8px;
  box-shadow: 0 24px 70px rgb(0 0 0 / 0.28);
}

.custom-menu-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 14px;
  border: 0;
  border-radius: 10px;
  background: transparent;
  padding: 10px 11px;
  color: #f7fbff;
  font: inherit;
  text-align: left;
  cursor: pointer;
}

.custom-menu-item:hover {
  background: rgb(255 255 255 / 0.08);
}

.custom-menu-item:disabled {
  color: #6f7b90;
  cursor: not-allowed;
}

.custom-menu-item.danger:not(:disabled) {
  color: #ffb4b4;
}

.custom-menu-item span {
  display: grid;
  gap: 3px;
}

.custom-menu-item small {
  color: #8f9bb0;
  font-size: 12px;
}

.custom-menu-item kbd {
  border-radius: 7px;
  background: rgb(255 255 255 / 0.08);
  padding: 3px 6px;
  color: #aebbd0;
  font-size: 11px;
}
`

Multiple trigger areas

Different areas can have different menu items and different UI.

File areaRight click files

App.tsx

Button.tsx

styles.css

Canvas areaRight click canvas
Right click different areas.
Source code
import { useState } from "react"
import { ContextMenu, ContextMenuProvider, type ContextMenuItem } from "@context-menu-kit/react"
import "@context-menu-kit/react/styles.css"

function MenuBody({ items, close }: { items: ContextMenuItem[]; close: () => void }) {
  return (
    <div className="multi-menu">
      {items.map((item) => (
        <button
          key={item.id}
          className={"multi-menu-item"}
          disabled={item.disabled}
          onClick={() => {
            if (item.disabled) return

            item.onClick?.(item)
            close()
          }}
        >
          {item.label}
        </button>
      ))}
    </div>
  )
}

export function MultiAreaDemo() {
  const [message, setMessage] = useState("Right click different areas.")

  const fileItems: ContextMenuItem[] = [
    {
      id: "rename",
      label: "Rename file",
      onClick: () => setMessage("File area: Rename file")
    },
    {
      id: "duplicate",
      label: "Duplicate file",
      onClick: () => setMessage("File area: Duplicate file")
    },
    {
      id: "delete",
      label: "Delete file",
      onClick: () => setMessage("File area: Delete file")
    }
  ]

  const canvasItems: ContextMenuItem[] = [
    {
      id: "bring-front",
      label: "Bring to front",
      onClick: () => setMessage("Canvas area: Bring to front")
    },
    {
      id: "send-back",
      label: "Send to back",
      onClick: () => setMessage("Canvas area: Send to back")
    },
    {
      id: "lock",
      label: "Lock layer",
      disabled: true
    }
  ]

  return (
    <ContextMenuProvider>
      <style>{styles}</style>

      <div className="multi-demo">
        <div className="multi-grid">
          <ContextMenu
            items={fileItems}
            renderBody={({ items, close }) => (
              <MenuBody items={items as ContextMenuItem[]} close={close} />
            )}
          >
            <div className="multi-card file-card">
              <span>File area</span>
              <strong>Right click files</strong>

              <div className="file-list">
                <p>App.tsx</p>
                <p>Button.tsx</p>
                <p>styles.css</p>
              </div>
            </div>
          </ContextMenu>

          <ContextMenu
            items={canvasItems}
            renderBody={({ items, close }) => (
              <MenuBody items={items as ContextMenuItem[]} close={close} />
            )}
          >
            <div className="multi-card canvas-card">
              <span>Canvas area</span>
              <strong>Right click canvas</strong>

              <div className="canvas-preview">
                <i className="shape one" />
                <i className="shape two" />
                <i className="shape three" />
              </div>
            </div>
          </ContextMenu>
        </div>

        <div className="multi-result">{message}</div>
      </div>
    </ContextMenuProvider>
  )
}

const styles = `
.multi-demo {
  display: grid;
  gap: 14px;
}

.multi-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 16px;
}

.multi-card {
  box-sizing: border-box;
  min-height: 240px;
  display: flex;
  flex-direction: column;
  border: 1px solid #dfe7f2;
  border-radius: 22px;
  background: #ffffff;
  padding: 22px;
  cursor: context-menu;
  user-select: none;
}

.multi-card span {
  width: fit-content;
  border-radius: 999px;
  background: #eef3ff;
  padding: 6px 10px;
  color: #4969e8;
  font-size: 12px;
  font-weight: 700;
}

.multi-card strong {
  margin-top: 14px;
  color: #172033;
  font-size: 22px;
}

.file-list {
  display: grid;
  gap: 8px;
  margin-top: auto;
}

.file-list p {
  margin: 0;
  border: 1px solid #e3eaf5;
  border-radius: 12px;
  background: #f8fbff;
  padding: 9px 11px;
  color: #43546c;
  font-size: 13px;
}

.canvas-card {
  background:
    radial-gradient(circle at top right, rgb(28 184 134 / 0.18), transparent 34%),
    #ffffff;
}

.canvas-preview {
  position: relative;
  min-height: 110px;
  margin-top: auto;
  border: 1px dashed #b9d8ca;
  border-radius: 18px;
  background:
    linear-gradient(90deg, rgb(23 32 51 / 0.04) 1px, transparent 1px),
    linear-gradient(0deg, rgb(23 32 51 / 0.04) 1px, transparent 1px);
  background-size: 18px 18px;
}

.shape {
  position: absolute;
  display: block;
  border-radius: 16px;
  box-shadow: 0 12px 28px rgb(23 32 51 / 0.14);
}

.shape.one {
  width: 76px;
  height: 42px;
  left: 22px;
  top: 20px;
  background: #4969e8;
}

.shape.two {
  width: 56px;
  height: 56px;
  right: 42px;
  top: 16px;
  border-radius: 999px;
  background: #20b886;
}

.shape.three {
  width: 92px;
  height: 28px;
  right: 24px;
  bottom: 16px;
  background: #f59f00;
}

.multi-result {
  border-radius: 14px;
  background: #eef3ff;
  padding: 12px 14px;
  color: #172033;
  font-size: 14px;
}

.multi-menu {
  min-width: 180px;
  display: grid;
  gap: 6px;
  border: 1px solid #d9eee6;
  border-radius: 18px;
  background: #ffffff;
  padding: 10px;
  box-shadow: 0 24px 70px rgb(23 32 51 / 0.18);
}

.multi-menu-item {
  border: 0;
  border-radius: 12px;
  background: #f2fbf7;
  padding: 10px 12px;
  color: #173f31;
  font: inherit;
  font-size: 14px;
  text-align: left;
  cursor: pointer;
}

.multi-menu-item:hover {
  background: #ddf7ec;
}

.multi-menu-item.danger {
  background: #fff0f0;
  color: #c92a2a;
}

.multi-menu-item.danger:hover {
  background: #ffe3e3;
}

.multi-menu-item:disabled {
  color: #9aa8ba;
  cursor: not-allowed;
}

@media (max-width: 720px) {
  .multi-grid {
    grid-template-columns: 1fr;
  }
}
`

Basic usage

import {
  ContextMenu,
  ContextMenuProvider,
  type ContextMenuItem,
} from '@context-menu-kit/react'

import '@context-menu-kit/react/styles.css'

const items: ContextMenuItem[] = [
  {
    id: 'copy',
    label: 'Copy',
    onSelect: () => {
      console.log('Copy')
    },
  },
  {
    id: 'paste',
    label: 'Paste',
    disabled: true,
    onSelect: () => {
      console.log('Paste')
    },
  },
]

export function App() {
  return (
    <ContextMenuProvider>
      <ContextMenu items={items}>
        <div className="target">
          Right click here
        </div>
      </ContextMenu>
    </ContextMenuProvider>
  )
}

Customize item UI

<ContextMenu
  items={items}
  renderItem={({ item, close }) => (
    <button
      disabled={item.disabled}
      onClick={() => {
        item.onSelect?.()
        close()
      }}
    >
      {item.label}
    </button>
  )}
>
  <div>Right click here</div>
</ContextMenu>

Customize menu body

<ContextMenu
  items={items}
  renderBody={({ items, close }) => (
    <div className="my-menu">
      {items.map((item) => (
        <button
          key={item.id}
          disabled={item.disabled}
          onClick={() => {
            item.onSelect?.()
            close()
          }}
        >
          {item.label}
        </button>
      ))}
    </div>
  )}
>
  <div>Right click here</div>
</ContextMenu>

Different areas, different menus

<ContextMenu items={fileItems}>
  <div>File area</div>
</ContextMenu>

<ContextMenu items={editorItems} renderBody={renderEditorMenu}>
  <div>Editor area</div>
</ContextMenu>

<ContextMenu items={canvasItems} renderItem={renderCanvasItem}>
  <div>Canvas area</div>
</ContextMenu>