For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to documentation URLs. Homepage markdown is available at /llms.md/content.md. An agent skill is available at /.well-known/agent-skills/site-skill.md.

Context Menu

The Context Menu component from this design system, built on Base UI.

Installation

$ pnpm dlx shadcn@latest add @cobalt/context-menu

Source

"use client";

import { ContextMenu as ContextMenuPrimitive } from "@base-ui/react/context-menu";
import { RiCheckLine, RiArrowRightSLine } from "@remixicon/react";
import { cn } from "cn";
import * as React from "react";

// The panel and row recipe is the source's compact menu: a 14px panel with a
// 4px gutter, 32px rows (px-2 py-1.5 over 14/20 text) with 20px icons, and the
// shared 150ms fade + scale-95 + 2px blur grown from the point that opened it.
// Colours go through the dropdown tokens that theme-cobalt already ships, with
// semantic fallbacks, so the menu also works without the theme.
const MENU_PANEL = [
  "flex max-h-(--available-height) min-w-52 max-w-[calc(100vw-32px)] flex-col overflow-hidden",
  "origin-(--transform-origin) rounded-[14px] border border-[var(--border-subtle,var(--border))] bg-[var(--surface-raised,var(--popover))] text-popover-foreground shadow-[shadow:var(--shadow-dropdown,0_1px_1px_0_rgb(0_0_0/0.04),0_4px_4px_0_rgb(0_0_0/0.02))]",
  "outline-none transition-[opacity,scale,filter] duration-150 ease-out",
  "data-ending-style:opacity-0 data-starting-style:opacity-0",
  "motion-safe:data-ending-style:scale-95 motion-safe:data-ending-style:blur-[2px] motion-safe:data-starting-style:scale-95 motion-safe:data-starting-style:blur-[2px]",
].join(" ");

const MENU_LIST =
  "flex max-h-(--available-height) min-h-0 flex-col gap-1 overflow-y-auto overscroll-contain p-1 outline-none [scrollbar-width:thin]";

const MENU_ITEM = [
  "group/context-menu-item relative flex w-full cursor-pointer items-center gap-2 rounded-[10px] px-2 py-1.5 text-start text-sm leading-5 font-medium",
  "outline-none select-none transition-colors motion-reduce:transition-none",
  "data-highlighted:bg-[var(--surface-hover,var(--accent))] data-popup-open:bg-[var(--surface-hover,var(--accent))]",
  "data-disabled:cursor-not-allowed data-disabled:text-[color:var(--context-menu-disabled,var(--muted-foreground))]",
  "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-5",
].join(" ");

const DESTRUCTIVE_ITEM =
  "text-[color:var(--context-menu-destructive,var(--destructive))]";

const ICON = "text-[color:var(--icon-foreground,var(--muted-foreground))]";

/** The progressive top blur the source puts over a scrolling menu. */
const SCROLL_FADE_DISTANCE = 24;

function useMenuScrollFade(distance = SCROLL_FADE_DISTANCE) {
  const [progress, setProgress] = React.useState(0);
  const onScroll = React.useCallback(
    (event: React.UIEvent<HTMLElement>) =>
      setProgress(
        Math.min(1, Math.max(0, event.currentTarget.scrollTop) / distance)
      ),
    [distance]
  );
  return { onScroll, progress };
}

/** 24px masked 1px blur, a 16px 4px blur, then a wash of the panel surface. */
function MenuScrollFade({ progress }: { progress: number }) {
  return (
    <div
      aria-hidden
      className="pointer-events-none absolute inset-x-0 top-0 z-10 h-6"
    >
      <div
        className="absolute inset-0 backdrop-blur-[1px] [mask-image:linear-gradient(to_bottom,black,transparent)]"
        style={{ opacity: progress }}
      />
      <div
        className="absolute inset-x-0 top-0 h-4 backdrop-blur-[4px] [mask-image:linear-gradient(to_bottom,black,transparent)]"
        style={{ opacity: progress }}
      />
      <div
        className="absolute inset-0 bg-linear-to-b from-[var(--surface-raised,var(--popover))] to-transparent"
        style={{ opacity: progress }}
      />
    </div>
  );
}

type IconComponent = React.ComponentType<{ className?: string }>;

function ContextMenu({ ...props }: ContextMenuPrimitive.Root.Props) {
  return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
}

function ContextMenuPortal({ ...props }: ContextMenuPrimitive.Portal.Props) {
  return (
    <ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
  );
}

