Skip to content
UI Kit
Accent color

Keyboard keys

Key caps and shortcuts that show Command on Apple devices and Ctrl elsewhere, and press down with the real key.

One of 5 components in Data display

3 variants

Variants

In text and menus

Keys inside sentences and as hints in a command menu.

Cheat sheet

All shortcuts in three groups, as a definition list.

Sizes and styles

Default, outline and ghost in three sizes, and sequences.

Details

What Keyboard keys brings along, read from the registry and the source.

Category
Data display
Variants
3
Version
1.0.0 · since 1.0.0
File
components/ui/kbd.tsx
Brings along
utilskiticon
npm packages
None
Languages
English and German, 15 texts of its own
Studio
Adjust in code

Props

The ShortcutProps interface from components/ui/kbd.tsx, with the comments from the code.

  • keysrequired
    string[]

    Keys pressed together, e.g. ['mod', 'k']. Special names: mod, shift, alt, enter, esc, backspace, tab, space, up, down, left, right.

  • modeoptional
    'together' | 'sequence'

    together: keys at the same time. sequence: one after the other ("G then D").

  • sizeoptional
    KbdSize

    smmdlg

  • variantoptional
    KbdVariant

    defaultoutlineghost

Built-in texts

Keyboard keys writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 15 texts
thenEnglish thenGerman dann
commandEnglish CommandGerman Befehl
controlEnglish ControlGerman Steuerung
shiftEnglish ShiftGerman Umschalt
optionEnglish OptionGerman Wahl
altEnglish AltGerman Alt
enterEnglish EnterGerman Eingabe
escapeEnglish EscapeGerman Escape
backspaceEnglish BackspaceGerman Rücktaste
tabEnglish TabGerman Tab
spaceEnglish SpaceGerman Leertaste
upEnglish Arrow upGerman Pfeil hoch
downEnglish Arrow downGerman Pfeil runter
leftEnglish Arrow leftGerman Pfeil links
rightEnglish Arrow rightGerman Pfeil rechts

Accessibility

Read from the source of the component.

  • Keyboard keys handles the keys Escape, Enter, Arrow up, Arrow down, Arrow left, Arrow right, Tab and Backspace itself.
  • Adds text for screen readers where icons and graphics stand alone.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.
  • No animations, it stands still from the start.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 230 lines
components/ui/kbd.tsx
'use client';

import { useEffect, useState, useSyncExternalStore } from 'react';
import { cn, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';

export type KbdSize = 'sm' | 'md' | 'lg';
export type KbdVariant = 'default' | 'outline' | 'ghost';

const SIZE: Record<KbdSize, string> = {
  sm: 'h-5 min-w-5 rounded-[4px] px-1 text-[11px]',
  md: 'h-6 min-w-6 rounded-[5px] px-1.5 text-[12px]',
  lg: 'h-8 min-w-8 rounded-[7px] px-2.5 text-[14px]',
};

const VARIANT: Record<KbdVariant, string> = {
  default: 'border border-border-strong bg-surface text-muted-foreground shadow-[inset_0_-1.5px_0_var(--border-strong),var(--highlight)]',
  outline: 'border border-border-strong bg-background text-foreground',
  ghost: 'bg-[color-mix(in_oklab,var(--foreground)_7%,transparent)] text-muted-foreground',
};

/* ------------------------------------------------------------------ */
/* Held keys                                                           */
/* ------------------------------------------------------------------ */

/*
 * Every key cap on the page reads one shared set of held keys, so a page
 * with fifty caps still has one pair of listeners. A cap whose key is held
 * on the real keyboard presses itself down. Names follow the Shortcut keys:
 * mod is Command on Apple devices and Control elsewhere.
 */
const held = new Set<string>();
const listeners = new Set<() => void>();
const FACE_NAME: Record<string, string> = {
  '⌘': 'cmd', '⌃': 'ctrl', '⇧': 'shift', '⌥': 'alt', '↵': 'enter', '⏎': 'enter', '⌫': 'backspace', '⇥': 'tab', '↑': 'up', '↓': 'down', '←': 'left', '→': 'right',
  esc: 'esc', escape: 'esc', return: 'enter', command: 'cmd', control: 'ctrl', strg: 'ctrl', option: 'alt', opt: 'alt',
};
const normal = (name: string) => {
  const n = name.trim().toLowerCase();
  return FACE_NAME[n] ?? FACE_NAME[name.trim()] ?? n;
};
function namesOf(e: KeyboardEvent, mac: boolean): string[] {
  switch (e.key) {
    case 'Meta': return mac ? ['cmd', 'mod'] : ['cmd'];
    case 'Control': return mac ? ['ctrl'] : ['ctrl', 'mod'];
    case 'Shift': return ['shift'];
    case 'Alt': return ['alt'];
    case 'Enter': return ['enter'];
    case 'Escape': return ['esc'];
    case 'Backspace': return ['backspace'];
    case 'Tab': return ['tab'];
    case ' ': return ['space'];
    case 'ArrowUp': return ['up'];
    case 'ArrowDown': return ['down'];
    case 'ArrowLeft': return ['left'];
    case 'ArrowRight': return ['right'];
    default: return e.key.length === 1 ? [e.key.toLowerCase()] : [e.key.toLowerCase()];
  }
}
function subscribe(cb: () => void) {
  listeners.add(cb);
  if (listeners.size === 1) {
    const mac = /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent);
    const emit = () => listeners.forEach((l) => l());
    const down = (e: KeyboardEvent) => {
      if (e.repeat) return;
      namesOf(e, mac).forEach((n) => held.add(n));
      emit();
    };
    const up = (e: KeyboardEvent) => {
      // With Command held, browsers on Apple devices skip the keyup of the letter: releasing Command lets go of everything
      if (e.key === 'Meta') held.clear();
      else namesOf(e, mac).forEach((n) => held.delete(n));
      emit();
    };
    const clear = () => {
      held.clear();
      emit();
    };
    document.addEventListener('keydown', down, true);
    document.addEventListener('keyup', up, true);
    window.addEventListener('blur', clear);
    stop = () => {
      document.removeEventListener('keydown', down, true);
      document.removeEventListener('keyup', up, true);
      window.removeEventListener('blur', clear);
    };
  }
  return () => {
    listeners.delete(cb);
    if (!listeners.size) stop?.();
  };
}
let stop: (() => void) | undefined;
const idle = () => () => {};

