← ui

answer-keys

ui

a question's answers as keys: yes, no or a choice, then the reference answer

npx shadcn add @ag/answer-keys
Already installed? Preview an update
npx shadcn add @ag/answer-keys --dry-run

Your 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.
Updated 2026-10-11 · release notes

also called quiz

shelf text & code

uses kbd, keys, key-router

the demo above, verbatim

answer-keys.demo.tsx
"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

answer-keys.tsx
"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>
  )
}