← ui

key-sheet

ui

every key on the page in one sheet, ⌘/ or ?, and caps beside controls while ⌘ is held

npx shadcn add @ag/key-sheet
Already installed? Preview an update
npx shadcn add @ag/key-sheet --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-11-04
  • New: every key on the page in one sheet, mounted once at the app's root (<KeySheet />): ⌘/ from anywhere (Ctrl+/ off a Mac), ? where nothing is typed, showKeys() for a button. The part holding the focus or used last first, then the others, then the page's own keys; only what answers now. Typing filters it; a key pressed while it is open lights its row instead of running; Esc empties the filter, then closes. A remappable part's keys change there, a collision refused by the name of its holder. Plain or glass. And which-key: ⌘ (Ctrl off a Mac) held still 400 ms shows each visible control's key as a cap beside it, read from data-key (which <Tip action> sets) or aria-keyshortcuts.
Updated 2026-11-04 · release notes

also called shortcuts, cheat sheet, which-key

shelf controls

uses keys, key-router, fuzzy, dialog, kbd, search, button, material

the demo above, verbatim

key-sheet.demo.tsx
"use client"

import { Eraser, Plus, Star } from "lucide-react"
import * as React from "react"
import { Sample } from "@/app/samples"
import type { Action } from "@/registry/base-nova/lib/actions"
import { Button } from "@/registry/base-nova/ui/button"
import { Kbd } from "@/registry/base-nova/ui/kbd"
import { ActionScope, useActions } from "@/registry/base-nova/ui/key-router"
import { showKeys } from "@/registry/base-nova/ui/key-sheet"
import { Menu, verbOf } from "@/registry/base-nova/ui/menu"
import { Tip } from "@/registry/base-nova/ui/tooltip"

// #region tally
/** A part with its keys declared once: the buttons' tips, the menu and the page's
 *  sheet all read them, and a person may change them (remappable). */
function Tally({ title }: { title: string }) {
  const root = React.useRef<HTMLDivElement>(null)
  const [count, setCount] = React.useState(0)
  const [starred, setStarred] = React.useState(false)
  const [menu, setMenu] = React.useState<Element | null>(null)
  const actions = useActions(
    {
      title,
      actions: [
        {
          id: "add",
          label: "add one",
          keys: ["a", "mod+enter"],
          section: "count",
          icon: <Plus />,
          run: () => setCount((n) => n + 1),
        },
        {
          id: "clear",
          label: "clear",
          keys: ["backspace"],
          section: "count",
          destructive: true,
          icon: <Eraser />,
          when: () => count > 0,
          run: () => setCount(0),
        },
        {
          id: "star",
          label: starred ? "unstar" : "star",
          keys: ["s"],
          section: "mark",
          icon: <Star />,
          run: () => setStarred((s) => !s),
        },
      ],
    },
    { root, remappable: true },
  )
  return (
    <ActionScope actions={actions}>
      <div
        ref={root}
        className="flex flex-col items-start gap-3 rounded-lg bg-fill-group p-4"
      >
        <span className="text-sm">
          {title}: <span className="font-mono tabular-nums">{count}</span>
          {starred && " ★"}
        </span>
        <div className="flex gap-2">
          <Tip action="add">
            <Button
              size="sm"
              variant="secondary"
              onClick={() => setCount((n) => n + 1)}
            >
              add
            </Button>
          </Tip>
          <Tip action="star">
            <Button
              size="sm"
              variant="ghost"
              onClick={() => setStarred((s) => !s)}
            >
              star
            </Button>
          </Tip>
          <Button
            size="sm"
            variant="ghost"
            onClick={(e) => setMenu(e.currentTarget)}
          >
            more
          </Button>
        </div>
        <Menu
          entries={actions.actions.map((a) =>
            verbOf(a as Action<React.ReactNode>),
          )}
          anchor={menu}
          onClose={() => setMenu(null)}
        />
      </div>
    </ActionScope>
  )
}
// #endregion

