a question's answers as keys: yes, no or a choice, then the reference answer
npx shadcn add @ag/answer-keysAlready installed? Preview an update
npx shadcn add @ag/answer-keys --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-10-11
- Changed: its keys go through key-router: a press a field or a control owns is never an answer, and a second instance on a page no longer throws (the one under the hand or holding focus answers). Migration:
keyboard={false}on extra instances is no longer needed.
the demo above, verbatim
"use client"
import * as React from "react"
import { Sample } from "@/app/samples"
import {
AnswerKeys,
type AnswerOption,
type AnswerReceipt,
YES_NO,
} from "@/registry/base-nova/ui/answer-keys"
// #region items
// Moments of Big Buck Bunny's opening, each with a question and a reference answer.
// The references are written by hand for this page, standing in for a model's answers.
const ON_SCREEN: AnswerOption[] = [
{ id: "rabbit", label: "the rabbit" },
{ id: "rodents", label: "the rodents" },
{ id: "title", label: "a title card" },
]
interface Item {
at: number
question: string
options: AnswerOption[]
reference: { option: string; p: number }
}
const ITEMS: Item[] = [
{
at: 0.5,
question: "is the rabbit on screen?",
options: YES_NO,
reference: { option: "no", p: 0.97 },
},
{
at: 1.5,
question: "what is on screen?",
options: ON_SCREEN,
reference: { option: "rodents", p: 0.88 },
},
{
at: 2.5,
question: "is the rabbit on screen?",
options: YES_NO,
reference: { option: "yes", p: 0.94 },
},
{
at: 4.5,
question: "is the rabbit on screen?",
options: YES_NO,
reference: { option: "no", p: 0.61 },
},
{
at: 6,
question: "what is on screen?",
options: ON_SCREEN,
reference: { option: "title", p: 0.99 },
},
{
at: 7.5,
question: "is the rabbit on screen?",
options: YES_NO,
reference: { option: "yes", p: 0.55 },
},
]
// #endregion
/** A second of footage around the item's moment, looping, muted. */
function Moment({ at }: { at: number }) {
const ref = React.useRef<HTMLVideoElement>(null)
React.useEffect(() => {
const video = ref.current
if (!video) return
const from = Math.max(0, at - 0.5)
video.currentTime = from
void video.play().catch(() => {})
const loop = () => {
if (video.currentTime >= at + 0.5) video.currentTime = from
}
video.addEventListener("timeupdate", loop)
return () => video.removeEventListener("timeupdate", loop)
}, [at])
return (
<video
ref={ref}
src="/bunny.mp4"
muted
playsInline
className="aspect-video w-full rounded-lg bg-foreground/5 object-cover"
/>
)
}
function Queue() {
const [answers, setAnswers] = React.useState<(string | null)[]>([])
const index = answers.length
const item = ITEMS[index]
const last = index > 0 ? ITEMS[index - 1] : undefined
const receipt: AnswerReceipt | undefined = last && {
answer: answers[index - 1] ?? null,
reference: { by: "reference", ...last.reference },
}
const undo = index > 0 ? () => setAnswers((a) => a.slice(0, -1)) : undefined
if (!item) {
const answered = answers.filter((a) => a !== null).length
const agreed = answers.filter(
(a, i) => a !== null && a === ITEMS[i]?.reference.option,
).length
return (
<div className="flex flex-col gap-3">
<p className="text-base text-foreground/90">
all {ITEMS.length} moments seen
</p>
<p className="font-mono text-xs text-foreground/45">
answered {answered} · agreed with the reference on {agreed}
</p>
<AnswerKeys
options={YES_NO}
question="finished"
onAnswer={() => {}}
onUndo={undo}
receipt={receipt}
disabled
/>
</div>
)
}
return (
<div className="flex flex-col gap-4">
<Moment at={item.at} />
<div className="flex items-baseline justify-between gap-4">
<p className="text-base text-foreground/90">{item.question}</p>
<p className="font-mono text-xs text-foreground/45">
{index + 1} of {ITEMS.length}
</p>
</div>
<AnswerKeys
options={item.options}
question={item.question}
onAnswer={(id) => setAnswers((a) => [...a, id])}
onSkip={() => setAnswers((a) => [...a, null])}
onUndo={undo}
receipt={receipt}
/>
</div>
)
}
export default function AnswerKeysDemo() {
return (
<div className="space-y-8">
<Sample name="queue" title="grading moments, keys first" with="items">
<Queue />
</Sample>
<Sample name="yes-no" glance title="a yes or no question">
<AnswerKeys
options={YES_NO}
question="is the rabbit on screen?"
onAnswer={() => {}}
onSkip={() => {}}
keyboard={false}
receipt={{
answer: "yes",
reference: { by: "model", option: "yes", p: 0.94 },
}}
/>
</Sample>
<Sample name="choice" title="a choice, on the number keys">
<AnswerKeys
options={[
{ id: "rabbit", label: "the rabbit" },
{ id: "rodents", label: "the rodents" },
{ id: "title", label: "a title card" },
]}
question="what is on screen?"
onAnswer={() => {}}
onSkip={() => {}}
keyboard={false}
receipt={{
answer: "rodents",
reference: { by: "model", option: "rabbit", p: 0.61 },
}}
/>
</Sample>
</div>
)
}the source, every file an install writes
"use client"
// A question's answers as keys and buttons, for grading at the speed of a hand: one
// press answers and the surface moves on. Data in, events out: it knows a question's
// options, never what is being graded or where answers go. Yes and no, or a choice's
// options on the number keys; skip and undo are keys of their own. Every press shows on
// its button, so the hand never has to be looked at. The receipt line says what you
// answered on the previous item beside a reference answer (a model's, another
// person's), revealed only AFTER you answered: a reference seen first anchors the
// answer and the grade becomes the reference's opinion.
//
// What it is for: any queue a person answers one item at a time, where speed and
// honesty of the answer matter more than the form around it.
// - Labelling and grading for models (RLHF, evals): is this claim right, which of
// two outputs is better (`a`, `b`, `tie` as a choice), what is in this frame. The
// receipt measures agreement with the model as you go; the blind reveal keeps the
// labels yours.
// - Triage: keep or drop a photo, like or dislike a film, approve or reject a
// change, read or archive a message. One key per verdict, skip for "later",
// undo for the slip.
// - Review and moderation queues: is this report spam, is this match the same
// person, does this caption fit the picture.
// - Practice and recall: flashcards rated again / hard / good / easy, a quiz whose
// receipt shows the right answer after yours.
// - Quick sorting: which folder, which project, which category, on 1 to 9.
// Not for a form with several fields at once: it answers ONE question per press.
//
// Keys are page-wide while it is mounted, because a grading surface owns the keyboard,
// through the page's key router (key-router): a press a field or a control owns is
// never an answer, a held key repeating and any modifier but ⌘Z are ignored, so holding
// Y can never answer twenty items. With two instances (or a player beside it), the one
// holding focus, under the hand or last pressed answers alone.
import * as React from "react"
import { cn } from "@/lib/utils"
import { capOf } from "@/registry/base-nova/lib/keys"
import { Kbd } from "@/registry/base-nova/ui/kbd"
import { useKeys } from "@/registry/base-nova/ui/key-router"
export interface AnswerOption {
/** What the answer is (`yes`, `freekick`): what `onAnswer` reports. */
id: string
/** What the button says. Lowercase, a word or two. */
label: string
/** The key that answers it. A choice's options default to 1 to 9 in order. */
key?: string
}
/** Yes and no on Y and N, the answers of every yes/no question. */
export const YES_NO: AnswerOption[] = [
{ id: "yes", label: "yes", key: "y" },
{ id: "no", label: "no", key: "n" },
]
/** The previous item, once answered: yours beside the reference's. */
export interface AnswerReceipt {
/** The id you answered, or null for a skip. */
answer: string | null
/** What the reference said and how sure: shown only now that you answered. */
reference?: { by: string; option: string; p: number }
}
export interface AnswerKeysProps {
options: AnswerOption[]
/** What the answers answer, for the group's accessible name. */
question: string
onAnswer: (id: string) => void
onSkip?: () => void
/** Present when there is something to take back. */
onUndo?: () => void
receipt?: AnswerReceipt
/** Stop listening and dim the keys (an item loading, a dialog over the page). */
disabled?: boolean
/** Answer the keyboard (through the page's key router); the buttons work either
* way. */
keyboard?: boolean
className?: string
}
/** How long a press shows on its button: long enough to see from the corner of the eye
* while the next item arrives, short enough that two fast presses read as two. */
export const PRESS_MS = 140
const KEY =
"flex h-12 min-w-0 cursor-pointer select-none items-center justify-between gap-3 rounded-lg border px-4 text-base transition-colors duration-150 ease-(--ease-out) focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-offset-(--focus-ring-offset) focus-visible:outline-foreground/(--focus-ring-alpha) disabled:cursor-default disabled:opacity-40"
const ANSWER =
"border-foreground/12 bg-foreground/4 text-foreground/90 hover:bg-foreground/8 data-pressed:border-foreground/30 data-pressed:bg-foreground/16"
const SKIP =
"border-transparent bg-transparent text-foreground/55 hover:bg-foreground/5 hover:text-foreground/80 data-pressed:bg-foreground/10 data-pressed:text-foreground/90"
const META = "font-mono text-xs lowercase text-foreground/45"
/** The options with their keys settled: given keys kept, a choice's missing ones 1 to 9. */
export function keyed(options: AnswerOption[]): Required<AnswerOption>[] {
const out = options.map((o, i) => {
const key = (o.key ?? (i < 9 ? String(i + 1) : "")).toLowerCase()
if (!key)
throw new Error(
`AnswerKeys: option ${o.id} has no key and is past the ninth`,
)
return { ...o, key }
})
const seen = new Set<string>()
for (const o of out) {
if (o.key === "s" || o.key === "z")
throw new Error(
`AnswerKeys: ${o.id} takes ${o.key}, the key of skip or undo`,
)
if (seen.has(o.key))
throw new Error(`AnswerKeys: key ${o.key} answers two options`)
seen.add(o.key)
}
return out
}
export function AnswerKeys({
options,
question,
onAnswer,
onSkip,
onUndo,
receipt,
disabled = false,
keyboard = true,
className,
}: AnswerKeysProps) {
const keys = React.useMemo(() => keyed(options), [options])
const [pressed, setPressed] = React.useState<string | null>(null)
const timer = React.useRef(0)
React.useEffect(() => () => window.clearTimeout(timer.current), [])
const press = React.useCallback((id: string, act: () => void) => {
window.clearTimeout(timer.current)
setPressed(id)
timer.current = window.setTimeout(() => setPressed(null), PRESS_MS)
act()
}, [])
// On the page's key router: a press a field or a control owns is never an answer,
// and with two instances (or a player beside it) the one holding focus or under the
// hand answers alone. Reads the latest props at press time.
const root = React.useRef<HTMLDivElement>(null)
useKeys(
(event) => {
if (disabled || event.repeat) return false
const key = capOf(event).toLowerCase()
const undo =
key === "z" && !event.altKey && !event.ctrlKey && !event.shiftKey
if (undo && onUndo) {
press("undo", onUndo)
return true
}
if (event.metaKey || event.ctrlKey || event.altKey) return false
if (key === "s" && onSkip) {
press("skip", onSkip)
return true
}
const option = keys.find((o) => o.key === key)
if (!option) return false
press(option.id, () => onAnswer(option.id))
return true
},
{ root, enabled: keyboard },
)
return (
<div
ref={root}
data-slot="answer-keys"
className={cn("flex flex-col gap-3", className)}
>
{/* A fieldset is the group of answers; its border, padding and min-width are
the browser's, reset so it lays out like any grid. */}
<fieldset
aria-label={question}
className="m-0 grid min-w-0 auto-cols-fr grid-flow-col gap-2 border-0 p-0"
>
{keys.map((o) => (
<button
key={o.id}
type="button"
data-slot="answer-keys-answer"
data-pressed={pressed === o.id ? "" : undefined}
aria-keyshortcuts={o.key.toUpperCase()}
disabled={disabled}
onClick={() => press(o.id, () => onAnswer(o.id))}
className={cn(KEY, ANSWER)}
>
<span className="truncate">{o.label}</span>
<Kbd keys={o.key.toUpperCase()} className="text-sm" />
</button>
))}
{onSkip && (
<button
type="button"
data-slot="answer-keys-skip"
data-pressed={pressed === "skip" ? "" : undefined}
aria-keyshortcuts="S"
disabled={disabled}
onClick={() => press("skip", onSkip)}
className={cn(KEY, SKIP)}
>
<span>skip</span>
<Kbd keys="S" className="text-sm" />
</button>
)}
</fieldset>
<AnswerReceiptLine
receipt={receipt}
options={keys}
onUndo={onUndo}
pressed={pressed === "undo"}
/>
</div>
)
}
/** The previous item in one line: yours, the reference's, whether they agree. Agreement
* is the reference's TOP answer equal to yours; how sure it was stays beside it as a
* number, never folded into the verdict word. */
function AnswerReceiptLine({
receipt,
options,
onUndo,
pressed,
}: {
receipt?: AnswerReceipt
options: Required<AnswerOption>[]
onUndo?: () => void
pressed: boolean
}) {
const label = (id: string) => options.find((o) => o.id === id)?.label ?? id
return (
<p
data-slot="answer-keys-receipt"
// Announced politely: the reveal is news, but never more urgent than the next item.
aria-live="polite"
className={cn(META, "flex min-h-5 flex-wrap items-center gap-x-2")}
>
{receipt && (
<>
<span>last</span>
<span aria-hidden>·</span>
<span className="text-foreground/75">
you {receipt.answer === null ? "skipped" : label(receipt.answer)}
</span>
{receipt.reference && (
<>
<span aria-hidden>·</span>
<span>
{receipt.reference.by} {label(receipt.reference.option)}{" "}
{receipt.reference.p.toFixed(2)}
</span>
{receipt.answer !== null && (
<>
<span aria-hidden>·</span>
<span
data-slot="answer-keys-agreement"
className={
receipt.answer === receipt.reference.option
? "text-tone-ok"
: "text-tone-bad"
}
>
{receipt.answer === receipt.reference.option
? "agrees"
: "disagrees"}
</span>
</>
)}
</>
)}
</>
)}
{onUndo && (
<button
type="button"
data-slot="answer-keys-undo"
data-pressed={pressed ? "" : undefined}
aria-keyshortcuts="Z Meta+Z"
onClick={onUndo}
className="ml-auto inline-flex cursor-pointer items-center gap-1.5 rounded-md px-1.5 py-0.5 text-foreground/45 transition-colors hover:bg-foreground/5 hover:text-foreground/80 data-pressed:bg-foreground/10 data-pressed:text-foreground/90"
>
<Kbd keys="Z" /> undo
</button>
)}
</p>
)
}