/** true while the named key is held on the real keyboard. Always false on the server. */
function useHeld(name: string | undefined) {
  return useSyncExternalStore(
    name ? subscribe : idle,
    () => (name ? held.has(name) : false),
    () => false,
  );
}

export interface KbdProps {
  size?: KbdSize;
  variant?: KbdVariant;
  /** The key this cap stands for, so it presses down with the real key, e.g. "k", "shift", "mod". Read from a text child by default. */
  keyName?: string;
  className?: string;
  children: React.ReactNode;
}

/** One key, drawn as a quiet key cap. It presses itself down while the same key is held on the keyboard. */
export function Kbd({ size = 'md', variant = 'default', keyName, className, children }: KbdProps) {
  const name = keyName ? normal(keyName) : typeof children === 'string' && children.length <= 6 ? normal(children) : undefined;
  const down = useHeld(name);
  return (
    <kbd
      data-pressed={down || undefined}
      className={cn(
        'inline-flex shrink-0 items-center justify-center whitespace-nowrap font-sans font-medium leading-none tracking-normal transition-[translate,scale,box-shadow,background-color,border-color,color] duration-150 ease-[var(--ease-kit)] motion-reduce:transition-none',
        SIZE[size],
        VARIANT[variant],
        'data-pressed:translate-y-px data-pressed:scale-[0.96] data-pressed:border-[color-mix(in_oklab,var(--primary)_55%,var(--border-strong))] data-pressed:bg-[color-mix(in_oklab,var(--primary)_14%,var(--surface))] data-pressed:text-foreground data-pressed:shadow-[inset_0_1px_1px_color-mix(in_oklab,var(--foreground)_10%,transparent)]',
        className,
      )}
    >
      {children}
    </kbd>
  );
}

const TEXTS = {
  en: { then: 'then', command: 'Command', control: 'Control', shift: 'Shift', option: 'Option', alt: 'Alt', enter: 'Enter', escape: 'Escape', backspace: 'Backspace', tab: 'Tab', space: 'Space', up: 'Arrow up', down: 'Arrow down', left: 'Arrow left', right: 'Arrow right' },
  de: { then: 'dann', command: 'Befehl', control: 'Steuerung', shift: 'Umschalt', option: 'Wahl', alt: 'Alt', enter: 'Eingabe', escape: 'Escape', backspace: 'Rücktaste', tab: 'Tab', space: 'Leertaste', up: 'Pfeil hoch', down: 'Pfeil runter', left: 'Pfeil links', right: 'Pfeil rechts' },
};
type KbdTexts = (typeof TEXTS)['en'];