export default function KeySheetDemo() {
  return (
    <div className="space-y-8">
      <Sample
        name="sheet"
        glance
        title="the page's keys"
        description="click the tally, then press ⌘/ (or ? anywhere nothing is typed): its keys come first. press a key in the sheet to find it; change one, then press it here."
        with="tally"
      >
        <div className="flex flex-col items-start gap-3">
          <Tally title="tally" />
          <Button size="sm" variant="outline" onClick={() => showKeys()}>
            keys <Kbd keys="mod+/" className="text-[11px]" />
          </Button>
        </div>
      </Sample>
      <Sample
        name="which-key"
        title="hold ⌘ to see the keys"
        description="hold ⌘ (Ctrl off a Mac) still for a moment: every control that carries a key shows it beside itself."
        with="tally"
      >
        <Tally title="votes" />
      </Sample>
    </div>
  )
}

the source, every file an install writes

key-sheet.tsx
"use client"

// THE PAGE'S KEYS, in one place: a sheet of every part's actions (lib/actions, declared
// with key-router's `useActions`), and the caps that show beside the controls while ⌘ is
// held. Mounted once, at the root of the app:
//
//   <KeySheet />
//
// ⌘/ opens it from anywhere (Ctrl+/ off a Mac), and ? where nothing is being typed. It
// lists the parts as the router hands them a press: the part holding the focus or used
// last first, then the others, then the page's own keys; inside each, its sections in
// the order it declared them, and only what answers now (its active modes, no
// unavailable binding). Typing filters it (lib/fuzzy, over the label, the part and the
// section). A key pressed while it is open is never run: its row flashes, so a person
// asks "what does this do here" by pressing it. Characters type into the filter; ↓
// moves into the list, where a plain key flashes too. Esc empties the filter, then
// closes. A part declared `remappable` offers to change each of its keys: the next
// press is the new key, a key the part already binds is refused naming the action that
// holds it, and the change is kept on this site (key-router's `remap`).
//
// Holding ⌘ (Ctrl off a Mac) still for a moment shows, beside each visible control that
// carries a key (`data-key`, which `<Tip action>` sets, or `aria-keyshortcuts`), that
// key as a cap: below the control, above it where the window has no room, caps that
// would overlap pushed apart along their row, the first of each run in its place
// (swift-utils Keymap's ShortcutTip.place and spread). Any other key, a press, the
// window losing the keyboard or the tab being hidden puts them away.

import * as React from "react"
import { createPortal } from "react-dom"
import { cn } from "@/lib/utils"
import {
  type Action,
  holderOf,
  type SheetSection,
  sheetOf,
  written,
} from "@/registry/base-nova/lib/actions"
import { fuzzy } from "@/registry/base-nova/lib/fuzzy"
import { combo, pressed } from "@/registry/base-nova/lib/keys"
import { placeCaps } from "@/registry/base-nova/lib/which-key"
import { Button } from "@/registry/base-nova/ui/button"
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogSection,
  DialogTitle,
} from "@/registry/base-nova/ui/dialog"
import { Kbd } from "@/registry/base-nova/ui/kbd"
import {
  type OwnerView,
  ownersNow,
  platformNow,
  remap,
  remapsOf,
  useActions,
  useKeys,
} from "@/registry/base-nova/ui/key-router"
import type { Material } from "@/registry/base-nova/ui/material"
import { Highlight, Search } from "@/registry/base-nova/ui/search"

/** The sheet's own owner: the keys that open it, listed last as the page's own. */
const PAGE = "this page"

/** How long a pressed row stays lit. */
const FLASH_MS = 900

/** A row of the sheet, where it stands. */
interface Listed {
  owner: OwnerView
  section: string
  row: SheetSection["rows"][number]
  /** Its address in the sheet: part, section, label. */
  at: string
}

const available = (a: Action) => (a.when ? a.when() : true)

/** The mounted sheet's opener. */
let shower: (() => void) | null = null

/** Opens the page's sheet, as ⌘/ does: for a button that says "keys". */
export function showKeys(): void {
  if (!shower)
    throw new Error("showKeys: no KeySheet is mounted (the app's root)")
  shower()
}

/** Every row of the owners, in the sheet's order: the parts as the router reaches
 *  them, the page's own keys last. */
