Keyboard shortcut hints drawn as keycaps. Labels follow the visitor's platform, and the caps press down while the real keys are held.
Hold the keys to press the caps.
pnpm dlx shadcn@latest add @latheui/kbdFirst time? Run npx latheui init once to add the @latheui registry. The CLI then writes the source into your project and installs what it needs. Or add it by hand:
Copy the source into your project.
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
// ---- keys -------------------------------------------------------------------
export type Platform = "mac" | "other"
type KeyInfo = {
/** What the key reports, normalized: `KeyboardEvent.key` lowercased, letters from `code`. */
id: string
mac: string
other: string
/** Spoken name, for screen readers. */
name: { mac: string; other: string }
}
const named = (id: string, mac: string, other: string, macName: string, otherName = macName): KeyInfo => ({
id,
mac,
other,
name: { mac: macName, other: otherName },
})
const keyTable: Record<string, KeyInfo> = {}
const define = (aliases: string[], info: KeyInfo) => aliases.forEach((alias) => (keyTable[alias] = info))
define(["cmd", "command", "meta", "⌘"], named("meta", "⌘", "⌘", "Command"))
define(["ctrl", "control", "⌃"], named("control", "⌃", "Ctrl", "Control"))
define(["alt", "option", "opt", "⌥"], named("alt", "⌥", "Alt", "Option", "Alt"))
define(["shift", "⇧"], named("shift", "⇧", "Shift", "Shift"))
define(["enter", "return", "↵", "↩"], named("enter", "↵", "↵", "Return", "Enter"))
define(["esc", "escape"], named("escape", "Esc", "Esc", "Escape"))
define(["backspace", "⌫"], named("backspace", "⌫", "Backspace", "Delete", "Backspace"))
define(["delete", "del", "⌦"], named("delete", "⌦", "Del", "Forward delete", "Delete"))
define(["tab", "⇥"], named("tab", "Tab", "Tab", "Tab"))
define(["space", "spacebar"], named(" ", "Space", "Space", "Space"))
define(["up", "arrowup", "↑"], named("arrowup", "↑", "↑", "Up arrow"))
define(["down", "arrowdown", "↓"], named("arrowdown", "↓", "↓", "Down arrow"))
define(["left", "arrowleft", "←"], named("arrowleft", "←", "←", "Left arrow"))
define(["right", "arrowright", "→"], named("arrowright", "→", "→", "Right arrow"))
define(["pageup", "pgup"], named("pageup", "PgUp", "PgUp", "Page up"))
define(["pagedown", "pgdn"], named("pagedown", "PgDn", "PgDn", "Page down"))
define(["home"], named("home", "Home", "Home", "Home"))
define(["end"], named("end", "End", "End", "End"))
define(["capslock", "⇪"], named("capslock", "⇪", "Caps", "Caps lock"))
define(["plus"], named("+", "+", "+", "Plus"))
const modSymbols = new Set(["⌘", "⌃", "⌥", "⇧"])
/** The key a token names. `mod` is Command on Apple platforms and Control elsewhere. */
function resolveKey(token: string, platform: Platform): KeyInfo {
const lower = token.toLowerCase()
if (lower === "mod") return keyTable[platform === "mac" ? "cmd" : "ctrl"]
if (keyTable[lower]) return keyTable[lower]
if (/^f\d{1,2}$/.test(lower)) return named(lower, token.toUpperCase(), token.toUpperCase(), token.toUpperCase())
const label = token.length === 1 ? token.toUpperCase() : token[0].toUpperCase() + token.slice(1)
return named(lower, label, label, label)
}
/**
* Splits a shortcut into steps, each a set of keys pressed together.
* `"mod+shift+p"` is one step of three keys, `"g d"` is G then D, and `"⌘K"` reads as ⌘ and K.
* An array is one step, one key per entry.
*/
export function parseShortcut(keys: string | string[], platform: Platform = "mac"): KeyInfo[][] {
if (Array.isArray(keys)) return [keys.map((key) => resolveKey(key, platform))]
return keys
.trim()
.split(/\s+/)
.filter(Boolean)
.map((step) => {
const tokens = step === "+" ? ["+"] : step.split("+").filter(Boolean)
// Peel leading modifier symbols off a compact token such as "⌘⇧P".
const expanded = tokens.flatMap((token) => {
const out: string[] = []
let rest = token
while (rest.length > 1 && modSymbols.has(rest[0])) {
out.push(rest[0])
rest = rest.slice(1)
}
return [...out, rest]
})
return expanded.map((token) => resolveKey(token, platform))
})
}
/** The key id an event reports, comparable with `parseShortcut` ids. Letters and digits ignore layout modifiers. */
export function eventKeyId(event: KeyboardEvent | React.KeyboardEvent) {
const { code, key } = event
if (code?.startsWith("Key")) return code.slice(3).toLowerCase()
if (code?.startsWith("Digit")) return code.slice(5)
return key.toLowerCase()
}
/**
* Whether a key event is exactly the one-step shortcut, for example `"mod+z"`. Modifiers must match,
* so `"mod+z"` does not fire for Shift+Cmd+Z.
*/
export function matchesShortcut(
event: KeyboardEvent | React.KeyboardEvent,
keys: string | string[],
platform: Platform = getPlatform()
) {
const [step] = parseShortcut(keys, platform)
if (!step) return false
const ids = new Set(step.map((key) => key.id))
const modifiers = { meta: event.metaKey, control: event.ctrlKey, alt: event.altKey, shift: event.shiftKey }
for (const [id, down] of Object.entries(modifiers)) if (ids.has(id) !== down) return false
const main = step.filter((key) => !(key.id in modifiers))
return main.length === 1 && main[0].id === eventKeyId(event)
}
/** The shortcut as plain text in the platform's style, for example "⌘K" or "Ctrl+K". */
export function formatShortcut(keys: string | string[], platform: Platform = getPlatform()) {
return parseShortcut(keys, platform)
.map((step) => step.map((key) => key[platform]).join(platform === "mac" ? "" : "+"))
.join(" ")
}
// ---- platform -----------------------------------------------------------------
function getPlatform(): Platform {
if (typeof navigator === "undefined") return "mac"
const nav = navigator as Navigator & { userAgentData?: { platform?: string } }
const name = nav.userAgentData?.platform || nav.platform || nav.userAgent
return /mac|iphone|ipad|ipod/i.test(name) ? "mac" : "other"
}
const noop = () => () => {}
/**
* The visitor's platform. The server and the first client render assume Apple, then it settles,
* so markup never mismatches.
*/
export function usePlatform(): Platform {
return React.useSyncExternalStore(noop, getPlatform, () => "mac")
}
// ---- held keys ------------------------------------------------------------------
// One set of listeners for every keycap on the page.
const held = new Set<string>()
const holders = new Set<() => void>()
const notify = () => holders.forEach((listener) => listener())
function onKeyDown(event: KeyboardEvent) {
const id = eventKeyId(event)
if (held.has(id)) return
held.add(id)
notify()
}
function onKeyUp(event: KeyboardEvent) {
// macOS swallows the keyup of anything pressed with Command, so letting go of it releases all.
if (event.key === "Meta") held.clear()
else held.delete(eventKeyId(event))
notify()
}
function release() {
if (!held.size) return
held.clear()
notify()
}
function subscribeHeld(listener: () => void) {
if (!holders.size) {
window.addEventListener("keydown", onKeyDown, true)
window.addEventListener("keyup", onKeyUp, true)
window.addEventListener("blur", release)
}
holders.add(listener)
return () => {
holders.delete(listener)
if (holders.size) return
window.removeEventListener("keydown", onKeyDown, true)
window.removeEventListener("keyup", onKeyUp, true)
window.removeEventListener("blur", release)
held.clear()
}
}
/** Whether a key is held down right now. */
export function useKeyHeld(id: string, enabled = true) {
return React.useSyncExternalStore(
enabled ? subscribeHeld : noop,
() => enabled && held.has(id),
() => false
)
}
// ---- view -----------------------------------------------------------------------
const variants = {
/** A keycap: border, a darker bottom edge, and it sinks when pressed. */
outline:
"border border-border bg-background text-muted-foreground shadow-[inset_0_-1px_0_var(--border)] data-pressed:translate-y-px data-pressed:bg-accent data-pressed:text-foreground data-pressed:shadow-none",
/** A tint of the surrounding text color, so it sits on any background, such as a tooltip. */
soft: "bg-current/10 data-pressed:translate-y-px data-pressed:bg-current/20",
/** Text only, for menus and dense lists. */
ghost: "px-0 min-w-0 text-muted-foreground data-pressed:text-foreground",
} as const
const sizes = {
sm: "h-4.5 min-w-4.5 rounded-[4px] px-1 text-[10px]",
md: "h-5 min-w-5 rounded-[5px] px-1.5 text-[11px]",
lg: "h-6 min-w-6 rounded-md px-1.5 text-xs",
} as const
export type KbdProps = Omit<React.ComponentProps<"kbd">, "children"> & {
/**
* The shortcut. `"mod+k"`, `"shift+?"`, `"g d"` for a sequence, or `["⌘", "K"]`. `mod` shows
* Command on Apple platforms and Ctrl elsewhere.
*/
keys?: string | string[]
/** A single key written out, used when `keys` is not given. */
children?: string
variant?: keyof typeof variants
size?: keyof typeof sizes
/** Press the keycaps while the matching keys are held. */
listen?: boolean
/** Forces Apple or other labels. Detected by default. */
platform?: Platform
/** Classes for each keycap. */
keyClassName?: string
}
function Key({
info,
platform,
listen,
spoken,
className,
}: {
info: KeyInfo
platform: Platform
listen: boolean
/** Give symbols a spoken name. Off when the whole hint is hidden from assistive tech. */
spoken: boolean
className: string
}) {
const pressed = useKeyHeld(info.id, listen)
const label = info[platform]
const name = info.name[platform]
return (
<kbd
data-slot="kbd-key"
data-pressed={pressed || undefined}
className={cn(
"inline-flex items-center justify-center font-sans leading-none font-medium tabular-nums transition-[translate,background-color,color,box-shadow] duration-75 ease-out motion-reduce:translate-y-0!",
// Words like "Shift" or "Esc" read better a touch smaller than single symbols.
label.length > 1 && "tracking-tight",
className
)}
>
{spoken && name !== label ? (
<>
<span className="sr-only">{name}</span>
<span aria-hidden="true">{label}</span>
</>
) : (
label
)}
</kbd>
)
}
/**
* A keyboard shortcut drawn as keycaps. Labels follow the visitor's platform, and the caps press
* down while the real keys are held.
*/
function Kbd({
keys,
children,
variant = "outline",
size = "md",
listen = true,
platform: platformProp,
keyClassName,
className,
...props
}: KbdProps) {
const detected = usePlatform()
const platform = platformProp ?? detected
const steps = parseShortcut(keys ?? children ?? "", platform)
const keyClass = cn(sizes[size], variants[variant], keyClassName)
const spoken = !props["aria-hidden"]
return (
<kbd
data-slot="kbd"
className={cn(
"inline-flex shrink-0 items-center align-middle font-sans whitespace-nowrap",
"gap-1.5",
className
)}
{...props}
>
{steps.map((step, index) => (
<React.Fragment key={index}>
{index > 0 && spoken && <span className="sr-only">then</span>}
<span className={cn("inline-flex items-center", variant === "ghost" ? "gap-0.5" : "gap-[3px]")}>
{step.map((info, keyIndex) => (
<React.Fragment key={keyIndex}>
{/* Text-only hints join keys the way each platform writes them: ⌘K, but Ctrl+K. */}
{keyIndex > 0 && variant === "ghost" && platform === "other" && <span aria-hidden="true">+</span>}
<Key info={info} platform={platform} listen={listen} spoken={spoken} className={keyClass} />
</React.Fragment>
))}
</span>
</React.Fragment>
))}
</kbd>
)
}
export { Kbd }Also accepts every native kbd attribute.
| Prop | Type | Default | Description |
|---|---|---|---|
| keys | string | string[] | — | The shortcut. Join keys pressed together with +, such as "mod+shift+p", and separate steps of a sequence with a space, such as "g d". An array is one combination, one key per entry. Compact strings such as "⌘K" work too. |
| children | string | — | A single key written out, used when keys is not given. |
| variant | "outline" | "soft" | "ghost" | "outline" | Keycap, tinted, or text only. |
| size | "sm" | "md" | "lg" | "md" | Cap height: 18, 20 or 24 pixels. |
| listen | boolean | true | Presses each cap while its key is held. All caps on a page share one set of listeners. |
| platform | "mac" | "other" | detected | Forces Apple or other labels, for example in a screenshot. |
| keyClassName | string | — | Classes for each cap. |
Case does not matter. Anything else is shown as written, with single letters capitalized.
| Prop | Type | Default | Description |
|---|---|---|---|
| mod | ⌘ / Ctrl | — | Command on Apple platforms, Control elsewhere. Use it for cross-platform shortcuts. |
| cmd, ctrl, alt, shift | ⌘ ⌃ ⌥ ⇧ | — | Specific modifiers. On Windows and Linux, ctrl, alt and shift are written out. |
| enter, esc, tab, space | ↵ Esc Tab Space | — | Common keys. return and escape work too. |
| backspace, delete | ⌫ ⌦ | — | Written out on Windows and Linux. |
| up, down, left, right | ↑ ↓ ← → | — | Arrow keys. |
| plus | + | — | The plus key, which can't be written inside a combination. |
Exported alongside Kbd for binding the same shortcut strings.
| Prop | Type | Default | Description |
|---|---|---|---|
| matchesShortcut(event, keys) | boolean | — | Whether a key event is exactly the shortcut, such as "mod+z". Extra modifiers do not match. |
| formatShortcut(keys) | string | — | The shortcut as text in the platform's style: "⌘K" or "Ctrl+K". Useful for titles. |
| usePlatform() | "mac" | "other" | — | The visitor's platform. Starts as mac on the server and the first render, so markup never mismatches. |
import { Kbd, matchesShortcut } from "@/components/ui/kbd"
React.useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (matchesShortcut(event, "mod+s")) {
event.preventDefault()
save()
}
}
window.addEventListener("keydown", onKeyDown)
return () => window.removeEventListener("keydown", onKeyDown)
}, [])
<Button onClick={save}>
Save <Kbd keys="mod+s" variant="soft" size="sm" />
</Button><Kbd keys="mod+k" keyClassName="rounded-full bg-muted px-2 border-transparent shadow-none" />