every key on the page in one sheet, ⌘/ or ?, and caps beside controls while ⌘ is held
npx shadcn add @ag/key-sheetAlready installed? Preview an update
npx shadcn add @ag/key-sheet --dry-runYour 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 fromdata-key(which<Tip action>sets) oraria-keyshortcuts.
also called shortcuts, cheat sheet, which-key
shelf controls
uses keys, key-router, fuzzy, dialog, kbd, search, button, material
the demo above, verbatim
"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
"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,
)
}// 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 })
}