function ContextMenuTrigger({
  className,
  ...props
}: ContextMenuPrimitive.Trigger.Props) {
  return (
    <ContextMenuPrimitive.Trigger
      data-slot="context-menu-trigger"
      className={cn("select-none", className)}
      {...props}
    />
  );
}

function ContextMenuContent({
  className,
  children,
  align = "start",
  alignOffset = 0,
  side = "bottom",
  sideOffset = 2,
  ...props
}: ContextMenuPrimitive.Popup.Props &
  Pick<
    ContextMenuPrimitive.Positioner.Props,
    "align" | "alignOffset" | "side" | "sideOffset"
  >) {
  const { onScroll, progress } = useMenuScrollFade();
  return (
    <ContextMenuPrimitive.Portal>
      <ContextMenuPrimitive.Positioner
        className="isolate z-50 outline-none"
        align={align}
        alignOffset={alignOffset}
        side={side}
        sideOffset={sideOffset}
      >
        <ContextMenuPrimitive.Popup
          data-slot="context-menu-content"
          className={cn(MENU_PANEL, className)}
          {...props}
        >
          {/* The panel clips its corners; the inner list is what scrolls, so
              the top fade can stay put while the rows move under it. */}
          <div className="relative flex min-h-0 flex-col">
            <div
              data-slot="context-menu-list"
              onScroll={onScroll}
              className={MENU_LIST}
            >
              {children}
            </div>
            <MenuScrollFade progress={progress} />
          </div>
        </ContextMenuPrimitive.Popup>
      </ContextMenuPrimitive.Positioner>
    </ContextMenuPrimitive.Portal>
  );
}

function ContextMenuGroup({ ...props }: ContextMenuPrimitive.Group.Props) {
  return (
    <ContextMenuPrimitive.Group
      data-slot="context-menu-group"
      className="flex w-full flex-col gap-1"
      {...props}
    />
  );
}

function ContextMenuLabel({
  className,
  inset,
  ...props
}: ContextMenuPrimitive.GroupLabel.Props & {
  inset?: boolean;
}) {
  return (
    <ContextMenuPrimitive.GroupLabel
      data-slot="context-menu-label"
      data-inset={inset}
      className={cn(
        "px-2 pt-1 pb-0.5 text-[13px] leading-[18px] font-medium text-[color:var(--command-muted-foreground,var(--muted-foreground))] data-inset:ps-8",
        className
      )}
      {...props}
    />
  );
}

function ContextMenuItem({
  className,
  inset,
  variant = "default",
  icon: Icon,
  children,
  ...props
}: ContextMenuPrimitive.Item.Props & {
  inset?: boolean;
  variant?: "default" | "destructive";
  /** Lucide icon before the label; the row supplies its 20px size and colour. */
  icon?: IconComponent;
}) {
  const disabled = props.disabled === true;
  return (
    <ContextMenuPrimitive.Item
      data-slot="context-menu-item"
      data-inset={inset}
      data-variant={variant}
      className={cn(
        MENU_ITEM,
        variant === "destructive" && !disabled && DESTRUCTIVE_ITEM,
        "data-inset:ps-8",
        className
      )}
      {...props}
    >
      {Icon ? (
        <Icon
          aria-hidden
          className={cn(
            "size-5",
            variant === "destructive" || disabled ? "text-current" : ICON
          )}
        />
      ) : null}
      {children}
    </ContextMenuPrimitive.Item>
  );
}

function ContextMenuSub({ ...props }: ContextMenuPrimitive.SubmenuRoot.Props) {
  return (
    <ContextMenuPrimitive.SubmenuRoot data-slot="context-menu-sub" {...props} />
  );
}

function ContextMenuSubTrigger({
  className,
  inset,
  icon: Icon,
  children,
  ...props
}: ContextMenuPrimitive.SubmenuTrigger.Props & {
  inset?: boolean;
  /** Lucide icon before the label. */
  icon?: IconComponent;
}) {
  const disabled = props.disabled === true;
  return (
    <ContextMenuPrimitive.SubmenuTrigger
      data-slot="context-menu-sub-trigger"
      data-inset={inset}
      className={cn(MENU_ITEM, "data-inset:ps-8", className)}
      {...props}
    >
      {Icon ? (
        <Icon
          aria-hidden
          className={cn("size-5", disabled ? "text-current" : ICON)}
        />
      ) : null}
      {children}
      <RiArrowRightSLine
        aria-hidden
        className="-me-1 ml-auto text-[color:var(--icon-foreground,var(--muted-foreground))] rtl:-scale-x-100"
      />
    </ContextMenuPrimitive.SubmenuTrigger>
  );
}