const ICON_SIZE: Record<KbdSize, number> = { sm: 11, md: 12, lg: 15 };

/** Is this an Apple device? Always false on the server, checked after mount. */
function useIsMac() {
  const [mac, setMac] = useState(false);
  useEffect(() => {
    setMac(/Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent));
  }, []);
  return mac;
}

/** Symbol and spoken name of a special key. `mod` is Command on Apple devices and Control elsewhere. */
function keyFace(key: string, mac: boolean, t: KbdTexts): { face: string; name?: string; icon?: IconName } {
  switch (key.toLowerCase()) {
    case 'mod':
      return mac ? { face: '⌘', name: t.command } : { face: 'Ctrl', name: t.control };
    case 'cmd':
    case 'command':
      return { face: '⌘', name: t.command };
    case 'ctrl':
    case 'control':
      return { face: mac ? '⌃' : 'Ctrl', name: t.control };
    case 'shift':
      return { face: '⇧', name: t.shift };
    case 'alt':
    case 'option':
      return mac ? { face: '⌥', name: t.option } : { face: 'Alt', name: t.alt };
    case 'enter':
    case 'return':
      return { face: '↵', name: t.enter, icon: 'corner-down-left' };
    case 'esc':
    case 'escape':
      return { face: 'Esc', name: t.escape };
    case 'backspace':
      return { face: '⌫', name: t.backspace };
    case 'tab':
      return { face: '⇥', name: t.tab };
    case 'space':
      return { face: t.space };
    case 'up':
      return { face: '↑', name: t.up, icon: 'arrow-up' };
    case 'down':
      return { face: '↓', name: t.down, icon: 'arrow-down' };
    case 'left':
      return { face: '←', name: t.left, icon: 'arrow-left' };
    case 'right':
      return { face: '→', name: t.right, icon: 'arrow-right' };
    default:
      return { face: key.length === 1 ? key.toUpperCase() : key };
  }
}

export interface ShortcutProps {
  /** Keys pressed together, e.g. ['mod', 'k']. Special names: mod, shift, alt, enter, esc, backspace, tab, space, up, down, left, right. */
  keys: string[];
  /** together: keys at the same time. sequence: one after the other ("G then D"). */
  mode?: 'together' | 'sequence';
  size?: KbdSize;
  variant?: KbdVariant;
  labels?: Partial<KbdTexts>;
  className?: string;
}

/** A keyboard shortcut as key caps. Shows Command on Apple devices and Ctrl elsewhere for `mod`. */
export function Shortcut({ keys, mode = 'together', size = 'md', variant = 'default', labels, className }: ShortcutProps) {
  const t = useTexts(TEXTS, labels);
  const mac = useIsMac();
  return (
    <kbd className={cn('inline-flex items-center gap-1 align-middle font-sans', className)}>
      {keys.map((k, i) => {
        const { face, name, icon } = keyFace(k, mac, t);
        return (
          <span key={`${k}-${i}`} className="inline-flex items-center gap-1">
            {i > 0 && mode === 'sequence' && <span className="px-0.5 text-[12px] text-subtle-foreground">{t.then}</span>}
            <Kbd size={size} variant={variant} keyName={k}>
              {name ? (
                <>
                  {icon ? <Icon name={icon} size={ICON_SIZE[size]} strokeWidth={2} /> : <span aria-hidden>{face}</span>}
                  <span className="sr-only">{name}</span>
                </>
              ) : (
                face
              )}
            </Kbd>
          </span>
        );
      })}
    </kbd>
  );
}

Frequent questions

How do I install Keyboard keys?

With aiw add @ai-website/kbd. The command puts components/ui/kbd.tsx into your project, the code is yours from then on. It brings along icon. It needs no further npm packages.

Which variants does Keyboard keys have?

3 variants, In text and menus, Cheat sheet and Sizes and styles.

Can I translate the texts of Keyboard keys?

Yes. Keyboard keys carries 15 texts of its own in English and German, such as “then”, “Command” and “Control”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Keyboard keys?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Keyboard keys with aiw_components and install it with aiw_add @ai-website/kbd. Name the variant in your prompt, for example variant="outline", and the assistant writes your content straight into the props.

Often searched

Jump to

Copied to the clipboard