function listOf(owners: readonly OwnerView[]): Listed[] {
  const order = [
    ...owners.filter((o) => o.title !== PAGE),
    ...owners.filter((o) => o.title === PAGE),
  ]
  // Two parts may share a title (two players on a page): the address counts them.
  return order.flatMap((owner, n) =>
    sheetOf(owner.set, { active: owner.active, available }).flatMap((s) =>
      s.rows.map((row) => ({
        owner,
        section: s.section,
        row,
        at: `${n}\u0000${owner.title}\u0000${s.section}\u0000${row.label}`,
      })),
    ),
  )
}

/** The owners read again (a key changed), in the order the sheet opened with: the hand
 *  is in the sheet now, and the order it had when it came stays the one shown. */
function reread(before: readonly OwnerView[]): OwnerView[] {
  const now = ownersNow()
  const taken = new Set<OwnerView>()
  return before.map((was) => {
    const same = now.find((o) => o.title === was.title && !taken.has(o))
    if (!same) return was
    taken.add(same)
    return { ...same, inHand: was.inHand }
  })
}

/** A row's keys pressed: every single combination it lists (a sequence is never one
 *  press). */
const isPressed = (l: Listed, e: KeyboardEvent) =>
  l.row.keys.some((k) => !/\s/.test(k.trim()) && pressed(k, e, platformNow()))

