← ui

kbd

ui

keyboard shortcuts drawn as keys, from any notation

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

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

Latest changes · 2026-10-11
  • Removed (breaking): isKeys. kbd is a client module (it reads the material), so a server component calling isKeys to choose between keys and code failed to prerender. Migration: keys(text) !== null from lib/keys, which runs anywhere.
Updated 2026-10-11 · release notes

also called keycap

shelf controls

uses keys, material

used by tooltip, components, search, verdict, form, shortcut, answer-keys, player, timeline, macos

the demo above, verbatim

kbd.demo.tsx
"use client"

import { Sample } from "@/app/samples"
import { Kbd, KeysText } from "@/registry/base-nova/ui/kbd"
import { MaterialProvider } from "@/registry/base-nova/ui/material"

export default function KbdDemo() {
  return (
    <div className="space-y-8">
      <Sample name="shortcut" glance title="one shortcut, any notation">
        <div className="flex flex-wrap items-center gap-6 text-sm">
          <Kbd keys="⌃⌥⌘A" />
          <Kbd keys="ctrl+alt+cmd+a" />
          <Kbd keys="⇧⌘F" />
          <Kbd keys="← →" />
          <Kbd keys="esc" />
        </div>
      </Sample>
      <Sample name="sentence" title="shortcuts inside text">
        <p className="text-sm text-foreground/80">
          <KeysText>Press ⌃⌥⌘A to keep your Mac awake, ⌘Q to quit.</KeysText>
        </p>
      </Sample>
      <Sample name="glass" title="in glass">
        <MaterialProvider material="glass">
          <div className="flex flex-wrap items-center gap-6 rounded-2xl bg-[url(/lightbox/peaks.jpg)] bg-cover bg-center p-10 text-sm text-white">
            <Kbd keys="⇧⌘F" />
            <Kbd keys="← →" />
            <span>
              <KeysText>Press space to play, esc to leave.</KeysText>
            </span>
          </div>
        </MaterialProvider>
      </Sample>
    </div>
  )
}

the source, every file an install writes

kbd.tsx
"use client"

// Keyboard shortcuts drawn as keys. `<Kbd keys="⌃⌥⌘A" />` draws one shortcut, in any
// notation lib/keys reads (`ctrl+alt+cmd+a`, `← →` for keys pressed in turn), and
// screams when the string is not keys. `<KeysText>` draws a sentence with its
// shortcuts as keys ("Press ⌃⌥⌘A to …"), for text that comes from somewhere else: a
// caption, a scene, a changelog. A renderer deciding between keys and code (`awake
// hotkey` is a command, not keys) asks lib/keys, `keys(text) !== null`, which a server
// component can call and this client module cannot offer it. Keys are `not-prose`: inside article
// typography they keep their own look, never a frame around the whole shortcut. Two
// materials (`material`): plain, a key with a deeper foot; glass, a small key of the
// material's glass (inside a glass tooltip or menu they are glass on their own).

import { cn } from "@/lib/utils"
import {
  capLabel,
  keyName,
  keys,
  type Platform,
  withKeys,
} from "@/registry/base-nova/lib/keys"
import { type Material, useMaterial } from "@/registry/base-nova/ui/material"
import { glassVariants } from "@/registry/base-nova/ui/material-glass"

/** One key. Every measure is in em, so a cap is the same shape at any size: inline
 *  in a sentence or large over a stage. The Mac's own face draws ⌃⌥⌘ as macOS does. */
const CAP =
  "inline-flex min-w-[1.8em] items-center justify-center rounded-[0.32em] px-[0.45em] py-[0.32em] text-[0.8em] font-medium leading-none [font-family:-apple-system,BlinkMacSystemFont,system-ui,sans-serif]"

/** Plain, a key with its deeper foot; glass, a small key of the material's glass,
 *  its rim the edge a plain key draws as a border. */
const CAP_FACE: Record<Material, string> = {
  plain: "border border-b-2 border-border bg-muted text-foreground/90",
  glass: cn(
    glassVariants({ shape: "surface" }),
    "text-current [--glass-blur:8px] [--glass-radius:0.32em]",
  ),
}

/** One cap: `cap` is the key as lib/keys names it (⌘, ⇧, K). `platform` prints it
 *  as that system does ("Ctrl" off a Mac, `shortcut` passes it); without one it is
 *  the Mac's glyph. The one cap every shortcut is drawn with, so a key looks the same
 *  wherever it appears. */
export function KbdCap({
  cap,
  platform = "mac",
  material,
}: {
  cap: string
  platform?: Platform
  material?: Material
}) {
  const m = useMaterial(material)
  return (
    <kbd
      data-slot="kbd-cap"
      data-material={m}
      title={keyName(cap, platform)}
      className={cn(CAP, CAP_FACE[m])}
    >
      {capLabel(cap, platform)}
    </kbd>
  )
}

function Caps({ caps, material }: { caps: string[]; material?: Material }) {
  return (
    <span className="inline-flex gap-[0.25em]">
      {caps.map((cap, i) => (
        // biome-ignore lint/suspicious/noArrayIndexKey: caps repeat (⌘ ⌘), position is identity
        <KbdCap key={i} cap={cap} material={material} />
      ))}
    </span>
  )
}

export function Kbd({
  keys: text,
  material,
  className,
}: {
  keys: string
  material?: Material
  className?: string
}) {
  const combos = keys(text)
  if (!combos) throw new Error(`Kbd: "${text}" is not a keyboard shortcut`)
  return (
    <kbd
      data-slot="kbd"
      className={cn(
        "not-prose inline-flex gap-[0.5em] align-[-0.12em] font-[inherit] not-italic",
        className,
      )}
    >
      {combos.map((caps, i) => (
        // biome-ignore lint/suspicious/noArrayIndexKey: a sequence is positional
        <Caps key={i} caps={caps} material={material} />
      ))}
    </kbd>
  )
}

export function KeysText({
  children,
  material,
}: {
  children: string
  material?: Material
}) {
  return (
    <>
      {withKeys(children).map((part, i) =>
        "keys" in part ? (
          <kbd
            // biome-ignore lint/suspicious/noArrayIndexKey: a sentence is positional
            key={i}
            data-slot="kbd"
            className="not-prose inline-flex align-[-0.12em] font-[inherit] not-italic"
          >
            <Caps caps={part.keys} material={material} />
          </kbd>
        ) : (
          // biome-ignore lint/suspicious/noArrayIndexKey: a sentence is positional
          <span key={i}>{part.text}</span>
        ),
      )}
    </>
  )
}