← ui

shortcut

ui

any input drawn as what the hands do: keys, clicks, drags, scrolls

npx shadcn add @ag/shortcut
Already installed? Preview an update
npx shadcn add @ag/shortcut --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-10
  • New: material, its keys in either material; on glass the joiner and the pointer glyph take the text's own colour.
  • Fixed: the double-click glyph drew its ripples beside the right button, reading as a right double-click. They now come off the left button, on the left.
Updated 2026-10-10 · release notes

also called keyboard shortcut

shelf controls

uses kbd, keys, material

used by selection, command, table, menu, components

the demo above, verbatim

shortcut.demo.tsx
"use client"

import { Sample } from "@/app/samples"
import { Button } from "@/registry/base-nova/ui/button"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import { POINTERS, Shortcut } from "@/registry/base-nova/ui/shortcut"
import { Tip } from "@/registry/base-nova/ui/tooltip"

// #region inputs
const WITH_KEYS = [
  "mod+click",
  "shift+click",
  "alt+secondary-click",
  "mod+double-click",
  "shift+drag",
  "alt+scroll",
  "mod+hover",
  "ctrl+long-press",
]
const KEYS_ONLY = ["mod+shift+k", "shift+f10", "alt+enter"]
// #endregion

// #region menu
const MENU = [
  { label: "exclude", hint: "mod+click" },
  { label: "include", hint: "click" },
  { label: "open in a new tab", hint: "middle-click" },
  { label: "show menu", hint: "secondary-click" },
  { label: "rename", hint: "double-click" },
]
// #endregion

const ROW = "flex items-center gap-4 text-sm"

export default function ShortcutDemo() {
  return (
    <div className="space-y-8">
      <Sample
        name="pointers"
        glance
        title="every pointer action"
        description="bare glyphs at the height of a key, in the order a hand learns them"
      >
        <div className="flex flex-wrap items-center gap-x-6 gap-y-3 text-sm">
          {POINTERS.map((p) => (
            <span key={p} className="inline-flex items-center gap-2">
              <Shortcut pointer={p} />
              <span className="text-foreground/50 text-xs">{p}</span>
            </span>
          ))}
        </div>
      </Sample>
      <Sample
        name="with-keys"
        title="keys and a pointer"
        description="mod is ⌘ on a Mac, Ctrl elsewhere. the second column is the same input drawn for Windows and Linux"
        with="inputs"
      >
        <div className="grid grid-cols-[auto_auto_1fr] items-center gap-x-8 gap-y-3 text-sm">
          {[...WITH_KEYS, ...KEYS_ONLY].map((input) => (
            <div key={input} className="contents">
              <Shortcut input={input} platform="mac" />
              <Shortcut input={input} platform="other" />
              <code className="text-foreground/50 text-xs">{input}</code>
            </div>
          ))}
        </div>
      </Sample>
      <Sample
        name="sizes"
        title="two sizes"
        description="the default follows the text it sits in; sm is a menu row's or a tooltip's"
      >
        <div className="space-y-3">
          <p className={ROW}>
            <Shortcut input="mod+click" />
            <Shortcut input="shift+drag" />
            <Shortcut input="alt+scroll" />
          </p>
          <p className={ROW}>
            <Shortcut input="mod+click" size="sm" />
            <Shortcut input="shift+drag" size="sm" />
            <Shortcut input="alt+scroll" size="sm" />
          </p>
        </div>
      </Sample>
      <Sample
        name="menu"
        title="in a menu row"
        description="each verb with the input it stands in for"
        with="menu"
      >
        <div className="w-64 rounded-lg border border-border bg-popover p-1 shadow-md">
          {MENU.map((m) => (
            <div
              key={m.label}
              className="flex items-center gap-4 rounded-md px-2 py-1.5 text-[13px] text-foreground/85 hover:bg-foreground/8"
            >
              {m.label}
              <Shortcut
                input={m.hint}
                size="sm"
                className="ml-auto opacity-70"
              />
            </div>
          ))}
        </div>
      </Sample>
      <Sample name="tooltip" title="in a tooltip">
        <Tip
          content={
            <span className="inline-flex items-center gap-2">
              exclude this tag <Shortcut input="mod+click" size="sm" />
            </span>
          }
        >
          <Button variant="secondary">nature</Button>
        </Tip>
      </Sample>
      <Sample name="glass" title="in glass" with="inputs">
        <MaterialProvider material="glass">
          <div className="flex flex-wrap items-center gap-x-6 gap-y-3 rounded-2xl bg-[url(/lightbox/shore.jpg)] bg-cover bg-center p-10 text-sm text-white">
            {WITH_KEYS.slice(0, 4).map((input) => (
              <Shortcut key={input} input={input} />
            ))}
          </div>
        </MaterialProvider>
      </Sample>
    </div>
  )
}

the source, every file an install writes

shortcut.tsx
"use client"