export function KeySheet({ material }: { material?: Material }) {
  const [open, setOpen] = React.useState(false)
  const [owners, setOwners] = React.useState<OwnerView[]>([])
  const [query, setQuery] = React.useState("")
  const [flash, setFlash] = React.useState<string | null>(null)
  const [recording, setRecording] = React.useState<Listed | null>(null)
  const [problem, setProblem] = React.useState<{
    at: string
    text: string
  } | null>(null)
  const popup = React.useRef<HTMLDivElement>(null)
  const list = React.useRef<HTMLElement>(null)

  // Read when it opens, while the focus is still on the part the person was in: the
  // order is the hand's at that moment.
  const show = () => {
    setOwners(ownersNow())
    setQuery("")
    setRecording(null)
    setProblem(null)
    setOpen(true)
  }
  useActions({
    title: PAGE,
    actions: [
      {
        id: "keys",
        label: "the keys on this page",
        keys: ["mod+/", "?"],
        section: "keys",
        run: show,
      },
    ],
  })
  const opens = React.useEffectEvent(show)
  React.useEffect(() => {
    if (shower)
      throw new Error("KeySheet: mounted twice; it belongs at the root")
    shower = () => opens()
    return () => {
      shower = null
    }
  }, [])

  const rows = listOf(owners)
  const shown = query
    ? rows.filter(
        (l) =>
          fuzzy(query, `${l.row.label} ${l.owner.title} ${l.section}`) !== null,
      )
    : rows

  const lit = React.useRef<ReturnType<typeof setTimeout>>(undefined)
  const light = (at: string) => {
    clearTimeout(lit.current)
    setFlash(at)
    lit.current = setTimeout(() => setFlash(null), FLASH_MS)
    popup.current
      ?.querySelector(`[data-at="${CSS.escape(at)}"]`)
      ?.scrollIntoView({ block: "nearest" })
  }

  // While it is open it takes every press (key-router's modal): a listed key lights
  // its row, a recording takes the next press as the new key, ⌘/ closes it. Esc and
  // Tab stay the dialog's.
  useKeys(
    (e) => {
      if (recording) {
        if (e.key === "Escape") {
          setRecording(null)
          return true
        }
        const text = written(e, platformNow())
        if (!text) return true
        const [id] = recording.row.ids as [string]
        // A key the part already answers is refused by the name of what holds it.
        const holder = holderOf(recording.owner.set, id, text)
        if (holder)
          setProblem({
            at: recording.at,
            text: `${combo(text)?.join("")} is already ${recording.owner.title}'s "${holder.label}"`,
          })
        else
          try {
            remap(recording.owner.title, id, [text])
            setProblem(null)
          } catch (error) {
            setProblem({ at: recording.at, text: (error as Error).message })
          }
        setRecording(null)
        setOwners(reread)
        return true
      }
      if (pressed("mod+/", e, platformNow())) {
        setOpen(false)
        return true
      }
      if (e.key === "Escape" || e.key === "Tab") return false
      const hit = shown.find((l) => isPressed(l, e))
      if (!hit) return false
      light(hit.at)
      return true
    },
    { root: popup, modal: true, enabled: open },
  )

  const reset = (l: Listed) => {
    remap(l.owner.title, l.row.ids[0] as string, null)
    setProblem(null)
    setOwners(reread)
  }

  return (
    <>
      <Dialog
        open={open}
        onOpenChange={(next, details) => {
          // Esc is a ladder here too: a recording first, then the filter, then the
          // sheet.
          if (
            !next &&
            details.reason === "escape-key" &&
            (recording || query)
          ) {
            if (recording) setRecording(null)
            else setQuery("")
            return
          }
          setOpen(next)
        }}
      >
        <DialogContent
          ref={popup}
          material={material}
          className="w-[min(40rem,calc(100vw-2rem))]"
        >
          <DialogHeader>
            <DialogTitle>keys</DialogTitle>
            <DialogDescription>
              what each part of this page answers, the one in your hand first.
              Press a key to find it.
            </DialogDescription>
          </DialogHeader>
          <Search
            value={query}
            onChange={setQuery}
            label="filter the keys"
            placeholder="filter"
            autoFocus
            onDown={() => list.current?.focus()}
          />
          <section
            ref={list}
            // A press here that is a plain key lights its row (in the filter it types).
            // biome-ignore lint/a11y/noNoninteractiveTabindex: the list takes the keyboard so a plain key can be asked about
            tabIndex={0}
            aria-label="the keys"
            className="flex flex-col gap-5 rounded-md outline-none"
          >
            {shown.length === 0 && (
              <p className="text-current/55 text-sm">no key matches</p>
            )}
            {groups(shown).map(({ owner, sections }, n) => (
              <DialogSection
                // biome-ignore lint/suspicious/noArrayIndexKey: two parts may share a title; their order is the hand's at opening
                key={`${n}-${owner.title}`}
                title={
                  owner.title === PAGE
                    ? PAGE
                    : owner.inHand
                      ? `${owner.title} · in hand`
                      : owner.title
                }
              >
                {sections.map(({ section, items }) => (
                  <div key={section} className="flex flex-col gap-1">
                    {section && section !== owner.title && (
                      <div className="text-[11px] text-current/45">
                        {section}
                      </div>
                    )}
                    {items.map((l) => (
                      <Row
                        key={l.at}
                        listed={l}
                        query={query}
                        flashing={flash === l.at}
                        recording={recording?.at === l.at}
                        problem={problem?.at === l.at ? problem.text : null}
                        onRecord={() => {
                          setProblem(null)
                          setRecording(l)
                        }}
                        onReset={() => reset(l)}
                      />
                    ))}
                  </div>
                ))}
              </DialogSection>
            ))}
          </section>
        </DialogContent>
      </Dialog>
      <WhichKey />
    </>
  )
}

/** The listed rows back into their parts and sections, in order. */
function groups(rows: readonly Listed[]) {
  const out: {
    owner: OwnerView
    sections: { section: string; items: Listed[] }[]
  }[] = []
  for (const l of rows) {
    let g = out.find((o) => o.owner === l.owner)
    if (!g) {
      g = { owner: l.owner, sections: [] }
      out.push(g)
    }
    let s = g.sections.find((x) => x.section === l.section)
    if (!s) {
      s = { section: l.section, items: [] }
      g.sections.push(s)
    }
    s.items.push(l)
  }
  return out
}

