# Kbd

> Key caps for shortcuts that show the right symbols on every platform, read them out properly and press down with the real keys.

Docs: https://hextaui.com/docs/kbd
Markdown: https://hextaui.com/docs/kbd.md

```tsx title="components/examples/kbd/demo.tsx"
import { Kbd, KbdGroup } from "@/components/ui/kbd"

export function KbdDemo() {
  return (
    <div className="flex flex-col items-center gap-6 text-sm text-muted-foreground">
      <KbdGroup keys="mod+shift+p" size="lg" listen />
      <p>
        Press <Kbd keys="mod+k" listen /> to search, or hold{" "}
        <Kbd keys="shift" listen /> and watch the keys.
      </p>
    </div>
  )
}
```

## Installation

### CLI

```bash
npx shadcn@latest add https://hextaui.com/r/kbd.json
```

This adds the component, the HextaUI theme tokens and any HextaUI components it depends on.

### Manual

Add the HextaUI theme tokens (https://hextaui.com/docs/installation#theme) to your global CSS if you haven't yet, then install the dependencies.

```bash
pnpm add @base-ui/react class-variance-authority cn
```

Copy and paste the following code into your project.

```tsx title="components/ui/kbd.tsx"
"use client"

import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"

import { useHeldKeys } from "@/hooks/use-held-keys"
import { keyLabel, parseHotkey, spokenKey, useIsApple } from "@/lib/hotkey"

function resolveKey(key: string, apple: boolean) {
  if (key === "mod") {
    return apple ? "meta" : "ctrl"
  }
  return key
}

const kbdVariants = cva(
  "inline-flex w-fit shrink-0 items-center justify-center gap-0.5 font-sans font-medium whitespace-nowrap tabular-nums transition-[translate,box-shadow,background-color,color] duration-100 ease-out-cubic select-none [unicode-bidi:isolate] motion-reduce:transition-colors [&_svg]:pointer-events-none [&_svg]:shrink-0",
  {
    variants: {
      variant: {
        keycap:
          "bg-background text-muted-foreground shadow-[0_1px_0_0_var(--color-border)] inset-ring-(length:--hairline) inset-ring-border in-data-[slot=button]:bg-current/10 in-data-[slot=button]:text-current in-data-[slot=button]:shadow-none in-data-[slot=button]:inset-ring-current/20 data-pressed:bg-muted data-pressed:text-foreground data-pressed:shadow-none motion-safe:data-pressed:translate-y-px dark:not-in-data-[slot=button]:bg-muted/60 dark:data-pressed:bg-muted forced-colors:border",
        flat: "bg-muted text-muted-foreground in-data-highlighted:bg-background in-data-[slot=button]:bg-current/10 in-data-[slot=button]:text-current data-pressed:bg-foreground/15 data-pressed:text-foreground dark:in-data-highlighted:bg-foreground/10",
      },
      size: {
        sm: "h-4.5 min-w-4.5 rounded-[calc(var(--radius-sm)*0.6)] px-1 text-xs [&_svg:not([class*='size-'])]:size-2.5",
        default:
          "h-5 min-w-5 rounded-[calc(var(--radius-sm)*0.75)] px-1 text-xs [&_svg:not([class*='size-'])]:size-3",
        lg: "h-6 min-w-6 rounded-sm px-1.5 text-sm [&_svg:not([class*='size-'])]:size-3.5",
      },
    },
    defaultVariants: {
      variant: "keycap",
      size: "default",
    },
  }
)

type KbdVariant = NonNullable<VariantProps<typeof kbdVariants>["variant"]>
type KbdSize = NonNullable<VariantProps<typeof kbdVariants>["size"]>

type KbdGroupContextValue = {
  variant?: KbdVariant
  size?: KbdSize
  listen?: boolean
}

const KbdGroupContext = React.createContext<KbdGroupContextValue | null>(null)

function KeyName({ keys, apple }: { keys: string[]; apple: boolean }) {
  const visible = keys.map((key) => keyLabel(key, apple))
  const spoken = keys.map((key) => spokenKey(key, apple)).join(" ")
  const label = apple ? visible.join("") : visible.join("+")

  if (label === spoken) {
    return label
  }

  return (
    <>
      <span aria-hidden="true">{label}</span>
      <span className="sr-only">{spoken}</span>
    </>
  )
}

type KbdProps = useRender.ComponentProps<"kbd"> & {
  variant?: KbdVariant
  size?: KbdSize
  keys?: string
  listen?: boolean
}

function Kbd({
  className,
  variant,
  size,
  keys,
  listen,
  render,
  children,
  ...props
}: KbdProps) {
  const group = React.useContext(KbdGroupContext)
  const resolvedVariant = variant ?? group?.variant ?? "keycap"
  const resolvedSize = size ?? group?.size ?? "default"
  const listening = listen ?? group?.listen ?? false
  const apple = useIsApple()
  const pressedKeys = useHeldKeys(listening)

  const chord = keys ? (parseHotkey(keys)[0] ?? []) : null
  const own =
    chord ??
    (typeof children === "string" && children.trim()
      ? parseHotkey(children.trim().replace(/\s+/g, "+"))[0]
      : null)
  const pressed =
    listening &&
    own !== null &&
    own.length > 0 &&
    own.every((key) => pressedKeys.has(resolveKey(key, apple)))

  return useRender({
    defaultTagName: "kbd",
    render,
    props: mergeProps<"kbd">(
      {
        className: cn(
          kbdVariants({ variant: resolvedVariant, size: resolvedSize }),
          className
        ),
        children: chord ? <KeyName keys={chord} apple={apple} /> : children,
      },
      props,
      {
        "data-slot": "kbd",
        "data-variant": resolvedVariant,
        "data-size": resolvedSize,
        ...(pressed ? { "data-pressed": "" } : {}),
      } as React.ComponentProps<"kbd">
    ),
  })
}

type KbdGroupProps = useRender.ComponentProps<"kbd"> & {
  variant?: KbdVariant
  size?: KbdSize
  keys?: string
  listen?: boolean
  separator?: React.ReactNode
}

function KbdGroup({
  className,
  variant,
  size,
  keys,
  listen,
  separator = "then",
  render,
  children,
  ...props
}: KbdGroupProps) {
  const context = React.useMemo(
    () => ({ variant, size, listen }),
    [variant, size, listen]
  )
  const chords = keys ? parseHotkey(keys) : null

  const content = chords
    ? chords.map((chord, index) => (
        <React.Fragment key={index}>
          {index > 0 && (
            <span
              data-slot="kbd-separator"
              className="px-0.5 font-sans text-xs text-muted-foreground"
            >
              {separator}
            </span>
          )}
          {chord.map((key, keyIndex) => (
            <Kbd key={keyIndex} keys={key} />
          ))}
        </React.Fragment>
      ))
    : children

  const element = useRender({
    defaultTagName: "kbd",
    render,
    props: mergeProps<"kbd">(
      {
        className: cn(
          "inline-flex w-fit shrink-0 items-center gap-1 align-middle [unicode-bidi:isolate] rtl:flex-row-reverse",
          className
        ),
        children: content,
      },
      props,
      { "data-slot": "kbd-group" } as React.ComponentProps<"kbd">
    ),
  })

  return (
    <KbdGroupContext.Provider value={context}>
      {element}
    </KbdGroupContext.Provider>
  )
}

export { Kbd, KbdGroup, kbdVariants, useHeldKeys }
export type { KbdGroupProps, KbdProps }
```

```ts title="lib/hotkey.ts"
import * as React from "react"

function subscribePlatform() {
  return () => {}
}

function isApplePlatform() {
  if (typeof navigator === "undefined") {
    return true
  }
  return /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent)
}

function useIsApple() {
  return React.useSyncExternalStore(
    subscribePlatform,
    isApplePlatform,
    () => true
  )
}

const modifierOrder = ["ctrl", "alt", "shift", "mod", "meta"]

const appleKeys: Record<string, string> = {
  mod: "⌘",
  meta: "⌘",
  ctrl: "⌃",
  alt: "⌥",
  shift: "⇧",
  enter: "↵",
  backspace: "⌫",
  escape: "esc",
  arrowup: "↑",
  arrowdown: "↓",
  arrowleft: "←",
  arrowright: "→",
}

const otherKeys: Record<string, string> = {
  mod: "Ctrl",
  meta: "Win",
  ctrl: "Ctrl",
  alt: "Alt",
  shift: "Shift",
  enter: "Enter",
  backspace: "Backspace",
  escape: "Esc",
  arrowup: "↑",
  arrowdown: "↓",
  arrowleft: "←",
  arrowright: "→",
}

const sharedKeys: Record<string, string> = {
  space: "Space",
  tab: "Tab",
  delete: "Del",
  home: "Home",
  end: "End",
  pageup: "PgUp",
  pagedown: "PgDn",
}

const spokenKeys: Record<string, string> = {
  mod: "Command",
  meta: "Command",
  ctrl: "Control",
  alt: "Option",
  shift: "Shift",
  enter: "Enter",
  backspace: "Delete",
  escape: "Escape",
  arrowup: "Up arrow",
  arrowdown: "Down arrow",
  arrowleft: "Left arrow",
  arrowright: "Right arrow",
}

const otherSpokenKeys: Record<string, string> = {
  mod: "Control",
  meta: "Windows",
  alt: "Alt",
  backspace: "Backspace",
}

function normalizeKey(token: string) {
  const key = token.trim().toLowerCase()
  const aliases: Record<string, string> = {
    cmd: "meta",
    command: "meta",
    control: "ctrl",
    option: "alt",
    opt: "alt",
    return: "enter",
    esc: "escape",
    up: "arrowup",
    down: "arrowdown",
    left: "arrowleft",
    right: "arrowright",
    " ": "space",
  }
  return aliases[key] ?? key
}

function sortModifiers(parts: string[]) {
  const key = parts.at(-1) ?? ""
  const modifiers = parts
    .slice(0, -1)
    .sort((a, b) => modifierOrder.indexOf(a) - modifierOrder.indexOf(b))
  return [...modifiers, key]
}

function parseHotkey(hotkey: string) {
  return hotkey
    .trim()
    .split(/\s+/)
    .filter(Boolean)
    .map((chord) =>
      sortModifiers(
        chord
          .split(/\+(?!$)/)
          .map(normalizeKey)
          .filter(Boolean)
      )
    )
}

function keyLabel(key: string, apple: boolean) {
  const names = apple ? appleKeys : otherKeys
  return (
    names[key] ??
    sharedKeys[key] ??
    (key.length === 1 ? key.toUpperCase() : key[0].toUpperCase() + key.slice(1))
  )
}

function spokenKey(key: string, apple: boolean) {
  return (
    (!apple ? otherSpokenKeys[key] : undefined) ??
    spokenKeys[key] ??
    sharedKeys[key] ??
    keyLabel(key, apple)
  )
}

function parseChord(hotkey: string) {
  return parseHotkey(hotkey.replace(/\s+/g, ""))[0] ?? []
}

function formatHotkey(hotkey: string, apple: boolean) {
  const labels = parseChord(hotkey).map((part) => keyLabel(part, apple))
  return apple ? labels.join("") : labels.join("+")
}

function eventKeys(event: KeyboardEvent) {
  const keys = new Set([normalizeKey(event.key === " " ? "space" : event.key)])
  const physical = /^(?:Key([A-Z])|Digit([0-9]))$/.exec(event.code ?? "")
  if (physical) {
    keys.add((physical[1] ?? physical[2]).toLowerCase())
  }
  return keys
}

function matchesHotkey(event: KeyboardEvent, hotkey: string) {
  const parts = parseChord(hotkey)
  const key = parts.pop()
  const wants = new Set(parts)
  const apple = isApplePlatform()
  const meta = wants.has("meta") || (wants.has("mod") && apple)
  const ctrl = wants.has("ctrl") || (wants.has("mod") && !apple)

  return (
    key !== undefined &&
    eventKeys(event).has(key) &&
    event.metaKey === meta &&
    event.ctrlKey === ctrl &&
    event.altKey === wants.has("alt") &&
    event.shiftKey === wants.has("shift")
  )
}

export {
  formatHotkey,
  isApplePlatform,
  keyLabel,
  matchesHotkey,
  parseHotkey,
  spokenKey,
  useIsApple,
}
```

Update the import paths to match your project setup.

## Usage

```tsx
import { Kbd, KbdGroup } from "@/components/ui/kbd"
```

```tsx
<KbdGroup keys="mod+k" />
<Kbd keys="escape" />
<Kbd>K</Kbd>
```

Write shortcuts once with `keys` and they show as ⌘K on a Mac and Ctrl K on Windows and Linux. Or pass any content as children for full control.

## Composition

```text
Kbd

KbdGroup
└── Kbd
```

## Examples

### Variants

`keycap` has a hairline edge and a 1px lip so it reads as a physical key. `flat` is a quiet fill for dense places like menus.

```tsx title="components/examples/kbd/variants.tsx"
import { KbdGroup } from "@/components/ui/kbd"

export function KbdVariants() {
  return (
    <div className="flex flex-wrap items-center justify-center gap-6">
      <KbdGroup keys="mod+c" />
      <KbdGroup keys="mod+c" variant="flat" />
    </div>
  )
}
```

### Sizes

`sm` sits in small text, `default` next to body text and `lg` in headings or on its own.

```tsx title="components/examples/kbd/sizes.tsx"
import { KbdGroup } from "@/components/ui/kbd"

const sizes = ["sm", "default", "lg"] as const

export function KbdSizes() {
  return (
    <div className="flex flex-wrap items-center justify-center gap-6">
      {sizes.map((size) => (
        <KbdGroup key={size} keys="mod+shift+z" size={size} />
      ))}
    </div>
  )
}
```

### Shortcuts

`<KbdGroup />` splits a combo into one cap per key. A space starts a sequence, joined by `separator`. `<Kbd />` with `keys` keeps the whole combo in one cap.

```tsx title="components/examples/kbd/shortcuts.tsx"
import { Kbd, KbdGroup } from "@/components/ui/kbd"

const shortcuts = [
  { label: "Command palette", keys: "mod+k" },
  { label: "Move line up", keys: "alt+up" },
  { label: "Close", keys: "escape" },
  { label: "Go to dashboard", keys: "g d" },
]

export function KbdShortcuts() {
  return (
    <dl className="grid w-full max-w-xs grid-cols-[1fr_auto] items-center gap-x-6 gap-y-3 text-sm">
      {shortcuts.map((shortcut) => (
        <div key={shortcut.keys} className="contents">
          <dt className="text-muted-foreground">{shortcut.label}</dt>
          <dd>
            <KbdGroup keys={shortcut.keys} />
          </dd>
        </div>
      ))}
      <dt className="text-muted-foreground">Save, as one cap</dt>
      <dd>
        <Kbd keys="mod+s" />
      </dd>
    </dl>
  )
}
```

### Live keys

With `listen`, a cap presses down while its real key is held. It only watches, so it never blocks, delays or changes what you type. Letters match by physical key, so Option and Shift don't confuse them.

```tsx title="components/examples/kbd/listen.tsx"
import { Kbd, KbdGroup } from "@/components/ui/kbd"

const rows = ["qwertyuiop", "asdfghjkl", "zxcvbnm"]

export function KbdListen() {
  return (
    <KbdGroup listen size="lg" className="flex-col">
      {rows.map((row) => (
        <span key={row} className="flex gap-1">
          {row.split("").map((key) => (
            <Kbd key={key}>{key.toUpperCase()}</Kbd>
          ))}
        </span>
      ))}
      <span className="flex gap-1">
        <Kbd keys="shift" />
        <Kbd keys="space" className="min-w-40" />
        <Kbd keys="enter" />
      </span>
    </KbdGroup>
  )
}
```

### In a button

Inside a button, the cap takes its colors from the button's text, so it fits every variant.

```tsx title="components/examples/kbd/button.tsx"
import { IconSearch } from "@tabler/icons-react"

import { Button } from "@/components/ui/button"
import { Kbd } from "@/components/ui/kbd"

export function KbdButton() {
  return (
    <div className="flex flex-wrap items-center justify-center gap-3">
      <Button variant="outline">
        <IconSearch data-icon="inline-start" />
        Search
        <Kbd keys="mod+k" />
      </Button>
      <Button>
        Save
        <Kbd keys="mod+s" />
      </Button>
      <Button variant="ghost">
        Undo
        <Kbd keys="mod+z" variant="flat" />
      </Button>
    </div>
  )
}
```

### Icons and actions

Put icons in a cap for actions without a key name, and give them a visually hidden label.

```tsx title="components/examples/kbd/composed.tsx"
import { IconArrowBackUp, IconClick } from "@tabler/icons-react"

import { Kbd, KbdGroup } from "@/components/ui/kbd"

export function KbdComposed() {
  return (
    <div className="flex flex-col items-center gap-3 text-sm text-muted-foreground">
      <p className="flex items-center gap-2">
        <KbdGroup>
          <Kbd keys="shift" />
          <Kbd>
            <IconClick aria-hidden="true" />
            <span className="sr-only">Click</span>
          </Kbd>
        </KbdGroup>
        to select a range
      </p>
      <p className="flex items-center gap-2">
        <Kbd>
          <IconArrowBackUp aria-hidden="true" />
          <span className="sr-only">Backspace</span>
        </Kbd>
        to go back
      </p>
    </div>
  )
}
```

### Right to left

Shortcuts stay in left-to-right order inside right-to-left text, the way they're printed on the keyboard.

```tsx title="components/examples/kbd/rtl.tsx"
import { Kbd, KbdGroup } from "@/components/ui/kbd"

export function KbdRtl() {
  return (
    <p
      dir="rtl"
      className="flex items-center gap-2 text-sm text-muted-foreground"
    >
      اضغط
      <KbdGroup keys="mod+shift+p" />
      لفتح لوحة الأوامر، أو <Kbd keys="escape" /> للإغلاق
    </p>
  )
}
```

## Accessibility

- Symbols like ⌘ and ⇧ are hidden from screen readers and replaced with their names, so `keys="mod+shift+p"` is read as "Command Shift P".
- Caps render as `<kbd>`, and a group nests them in another `<kbd>`, which is how HTML marks a key combination.
- Showing a shortcut doesn't bind it. Register the key handler yourself.
- The press effect is decoration, with a color change in place of movement when reduced motion is on. Before hydration, every platform sees the Apple symbols.

## API reference

Both parts render a `<kbd>` and accept `render` and its attributes.

### Kbd

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `keys` | `string` | – | Keys joined with +, like "mod+shift+p". mod is ⌘ on Apple devices and Ctrl elsewhere. Names such as alt, enter, escape, up and space become symbols or short words, and get a spoken name for screen readers. |
| `variant` | `"keycap" \| "flat"` | `"keycap"` | Inherited from KbdGroup when unset. |
| `size` | `"sm" \| "default" \| "lg"` | `"default"` | Inherited from KbdGroup when unset. |
| `listen` | `boolean` | `false` | Press the cap while its real key is held. Works with keys or a plain key name as children. |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<kbd>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="kbd"` | Target caps in CSS. |
| `data-variant` | The current variant. |
| `data-size` | The current size. |
| `data-pressed` | Present while the real key is held. |

### KbdGroup

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `keys` | `string` | – | Keys joined with +, like "mod+shift+p". mod is ⌘ on Apple devices and Ctrl elsewhere. Names such as alt, enter, escape, up and space become symbols or short words, and get a spoken name for screen readers. Spaces separate the steps of a sequence. |
| `separator` | `ReactNode` | `"then"` | Shown between the steps of a sequence. |
| `variant` | `"keycap" \| "flat"` | – | Passed to every cap inside. |
| `size` | `"sm" \| "default" \| "lg"` | – | Passed to every cap inside. |
| `listen` | `boolean` | – | Passed to every cap inside. |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<kbd>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="kbd-group"` | Target groups in CSS. |
| `data-slot="kbd-separator"` | The text between sequence steps. |

## Notes for AI assistants

- Install a component with the shadcn CLI: `npx shadcn@latest add https://hextaui.com/r/<name>.json`. It adds the source, the HextaUI theme tokens and any HextaUI components it depends on. `https://hextaui.com/r/all.json` installs every component.
- The code is then owned by the project, like shadcn/ui. There is no HextaUI npm package. HextaUI is MIT licensed and free for personal and commercial use.
- Behavior and accessibility come from Base UI (`@base-ui/react`). Compose with the `render` prop, not `asChild`.
- Styling uses Tailwind CSS v4 with theme tokens. Merge classes with `cn` from the `cn` package.
- Icons come from `@tabler/icons-react`.
- Import components from `@/components/ui/<name>`, hooks from `@/hooks/<name>` and utilities from `@/lib/<name>`.

Every HextaUI doc: https://hextaui.com/llms.txt