// An input drawn as what the hands do: keys, a pointer action, or both. `<Shortcut
// input="mod+click" />` draws ⌘ and a mouse with its left button lit; "shift+drag",
// "alt+scroll", "secondary-click", "mod+shift+k" read the same way, the pointer
// action last. Keys are kbd's own caps; `mod` is ⌘ on a Mac and Ctrl elsewhere, and
// off a Mac every modifier reads as a word (Ctrl, Shift), the way those systems print
// a shortcut. The platform is read once on the client (lib/keys decides it); the
// server draws the Mac's and the client corrects it after hydration.
//
// A POINTER ACTION IS NOT A KEY, SO IT IS NOT A CAP: it is drawn bare, at the caps'
// height and centre, after a quiet "+". A cap says "press this key"; a mouse in a cap
// would read as a key labelled with a mouse. Between keys there is no joiner, kbd's
// convention; the "+" marks only the change of hand, keyboard to pointer.
//
// The glyphs are one family on lucide's grid (24, stroke 2): a mouse whose lit button
// is the click (left, right, the wheel), with a mark beside it for what is not a
// single press (two ripples, a wheel's arrows, a clock). lucide's own mouse-left and
// mouse-right mark a button with a dot, and it has no double, middle, scroll or
// long-press mouse, so those are drawn here. Drag is lucide's closed hand, the cursor
// macOS shows while dragging; hover is its resting arrow.
//
// A screen reader hears words ("Command click"), never glyphs: the drawing is
// aria-hidden and the words ride beside it.

import { HandGrab, MousePointer2 } from "lucide-react"
import { useSyncExternalStore } from "react"
import { cn } from "@/lib/utils"
import {
  combo,
  keyName,
  modifiers,
  type Platform,
  platformOf,
} from "@/registry/base-nova/lib/keys"
import { KbdCap } from "@/registry/base-nova/ui/kbd"
import { type Material, useMaterial } from "@/registry/base-nova/ui/material"

/** What a pointer can do, as people write it. */
export const POINTERS = [
  "click",
  "double-click",
  "secondary-click",
  "middle-click",
  "drag",
  "scroll",
  "hover",
  "long-press",
] as const
export type Pointer = (typeof POINTERS)[number]

/** Other names for the same action. */
const ALIASES: Record<string, Pointer> = {
  "right-click": "secondary-click",
  "context-click": "secondary-click",
  dblclick: "double-click",
  wheel: "scroll",
  hold: "long-press",
}

/** The action as a person says it. */
const SPOKEN: Record<Pointer, string> = {
  click: "click",
  "double-click": "double click",
  "secondary-click": "right click",
  "middle-click": "middle click",
  drag: "drag",
  scroll: "scroll",
  hover: "hover",
  "long-press": "long press",
}

/** An input: the keys held (as lib/keys caps, macOS order) and what the pointer
 *  does, either of which may be absent but not both. */
export interface ShortcutParts {
  keys: string[]
  pointer: Pointer | null
}

function pointerOf(token: string): Pointer | null {
  const t = token.trim().toLowerCase()
  return (POINTERS as readonly string[]).includes(t)
    ? (t as Pointer)
    : (ALIASES[t] ?? null)
}

/** "mod+click", "shift+drag", "secondary-click", "mod+shift+k" to its parts, or
 *  null when it is not an input. A pointer action comes last; what is before it
 *  must be modifiers. Framework-free, like lib/keys. */
export function shortcut(
  text: string,
  platform: Platform = "mac",
): ShortcutParts | null {
  const parts = text.trim().split("+")
  const pointer = pointerOf(parts.at(-1) ?? "")
  if (pointer) {
    const held = parts.slice(0, -1)
    if (held.length === 0) return { keys: [], pointer }
    const caps = modifiers(held, platform)
    return caps ? { keys: caps, pointer } : null
  }
  const caps = combo(text, platform)
  return caps ? { keys: caps, pointer: null } : null
}

/** The input in words, for a screen reader and a hover title: "Command click",
 *  "Control Shift K", "Right click"; off a Mac ⌥ is "Alt". */
export function spoken(
  parts: ShortcutParts,
  platform: Platform = "mac",
): string {
  const words = [
    ...parts.keys.map((cap) => keyName(cap, platform)),
    ...(parts.pointer ? [SPOKEN[parts.pointer]] : []),
  ].join(" ")
  return words.charAt(0).toUpperCase() + words.slice(1)
}

const noop = () => () => {}
/** The platform this page runs on; the Mac on the server and during hydration. */
export function usePlatform(): Platform {
  return useSyncExternalStore(
    noop,
    () => platformOf(navigator.platform),
    () => "mac",
  )
}

// ── the glyphs. One grid: 24 high, lucide's stroke, the mouse's body at x 4–16 so
// a mark fits to its right. A glyph without a mark crops its view to the body.

const BODY = "M4 8a6 6 0 0 1 12 0v8a6 6 0 0 1-12 0z"
const LEFT = "M10 2a6 6 0 0 0-6 6v3h6z"
const RIGHT = "M10 2a6 6 0 0 1 6 6v3h-6z"
const WHEEL = "M10 5.5v3"

/** Each mouse glyph: what is lit, what is stroked, and the mark beside it if any, on
 *  the side it belongs to: a mark about a button sits by that button (a double
 *  click's ripples come off the left one), a mark about the whole hand sits right. */