function Row({
  listed: l,
  query,
  flashing,
  recording,
  problem,
  onRecord,
  onReset,
}: {
  listed: Listed
  query: string
  flashing: boolean
  recording: boolean
  problem: string | null
  onRecord: () => void
  onReset: () => void
}) {
  const id = l.row.ids[0] as string
  // One binding behind the row, so a new key has one place to go.
  const changeable = l.owner.remappable && l.row.ids.length === 1
  const changed = changeable && remapsOf(l.owner.title).has(id)
  return (
    <div
      data-at={l.at}
      data-flash={flashing || undefined}
      className={cn(
        "grid grid-cols-[minmax(7rem,auto)_1fr_auto] items-center gap-x-4 rounded-md px-2 py-1 text-sm transition-colors duration-300",
        flashing && "bg-fill-row-on duration-0",
      )}
    >
      <span className="flex flex-wrap items-center gap-1.5">
        {recording ? (
          <span className="text-current/55 text-xs">press the new key</span>
        ) : l.row.shown ? (
          <span className="font-mono text-xs">{l.row.shown}</span>
        ) : (
          // Alternatives, never one chord: each its own keys, "or" between.
          l.row.keys.map((k, i) => (
            <React.Fragment key={k}>
              {i > 0 && <span className="text-current/40 text-xs">or</span>}
              <Kbd keys={k} className="text-[11px]" />
            </React.Fragment>
          ))
        )}
      </span>
      <span className="min-w-0">
        <Highlight text={l.row.label} query={query} />
        {l.row.takesBrowserChord && (
          <span className="text-current/45 text-xs">
            {" "}
            · the browser's, taken
          </span>
        )}
        {problem && (
          <span role="alert" className="block text-tone-bad text-xs">
            {problem}
          </span>
        )}
      </span>
      <span className="flex gap-1">
        {changeable && (
          <Button variant="ghost" size="sm" onClick={onRecord}>
            {recording ? "esc to cancel" : "change"}
          </Button>
        )}
        {changed && (
          <Button variant="ghost" size="sm" onClick={onReset}>
            reset
          </Button>
        )}
      </span>
    </div>
  )
}

// ── WHICH-KEY: the caps beside the controls while ⌘ (Ctrl off a Mac) is held. ──

/** How long the modifier is held still before the caps show. */
const HOLD_MS = 400

interface Tip {
  key: string
  /** The control's box. */
  box: DOMRect
}

/** A control's key in lib/keys notation: its `data-key`, else the first of its
 *  `aria-keyshortcuts` read back (Meta+Enter is mod+enter), else none. */
function keyOfControl(el: Element): string | null {
  const own = el.getAttribute("data-key")
  if (own) return own
  const aria = el.getAttribute("aria-keyshortcuts")?.split(/\s+/)[0]
  if (!aria) return null
  const text = aria
    .split("+")
    .map((p) => {
      const k = p.toLowerCase()
      if (k === "meta") return "cmd"
      if (k === "control") return "ctrl"
      if (k.startsWith("arrow")) return k.slice(5)
      if (k === "escape") return "esc"
      if (k === "plus") return "+"
      return k
    })
    .join("+")
  return combo(text) ? text : null
}

/** The controls on screen that carry a key. */
function tipsNow(): Tip[] {
  const out: Tip[] = []
  for (const el of document.querySelectorAll(
    "[data-key], [aria-keyshortcuts]",
  )) {
    if (el.closest("[inert], [aria-hidden='true']")) continue
    if (!(el as HTMLElement).checkVisibility?.({ visibilityProperty: true }))
      continue
    const box = el.getBoundingClientRect()
    if (box.width === 0 || box.height === 0) continue
    if (box.bottom < 0 || box.top > innerHeight) continue
    if (box.right < 0 || box.left > innerWidth) continue
    const key = keyOfControl(el)
    if (key) out.push({ key, box })
  }
  return out
}