function ContextMenuSubContent({
  align = "start",
  alignOffset = -5,
  side = "right",
  sideOffset = 9,
  className,
  ...props
}: React.ComponentProps<typeof ContextMenuContent>) {
  return (
    <ContextMenuContent
      data-slot="context-menu-sub-content"
      className={className}
      align={align}
      alignOffset={alignOffset}
      side={side}
      sideOffset={sideOffset}
      {...props}
    />
  );
}

function ContextMenuCheckboxItem({
  className,
  children,
  checked,
  inset,
  ...props
}: ContextMenuPrimitive.CheckboxItem.Props & {
  inset?: boolean;
}) {
  return (
    <ContextMenuPrimitive.CheckboxItem
      data-slot="context-menu-checkbox-item"
      data-inset={inset}
      className={cn(
        MENU_ITEM,
        "isolate pr-8 before:absolute before:inset-0 before:-z-10 before:rounded-[inherit] data-checked:transition-none data-checked:not-data-highlighted:before:bg-[var(--surface-hover,var(--accent))] data-inset:ps-8",
        className
      )}
      checked={checked}
      {...props}
    >
      <span
        className="pointer-events-none absolute right-2 flex items-center justify-center"
        data-slot="context-menu-checkbox-item-indicator"
      >
        <ContextMenuPrimitive.CheckboxItemIndicator>
          <RiCheckLine aria-hidden className="size-4 text-ring" />
        </ContextMenuPrimitive.CheckboxItemIndicator>
      </span>
      {children}
    </ContextMenuPrimitive.CheckboxItem>
  );
}

function ContextMenuRadioGroup({
  ...props
}: ContextMenuPrimitive.RadioGroup.Props) {
  return (
    <ContextMenuPrimitive.RadioGroup
      data-slot="context-menu-radio-group"
      {...props}
    />
  );
}

function ContextMenuRadioItem({
  className,
  children,
  inset,
  ...props
}: ContextMenuPrimitive.RadioItem.Props & {
  inset?: boolean;
}) {
  return (
    <ContextMenuPrimitive.RadioItem
      data-slot="context-menu-radio-item"
      data-inset={inset}
      className={cn(
        MENU_ITEM,
        "isolate pr-8 before:absolute before:inset-0 before:-z-10 before:rounded-[inherit] data-checked:transition-none data-checked:not-data-highlighted:before:bg-[var(--surface-hover,var(--accent))] data-inset:ps-8",
        className
      )}
      {...props}
    >
      <span
        className="pointer-events-none absolute right-2 flex items-center justify-center"
        data-slot="context-menu-radio-item-indicator"
      >
        <ContextMenuPrimitive.RadioItemIndicator>
          <RiCheckLine aria-hidden className="size-4 text-ring" />
        </ContextMenuPrimitive.RadioItemIndicator>
      </span>
      {children}
    </ContextMenuPrimitive.RadioItem>
  );
}

function ContextMenuSeparator({
  className,
  ...props
}: ContextMenuPrimitive.Separator.Props) {
  return (
    <ContextMenuPrimitive.Separator
      data-slot="context-menu-separator"
      className={cn("-mx-1 my-0.5 h-px shrink-0 bg-border", className)}
      {...props}
    />
  );
}

function ContextMenuShortcut({
  className,
  ...props
}: React.ComponentProps<"span">) {
  return (
    <span
      data-slot="context-menu-shortcut"
      className={cn(
        "ml-auto shrink-0 ps-3 font-sans text-sm leading-5 font-normal text-[color:var(--command-muted-foreground,var(--muted-foreground))]",
        className
      )}
      {...props}
    />
  );
}

export {
  ContextMenu,
  ContextMenuTrigger,
  ContextMenuContent,
  ContextMenuItem,
  ContextMenuCheckboxItem,
  ContextMenuRadioItem,
  ContextMenuLabel,
  ContextMenuSeparator,
  ContextMenuShortcut,
  ContextMenuGroup,
  ContextMenuPortal,
  ContextMenuSub,
  ContextMenuSubContent,
  ContextMenuSubTrigger,
  ContextMenuRadioGroup,
};