const MICE: Record<
  Exclude<Pointer, "drag" | "hover">,
  { fill: string[]; stroke: string[]; mark: "left" | "right" | null }
> = {
  click: { fill: [LEFT], stroke: [], mark: null },
  "secondary-click": { fill: [RIGHT], stroke: [], mark: null },
  "middle-click": {
    fill: ["M8.5 6a1.5 1.5 0 0 1 3 0v2.5a1.5 1.5 0 0 1-3 0z"],
    stroke: [],
    mark: null,
  },
  "double-click": {
    fill: [LEFT],
    // two ripples off the left button: the press, again
    stroke: ["M1.5 5.5a4.5 4.5 0 0 0 0 6", "M-1.5 3.5a7.5 7.5 0 0 0 0 10"],
    mark: "left",
  },
  scroll: {
    fill: [],
    // the wheel, and the way it turns
    stroke: [
      WHEEL,
      "M20.5 5v14",
      "m18 7.5 2.5-2.5 2.5 2.5",
      "m18 16.5 2.5 2.5 2.5-2.5",
    ],
    mark: "right",
  },
  "long-press": {
    fill: [LEFT],
    // a clock: held
    stroke: ["M20.5 15.5a3 3 0 1 1 0 6a3 3 0 0 1 0-6", "M20.5 17v1.6l1 .8"],
    mark: "right",
  },
}

/** The glyph for a pointer action, at the height of the text it sits in. */
export function PointerGlyph({
  pointer,
  className,
}: {
  pointer: Pointer
  className?: string
}) {
  // Decorative: the words for it are `spoken`, said beside it.
  const size = cn("h-[1.15em] w-auto shrink-0", className)
  if (pointer === "drag")
    return (
      <HandGrab aria-hidden data-slot="shortcut-pointer" className={size} />
    )
  if (pointer === "hover")
    return (
      <MousePointer2
        aria-hidden
        data-slot="shortcut-pointer"
        className={size}
      />
    )
  const m = MICE[pointer]
  return (
    <svg
      aria-hidden
      data-slot="shortcut-pointer"
      viewBox={
        m.mark === "right"
          ? "2 0 22.5 24"
          : m.mark === "left"
            ? "-4.5 0 22.5 24"
            : "2 0 16 24"
      }
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      className={size}
    >
      {m.fill.map((d) => (
        <path key={d} d={d} fill="currentColor" stroke="none" />
      ))}
      <path d={BODY} />
      {m.fill.includes(LEFT) || m.fill.includes(RIGHT) ? (
        <path d="M10 2v9M4 11h12" strokeWidth={1.5} />
      ) : null}
      {m.stroke.map((d) => (
        <path key={d} d={d} />
      ))}
    </svg>
  )
}

const SIZES = {
  default: "",
  sm: "text-[11px]",
} as const

export interface ShortcutProps {
  /** The whole input as text: "mod+click", "shift+drag", "secondary-click",
   *  "mod+shift+k". */
  input?: string
  /** Or in parts: the keys held, as lib/keys reads them ("mod", "shift", "k"). */
  keys?: string[]
  /** …and what the pointer does. */
  pointer?: Pointer
  /** Draw for this platform instead of the one the page runs on (a docs page
   *  showing both). */
  platform?: Platform
  /** `sm` for a menu row or a tooltip; the default follows the text it sits in. */
  size?: keyof typeof SIZES
  /** Its keys' material: plain keys, or keys of glass (on glass, the joiner and the
   *  pointer take the text's own colour). */
  material?: Material
  className?: string
}

export function Shortcut({
  input,
  keys,
  pointer,
  platform,
  size = "default",
  material,
  className,
}: ShortcutProps) {
  const own = usePlatform()
  const p = platform ?? own
  const m = useMaterial(material)
  const ink = m === "glass" ? "text-current" : "text-foreground"
  const text =
    input ?? [...(keys ?? []), ...(pointer ? [pointer] : [])].join("+")
  const parts = shortcut(text, p)
  if (!parts) throw new Error(`Shortcut: "${text}" is not an input`)
  const words = spoken(parts, p)
  return (
    <kbd
      data-slot="shortcut"
      data-material={m}
      title={words}
      className={cn(
        "not-prose inline-flex items-center align-[-0.12em] font-[inherit] not-italic",
        SIZES[size],
        className,
      )}
    >
      <span aria-hidden className="inline-flex items-center gap-[0.3em]">
        {parts.keys.length > 0 && (
          <span className="inline-flex gap-[0.25em]">
            {parts.keys.map((cap, i) => (
              // biome-ignore lint/suspicious/noArrayIndexKey: caps repeat, position is identity
              <KbdCap key={i} cap={cap} platform={p} material={m} />
            ))}
          </span>
        )}
        {parts.keys.length > 0 && parts.pointer && (
          <span
            data-slot="shortcut-joiner"
            className={cn("text-[0.8em] opacity-40", ink)}
          >
            +
          </span>
        )}
        {parts.pointer && (
          <PointerGlyph
            pointer={parts.pointer}
            className={cn("opacity-75", ink)}
          />
        )}
      </span>
      <span className="sr-only">{words}</span>
    </kbd>
  )
}