keyboard shortcuts drawn as keys, from any notation
npx shadcn add @ag/kbdAlready installed? Preview an update
npx shadcn add @ag/kbd --dry-runYour 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 callingisKeysto choose between keys and code failed to prerender. Migration:keys(text) !== nullfromlib/keys, which runs anywhere.
also called keycap
shelf controls
used by tooltip, components, search, verdict, form, shortcut, answer-keys, player, timeline, macos
the demo above, verbatim
"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
"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>
),
)}
</>
)
}