Components

Getting started

Components144

More

A keyboard shortcut written for the reader's keyboard (⌘ on a Mac, Ctrl elsewhere) whose keys sink when you press them for real.

Used forMenusCommand palettesHelp and onboardingTooltips

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/kbd.json

Templates

Need the whole page?

43 Next.js landing pages, each designed from scratch. Try any of them live; one is $39, all of them $99, and 5 are free.

All 43 templates

Where it’s used

  • Menus

    Shortcuts at the end of each row.

  • Command palettes

    The ⌘K hint in a search field.

  • Help and onboarding

    A list of keys to learn.

  • Tooltips

    The key after the name.

What it handles for you

Showing ⌘ to Windows users is a small thing people notice.

Instead of wiring upplatform detectionkeycap styling
  1. Right platform

    "mod" is ⌘ or Ctrl; alt is ⌥ or Alt.

  2. Live keys

    Keys sink 1px while held.

  3. Readable

    Screen readers hear words ("Command K"), not symbols.

  4. Two sizes

    20px or 24px keycaps.

Overview

Shortcut hints are usually hard-coded to one platform. Here "mod" reads ⌘ on a Mac and Ctrl elsewhere, modifiers get their symbols on a Mac, and with live on, each key sinks while you hold it, so the hint answers the keyboard.

States

rest
Hairline keycaps with a bottom edge.
pressed
With live, the key sinks 1px and darkens while held.

Usage

usage.tsx
import { Kbd } from "@/components/ui/kbd";<Kbd keys="mod+k" /><Kbd keys={["shift", "mod", "p"]} live />

Props

PropTypeDescription
keysstring[] | stringKeys, or "mod+shift+p".
livebooleandefault falseKeys sink while held.
size"sm" | "md"default "md"20px or 24px.

Accessibility and motion

  • The platform is read after hydration, so the server renders the Mac form.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

Source

components/kbd.tsx
"use client";import * as React from "react";/* ───────────────────────────────────────────────────────── * KBD: a keyboard shortcut, written for the reader's keyboard * *   platform  "mod" reads ⌘ on a Mac and Ctrl elsewhere; alt, *             shift and enter get their symbols on a Mac *   press     with `live`, each key sinks when you press it for *             real, so a shortcut hint answers the keyboard * ───────────────────────────────────────────────────────── */export interface KbdProps extends Omit<React.HTMLAttributes<HTMLElement>, "children"> {  /** Keys, e.g. ["mod", "k"] or "mod+shift+p". */  keys: string[] | string;  /** Keys sink while they're held down. */  live?: boolean;  size?: "sm" | "md";}const MAC: Record<string, string> = { mod: "⌘", cmd: "⌘", meta: "⌘", ctrl: "⌃", control: "⌃", alt: "⌥", option: "⌥", shift: "⇧", enter: "↵", return: "↵", backspace: "⌫", escape: "Esc", esc: "Esc", tab: "⇥" };const PC: Record<string, string> = { mod: "Ctrl", cmd: "Win", meta: "Win", ctrl: "Ctrl", control: "Ctrl", alt: "Alt", option: "Alt", shift: "Shift", enter: "Enter", return: "Enter", backspace: "Backspace", escape: "Esc", esc: "Esc", tab: "Tab" };const ARROWS: Record<string, string> = { up: "↑", down: "↓", left: "←", right: "→", arrowup: "↑", arrowdown: "↓", arrowleft: "←", arrowright: "→" };const subscribeNoop = () => () => {};const isMac = () => /Mac|iPhone|iPad/.test(navigator.platform);function matches(key: string, e: KeyboardEvent, mac: boolean) {  const k = key.toLowerCase();  if (k === "mod") return mac ? e.key === "Meta" : e.key === "Control";  if (k === "cmd" || k === "meta") return e.key === "Meta";  if (k === "ctrl" || k === "control") return e.key === "Control";  if (k === "alt" || k === "option") return e.key === "Alt";  if (k === "shift") return e.key === "Shift";  if (k === "esc") return e.key === "Escape";  if (k in ARROWS) return e.key.toLowerCase() === (k.startsWith("arrow") ? k : `arrow${k}`);  return e.key.toLowerCase() === k;}export function Kbd({ keys, live, size = "md", className = "", ...props }: KbdProps) {  const mac = React.useSyncExternalStore(subscribeNoop, isMac, () => true);  const list = Array.isArray(keys) ? keys : keys.split("+");  const [down, setDown] = React.useState<string[]>([]);  React.useEffect(() => {    if (!live) return;    const onDown = (e: KeyboardEvent) => setDown((d) => [...new Set([...d, ...list.filter((k) => matches(k, e, mac))])]);    const onUp = (e: KeyboardEvent) => setDown((d) => d.filter((k) => !matches(k, e, mac)));    const clear = () => setDown([]);    window.addEventListener("keydown", onDown);    window.addEventListener("keyup", onUp);    window.addEventListener("blur", clear);    return () => {      window.removeEventListener("keydown", onDown);      window.removeEventListener("keyup", onUp);      window.removeEventListener("blur", clear);    };    // list is derived from keys    // eslint-disable-next-line react-hooks/exhaustive-deps  }, [live, mac, list.join("+")]);  const label = (k: string) => {    const l = k.toLowerCase();    return ARROWS[l] ?? (mac ? MAC[l] : PC[l]) ?? (k.length === 1 ? k.toUpperCase() : k);  };  // Words for screen readers, not symbols.  const spoken = (k: string) => {    const l = k.toLowerCase();    const words: Record<string, string> = { mod: mac ? "Command" : "Control", cmd: "Command", meta: "Command", ctrl: "Control", alt: mac ? "Option" : "Alt", option: "Option", shift: "Shift" };    return words[l] ?? (PC[l] ? PC[l] : l.startsWith("arrow") || l in ARROWS ? `${l.replace("arrow", "")} arrow` : label(k));  };  return (    <kbd className={`inline-flex items-center gap-1 font-sans ${className}`} aria-label={list.map(spoken).join(" ")} {...props}>      {list.map((k, i) => {        const pressed = down.includes(k);        return (          <span            key={k + i}            aria-hidden="true"            className={`inline-flex items-center justify-center rounded-[6px] bg-background font-medium text-muted-foreground transition-[translate,box-shadow,color] duration-100 ${              size === "sm" ? "h-5 min-w-5 px-1 text-[11px]" : "h-6 min-w-6 px-1.5 text-[12px]"            } ${              pressed                ? "translate-y-px text-foreground shadow-[inset_0_0_0_1px_var(--border)]"                : "shadow-[inset_0_0_0_1px_var(--border),inset_0_-1px_0_var(--border)]"            }`}          >            {label(k)}          </span>        );      })}    </kbd>  );}export default Kbd;

Registry item: /r/kbd.json