function WhichKey() {
  const [tips, setTips] = React.useState<Tip[] | null>(null)
  const [placed, setPlaced] = React.useState<{ x: number; y: number }[] | null>(
    null,
  )
  const caps = React.useRef<(HTMLDivElement | null)[]>([])

  React.useEffect(() => {
    const held = platformNow() === "mac" ? "Meta" : "Control"
    let timer: ReturnType<typeof setTimeout> | undefined
    const away = () => {
      clearTimeout(timer)
      timer = undefined
      setTips(null)
      setPlaced(null)
    }
    const down = (e: KeyboardEvent) => {
      const alone =
        e.key === held &&
        !e.shiftKey &&
        !e.altKey &&
        (held === "Meta" ? !e.ctrlKey : !e.metaKey)
      if (!alone) return away()
      if (e.repeat || timer) return
      timer = setTimeout(() => setTips(tipsNow()), HOLD_MS)
    }
    const up = (e: KeyboardEvent) => {
      if (e.key === held) away()
    }
    const hidden = () => {
      if (document.visibilityState === "hidden") away()
    }
    addEventListener("keydown", down, true)
    addEventListener("keyup", up, true)
    addEventListener("pointerdown", away, true)
    addEventListener("blur", away)
    document.addEventListener("visibilitychange", hidden)
    return () => {
      clearTimeout(timer)
      removeEventListener("keydown", down, true)
      removeEventListener("keyup", up, true)
      removeEventListener("pointerdown", away, true)
      removeEventListener("blur", away)
      document.removeEventListener("visibilitychange", hidden)
    }
  }, [])

  // Drawn unseen once to be measured, then placed.
  React.useLayoutEffect(() => {
    if (!tips) return
    const sized = tips.map((t, i) => {
      const r = caps.current[i]?.getBoundingClientRect()
      return { box: t.box, width: r?.width ?? 0, height: r?.height ?? 0 }
    })
    setPlaced(placeCaps(sized, { width: innerWidth, height: innerHeight }))
  }, [tips])

  if (!tips) return null
  return createPortal(
    <div
      data-slot="which-key"
      aria-hidden="true"
      className="pointer-events-none fixed inset-0 z-[60]"
    >
      {tips.map((t, i) => (
        <div
          // biome-ignore lint/suspicious/noArrayIndexKey: one cap per control found now, in document order
          key={i}
          ref={(el) => {
            caps.current[i] = el
          }}
          className={cn(
            "absolute rounded-md bg-popover/90 p-0.5 text-popover-foreground shadow-md",
            !placed && "invisible",
          )}
          style={placed ? { left: placed[i]?.x, top: placed[i]?.y } : undefined}
        >
          <Kbd keys={t.key} className="text-[11px]" />
        </div>
      ))}
    </div>,
    document.body,
  )
}
which-key.ts
// Where which-key's caps stand (ui/key-sheet draws them): swift-utils Keymap's
// ShortcutTip.place and spread, on the web. A cap is centred below its control, above it
// where the window has no room below, never past the window's sides; caps that would
// overlap are pushed apart along their row, left to right, the first of each run kept
// in its place. Framework-free: boxes in, places out.

/** A control's box, in the window's coordinates. */
export interface Box {
  left: number
  top: number
  width: number
  height: number
}

/** A cap to place: its control's box and its own measured size. */
export interface Cap {
  box: Box
  width: number
  height: number
}

/** Room between a control and its cap, and between caps pushed apart. */
export const GAP = 4

/** Where each cap's top left corner stands, in the order the caps came. */
export function placeCaps(
  caps: readonly Cap[],
  view: { width: number; height: number },
): { x: number; y: number }[] {
  const first = caps.map((c) => {
    const below = c.box.top + c.box.height + GAP
    const y =
      below + c.height > view.height ? c.box.top - GAP - c.height : below
    const x = Math.min(
      Math.max(0, c.box.left + c.box.width / 2 - c.width / 2),
      Math.max(0, view.width - c.width),
    )
    return { x, y }
  })
  // A row is the caps whose tops lie within a cap's height of each other; walked left
  // to right, each one starts past the end of the one before it.
  const order = first
    .map((p, i) => ({ ...p, i }))
    .sort((a, b) => a.y - b.y || a.x - b.x)
  const placed = new Map<number, { x: number; y: number }>()
  order.forEach((p, n) => {
    const prev = n > 0 ? order[n - 1] : undefined
    const before = prev && placed.get(prev.i)
    const height = (caps[p.i] as Cap).height
    if (!prev || !before || Math.abs(p.y - before.y) >= height) {
      placed.set(p.i, { x: p.x, y: p.y })
      return
    }
    const end = before.x + (caps[prev.i] as Cap).width + GAP
    placed.set(p.i, { x: Math.max(p.x, end), y: p.y })
  })
  return first.map((_, i) => placed.get(i) as { x: number; y: number })
}