← ui

verdict

ui

like, neutral or dislike, hide and a star, with their keys: a pass over a list

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

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

Latest changes · 2026-10-12
  • New: Verdict (inline) and VerdictBar (floating over a list, with the one before and after on ↑ ↓): like, neutral or dislike (always one), hidden and favourite. Keys from one table, VERDICT_KEYS: K U X W S, never repeated while held; inline they answer while the hand holds the card (within), the bar while the hand is in its list. Controlled: value, onValueChange(next, act); judge(value, act) is the rule.
Updated 2026-10-12 · release notes

also called triage, keep or kill, like dislike

shelf ratings & feedback

uses keys, button, kbd, key-router, material, toggle-group

the demo above, verbatim

verdict.demo.tsx
"use client"

import * as React from "react"
import { Sample } from "@/app/samples"
import { cn } from "@/lib/utils"
import { frameStills } from "@/registry/base-nova/lib/film-stills-core"
import { FilmMosaic } from "@/registry/base-nova/ui/film-mosaic"
import { useStills } from "@/registry/base-nova/ui/film-stills"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import {
  NO_VERDICT,
  Verdict,
  type VerdictAct,
  VerdictBar,
  type VerdictValue,
} from "@/registry/base-nova/ui/verdict"

// #region films
// From the gallery's corpus (Wikimedia Commons), on its CDN, each with its stills cut
// a second apart.
const CDN = "https://cdn.untitled.garden/media/ui/gallery"
const film = (id: string, label: string, duration: number, count: number) => ({
  id,
  label,
  src: `${CDN}/${id}.mp4`,
  duration,
  aspect: 16 / 9,
  stills: frameStills({ every: 1, count, src: `${CDN}/${id}.frame-{i}.jpg` }),
})
const FILMS = [
  film("461a38825c3d7b1172c6d6b2", "one small step", 56_112, 56),
  film("e141b3900933b1fd907b5d25", "Le Déjeuner des Minet (1906)", 20_003, 20),
  film("62c7eb4b4d80d8f33d1b3b6e", "a howling husky", 20_000, 20),
  film("f762b3b2b89d96748661931f", "a golden sunset in January", 20_020, 20),
  film("d5c514cac050efb84897d396", "snow falling by a fence", 20_020, 20),
  film("27c88db7b1cd2ca74c71156a", "waves at Isola Rossa", 20_000, 20),
]
type Film = (typeof FILMS)[number]
// #endregion

// #region card
/** One film on a card: its tiles, and its verdict, whose keys answer while the hand
 *  is on the card. */
function Card({
  film,
  value,
  onValueChange,
  active = false,
  onEnter,
  size = "sm",
}: {
  film: Film
  value: VerdictValue
  onValueChange: (next: VerdictValue, act: VerdictAct) => void
  active?: boolean
  onEnter?: () => void
  size?: "sm" | "md"
}) {
  const card = React.useRef<HTMLDivElement>(null)
  const stills = useStills([film.stills])
  return (
    <div
      ref={card}
      onPointerEnter={onEnter}
      data-active={active || undefined}
      className={cn(
        "flex flex-col gap-2 rounded-2xl border border-border p-3 transition-[opacity,border-color] duration-200",
        active ? "border-foreground/40" : "opacity-80",
        value.hidden && "opacity-40",
      )}
    >
      <div className="flex min-w-0 items-center justify-between gap-2">
        <span className="truncate font-medium text-[13px]">{film.label}</span>
        <Verdict
          label={film.label}
          value={value}
          onValueChange={onValueChange}
          within={card}
          size={size}
        />
      </div>
      <FilmMosaic
        tile={96}
        label={film.label}
        duration={film.duration}
        aspect={film.aspect}
        src={film.src}
        stills={stills}
      />
    </div>
  )
}
// #endregion

// #region one
function One() {
  const [value, setValue] = React.useState<VerdictValue>(NO_VERDICT)
  return (
    <Verdict label="one small step" value={value} onValueChange={setValue} />
  )
}
// #endregion

// #region triage
/** A pass over a list: the card under the hand (or chosen with ↑ ↓) is the one the
 *  bar judges, each verdict kept by the page; a judgement moves on to the next. */
function Triage({ float = "sticky" }: { float?: "sticky" | "fixed" }) {
  const [verdicts, setVerdicts] = React.useState<Record<string, VerdictValue>>(
    {},
  )
  const [active, setActive] = React.useState(0)
  const [log, setLog] = React.useState<string[]>([])
  const cards = React.useRef(new Map<number, HTMLElement>())
  const go = (i: number) => {
    const next = Math.min(FILMS.length - 1, Math.max(0, i))
    setActive(next)
    cards.current
      .get(next)
      ?.scrollIntoView({ block: "nearest", behavior: "smooth" })
  }
  const judge = (f: Film, next: VerdictValue, act: VerdictAct) => {
    setVerdicts((all) => ({ ...all, [f.id]: next }))
    setLog((all) => [`${act} · ${f.label}`, ...all].slice(0, 4))
    // An opinion is a step in the pass: on to the next.
    if (act === "like" || act === "dislike") go(FILMS.indexOf(f) + 1)
  }
  const current = FILMS[active] as Film
  return (
    <div className="flex flex-col gap-3">
      {FILMS.map((f, i) => (
        <div
          key={f.id}
          ref={(node) => {
            if (node) cards.current.set(i, node)
            else cards.current.delete(i)
          }}
        >
          <Card
            film={f}
            value={verdicts[f.id] ?? NO_VERDICT}
            onValueChange={(next, act) => judge(f, next, act)}
            active={i === active}
            onEnter={() => setActive(i)}
          />
        </div>
      ))}
      <p className="m-0 font-mono text-[11px] text-foreground/60">
        {log.length
          ? log.join("  ·  ")
          : "K like · U neutral · X dislike · W hide · S star · ↑ ↓ move"}
      </p>
      <VerdictBar
        float={float}
        label={current.label}
        value={verdicts[current.id] ?? NO_VERDICT}
        onValueChange={(next, act) => judge(current, next, act)}
        onPrevious={active > 0 ? () => go(active - 1) : undefined}
        onNext={active < FILMS.length - 1 ? () => go(active + 1) : undefined}
      />
    </div>
  )
}
// #endregion

// #region states
/** Every state at once, and a title too long for the bar: it is cut, whole in its
 *  title. */
function States() {
  const [value, setValue] = React.useState<VerdictValue>({
    opinion: "dislike",
    hidden: true,
    favorite: true,
  })
  return (
    <div className="flex flex-col items-start gap-3">
      <Verdict label="a film" value={value} onValueChange={setValue} />
      <Verdict
        label="a film"
        value={value}
        onValueChange={setValue}
        size="sm"
      />
      <div className="w-full">
        <VerdictBar
          label="Braided River in Tibet Redraws Its Channels, a NASA Earth Observatory animation using Landsat data"
          value={value}
          onValueChange={setValue}
        />
      </div>
    </div>
  )
}
// #endregion

export default function VerdictDemo() {
  return (
    <div className="space-y-8">
      <Sample
        name="one"
        glance="wide"
        title="a verdict"
        description="Like, neutral or dislike, and hide and a star beside them. The keys answer while the hand is on it: K U X W S."
        with="one"
      >
        <One />
      </Sample>
      <Sample
        name="triage"
        title="a pass over a list"
        description="Each card's verdict answers its keys under the hand; the bar floating at the foot judges the card in hand from anywhere, ↑ ↓ to the one before and after. A like or a dislike moves on."
        with="films card triage"
      >
        <Triage />
      </Sample>
      <Sample name="glass" title="in glass" with="films card triage">
        <MaterialProvider material="glass" scheme="dark">
          <div className="rounded-2xl bg-[url(/lightbox/shore.jpg)] bg-cover bg-center p-4 text-white sm:p-8">
            <Triage />
          </div>
        </MaterialProvider>
      </Sample>
      <Sample name="states" title="every state, a long title" with="states">
        <States />
      </Sample>
    </div>
  )
}

the source, every file an install writes

verdict.tsx
"use client"

// A PERSON'S VERDICT ON A THING, for triage: like, neutral or dislike, and two marks
// beside the opinion, hidden and favourite (a film library's judging rail: K like, U
// neutral, X dislike, W hide, S the star). Inline on a card (`Verdict`) and as a bar that floats
// over a list naming the thing in hand, with the way to the one before and after
// (`VerdictBar`, ↑ and ↓). Controlled: the consumer holds the value and does what a
// verdict means in its world (a dislike that removes, a hide that filters), and the
// journal that undoes it.
//
//   opinion   three options, always one (a segmented control, toggle-group's: one Tab
//             stop, the arrows between them, the highlight gliding); neutral is the
//             default AND the way back, a press on the chosen one keeps it.
//   hidden,   two toggles (`Button` with aria-pressed, a fixed name).
//   favorite
//   keys      THE ONE TABLE (`VERDICT_KEYS`) the handler, the hints in the buttons and
//             aria-keyshortcuts read. On the key router: an inline verdict answers
//             only while the hand holds its card (`within`: the pointer over it, or the
//             focus in it), so a page of cards never judges the wrong one; the bar
//             while the hand is in the list it floats over (the page's, when the list
//             is the page), after anything nearer (a player under the hand keeps its
//             K). A held key never repeats a verdict: each one is a deliberate press.
//             What a key did is said politely.
//
// Plain or glass: the registry's ToggleGroup and Button in the material; the bar a pane
// of the material's glass, or the page's popover surface.

import {
  ChevronDown,
  ChevronUp,
  Circle,
  EyeOff,
  Star,
  ThumbsDown,
  ThumbsUp,
} from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
import { ariaKeyshortcuts, capOf } from "@/registry/base-nova/lib/keys"
import { Button } from "@/registry/base-nova/ui/button"
import { Kbd } from "@/registry/base-nova/ui/kbd"
import { useKeys } from "@/registry/base-nova/ui/key-router"
import {
  type Material,
  MaterialProvider,
  type Scheme,
  useMaterial,
  useSurfaceScheme,
} from "@/registry/base-nova/ui/material"
import { glassVariants, schemeOf } from "@/registry/base-nova/ui/material-glass"
import {
  ToggleGroup,
  ToggleGroupItem,
} from "@/registry/base-nova/ui/toggle-group"

export type Opinion = "like" | "neutral" | "dislike"

/** A verdict: the opinion, and the two marks beside it. */
export interface VerdictValue {
  opinion: Opinion
  hidden: boolean
  favorite: boolean
}

/** Nothing said yet: neutral, shown, no star. */
export const NO_VERDICT: VerdictValue = {
  opinion: "neutral",
  hidden: false,
  favorite: false,
}

/** What a verdict's key does: an opinion set, or a mark turned on or off. */
export type VerdictAct = Opinion | "hide" | "favorite"

/** The keys, one table: the handler answers through it, the buttons hint it, and
 *  aria-keyshortcuts is derived from it. */
export const VERDICT_KEYS: readonly { key: string; act: VerdictAct }[] = [
  { key: "k", act: "like" },
  { key: "u", act: "neutral" },
  { key: "x", act: "dislike" },
  { key: "w", act: "hide" },
  { key: "s", act: "favorite" },
]

/** The key that does `act`. */
const keyOf = (act: VerdictAct) =>
  (VERDICT_KEYS.find((k) => k.act === act) as { key: string }).key

/** The verdict after `act`: an opinion replaces the opinion, a mark flips. */
export function judge(value: VerdictValue, act: VerdictAct): VerdictValue {
  if (act === "hide") return { ...value, hidden: !value.hidden }
  if (act === "favorite") return { ...value, favorite: !value.favorite }
  return { ...value, opinion: act }
}

/** What an act did, said: "liked the moon landing", "the moon landing hidden". */
function saying(act: VerdictAct, next: VerdictValue, label: string): string {
  if (act === "hide") return `${label} ${next.hidden ? "hidden" : "shown"}`
  if (act === "favorite")
    return `${label} ${next.favorite ? "a favourite" : "no longer a favourite"}`
  return act === "neutral"
    ? `${label}: no opinion`
    : `${act === "like" ? "liked" : "disliked"} ${label}`
}

/** The act a press asks for, or null: a verdict key alone, never held down. */
function actOf(event: KeyboardEvent): VerdictAct | null {
  if (event.metaKey || event.ctrlKey || event.altKey || event.repeat)
    return null
  const cap = capOf(event).toLowerCase()
  return VERDICT_KEYS.find((k) => k.key === cap)?.act ?? null
}

const OPINIONS: readonly {
  value: Opinion
  words: string
  Glyph: typeof ThumbsUp
  tone: string
}[] = [
  {
    value: "like",
    words: "like",
    Glyph: ThumbsUp,
    tone: "data-pressed:text-(--tone-ok)",
  },
  { value: "neutral", words: "neutral", Glyph: Circle, tone: "" },
  {
    value: "dislike",
    words: "dislike",
    Glyph: ThumbsDown,
    tone: "data-pressed:text-(--tone-bad)",
  },
]

export interface VerdictProps {
  value: VerdictValue
  /** The verdict a press or a key makes, and the act that made it. */
  onValueChange: (next: VerdictValue, act: VerdictAct) => void
  /** What is judged ("the moon landing"): every control's name says it. */
  label: string
  /** Its keys (VERDICT_KEYS) on the key router. Default on. */
  keys?: boolean
  /** The element whose hand holds this verdict (its card): the pointer over it or the
   *  focus in it, and its keys answer. Default the verdict itself. */
  within?: React.RefObject<HTMLElement | null>
  /** md: the words and the keys in the buttons; sm: the glyphs alone, the keys in
   *  their names. */
  size?: "sm" | "md"
  material?: Material
  /** The colour scheme it stands in (material): default the nearest provider's. */
  scheme?: Scheme
  className?: string
}

export function Verdict({
  value,
  onValueChange,
  label,
  keys = true,
  within,
  size = "md",
  material,
  scheme,
  className,
}: VerdictProps) {
  const m = useMaterial(material)
  const own = React.useRef<HTMLDivElement>(null)
  const [s, here] = useSurfaceScheme(scheme, own)
  const [said, setSaid] = React.useState("")
  const act = (a: VerdictAct, by: "key" | "press") => {
    const next = judge(value, a)
    onValueChange(next, a)
    if (by === "key") setSaid(saying(a, next, label))
  }
  const holder = within ?? own
  useKeys(
    (event) => {
      const a = actOf(event)
      const box = holder.current
      if (!a || !box) return false
      // Held: the focus in it, or the pointer over it (a card of many).
      const held = box.contains(document.activeElement) || box.matches(":hover")
      if (!held) return false
      act(a, "key")
      return true
    },
    { root: holder, enabled: keys },
  )
  return (
    <MaterialProvider material={m} scheme={s}>
      <VerdictControls
        ref={here}
        value={value}
        label={label}
        size={size}
        material={m}
        keys={keys}
        onAct={(a) => act(a, "press")}
        className={cn(schemeOf(s), className)}
      />
      <span className="sr-only" aria-live="polite">
        {said}
      </span>
    </MaterialProvider>
  )
}

/** The controls, drawn once for the inline verdict and the bar. */
function VerdictControls({
  ref,
  value,
  label,
  size,
  material,
  keys,
  onAct,
  className,
}: {
  ref?: React.Ref<HTMLDivElement>
  value: VerdictValue
  label: string
  size: "sm" | "md"
  material: Material
  keys: boolean
  onAct: (act: VerdictAct) => void
  className?: string
}) {
  const md = size === "md"
  const hint = (act: VerdictAct) =>
    keys && md ? (
      <Kbd
        keys={keyOf(act)}
        material={material}
        className="text-[10px] opacity-70"
      />
    ) : null
  const shortcut = (act: VerdictAct) =>
    keys ? ariaKeyshortcuts([keyOf(act)]) : undefined
  return (
    // biome-ignore lint/a11y/useSemanticElements: a run of controls judging one thing; a fieldset groups form fields
    <div
      ref={ref}
      role="group"
      aria-label={`verdict on ${label}`}
      data-slot="verdict"
      data-material={material}
      data-opinion={value.opinion}
      data-hidden={value.hidden || undefined}
      data-favorite={value.favorite || undefined}
      className={cn(
        "inline-flex w-fit max-w-full items-center gap-1",
        className,
      )}
    >
      <ToggleGroup
        aria-label={`opinion of ${label}`}
        size={md ? "md" : "sm"}
        value={value.opinion}
        onValueChange={(v) => onAct(v as Opinion)}
      >
        {OPINIONS.map((o) => (
          <ToggleGroupItem
            key={o.value}
            value={o.value}
            aria-label={o.words}
            aria-keyshortcuts={shortcut(o.value)}
            // The item keeps toggle-group's own slot (its highlight finds it by it).
            data-verdict={o.value}
            className={o.tone}
          >
            <o.Glyph
              aria-hidden
              className={cn(
                value.opinion === o.value &&
                  o.value !== "neutral" &&
                  "fill-current/25",
              )}
            />
            {md && <span>{o.words}</span>}
            {hint(o.value)}
          </ToggleGroupItem>
        ))}
      </ToggleGroup>
      <Button
        variant="ghost"
        size={md ? "md" : "icon-sm"}
        pressed={value.hidden}
        aria-label="hidden"
        aria-keyshortcuts={shortcut("hide")}
        data-slot="verdict-hide"
        onClick={() => onAct("hide")}
        className={value.hidden ? "text-current" : "text-current/60"}
      >
        <EyeOff aria-hidden />
        {md && <span>hide</span>}
        {hint("hide")}
      </Button>
      <Button
        variant="ghost"
        size={md ? "md" : "icon-sm"}
        pressed={value.favorite}
        aria-label="favourite"
        aria-keyshortcuts={shortcut("favorite")}
        data-slot="verdict-favorite"
        onClick={() => onAct("favorite")}
        className={value.favorite ? "text-current" : "text-current/60"}
      >
        <Star aria-hidden className={cn(value.favorite && "fill-current")} />
        {hint("favorite")}
      </Button>
    </div>
  )
}

export interface VerdictBarProps
  extends Omit<VerdictProps, "keys" | "within" | "size"> {
  /** The thing before, and after, in the list it floats over (↑, ↓). */
  onPrevious?: () => void
  onNext?: () => void
  /** Where it floats: "sticky", at the foot of the list it is placed in, while the
   *  list is on screen; "fixed", at the window's foot. */
  float?: "sticky" | "fixed"
}

/** The verdict as a bar floating over a list, naming the thing in hand, with the way
 *  to the one before and after. Its keys answer while the hand is in that list (the
 *  page, when the list is the page), after anything nearer: K U X W S judge, ↑ ↓
 *  move. */
export function VerdictBar({
  value,
  onValueChange,
  label,
  onPrevious,
  onNext,
  float = "sticky",
  material,
  scheme,
  className,
}: VerdictBarProps) {
  const m = useMaterial(material)
  const outer = React.useRef<HTMLDivElement>(null)
  const pane = React.useRef<HTMLDivElement>(null)
  const [s, here] = useSurfaceScheme(scheme, pane)
  const [said, setSaid] = React.useState("")
  // The words and keys in the buttons while the bar has the room for them, measured:
  // the pane's own width with them, against the width of the list it floats over.
  const [roomy, setRoomy] = React.useState(true)
  const needs = React.useRef(0)
  React.useLayoutEffect(() => {
    const o = outer.current
    const p = pane.current
    if (!o || !p) return
    const read = () => {
      if (roomy) {
        needs.current = p.scrollWidth
        if (needs.current > o.clientWidth) setRoomy(false)
      } else if (o.clientWidth >= needs.current) setRoomy(true)
    }
    const observer = new ResizeObserver(read)
    observer.observe(o)
    read()
    return () => observer.disconnect()
  }, [roomy])
  const act = (a: VerdictAct, by: "key" | "press") => {
    const next = judge(value, a)
    onValueChange(next, a)
    if (by === "key") setSaid(saying(a, next, label))
  }
  // The list it floats over holds it: the hand in that list (the pointer over it, the
  // focus in it), and its keys answer, after anything nearer. A list that is the page
  // makes them the page's.
  const list = () => outer.current?.parentElement ?? null
  useKeys(
    (event) => {
      if (event.metaKey || event.ctrlKey || event.altKey) return false
      const box = list()
      if (
        !box ||
        !(box.contains(document.activeElement) || box.matches(":hover"))
      )
        return false
      if (event.key === "ArrowUp" && onPrevious) {
        onPrevious()
        return true
      }
      if (event.key === "ArrowDown" && onNext) {
        onNext()
        return true
      }
      const a = actOf(event)
      if (!a) return false
      act(a, "key")
      return true
    },
    { root: list },
  )
  return (
    <div
      ref={outer}
      data-slot="verdict-bar"
      className={cn(
        "pointer-events-none z-40 flex justify-center",
        float === "fixed" ? "fixed inset-x-0 bottom-5" : "sticky bottom-4 mt-3",
        className,
      )}
    >
      <MaterialProvider material={m} scheme={s}>
        <div
          ref={here}
          data-slot="verdict-bar-pane"
          data-material={m}
          className={cn(
            "pointer-events-auto flex min-w-0 max-w-full items-center gap-1.5 px-2 py-1.5",
            schemeOf(s),
            m === "glass"
              ? [glassVariants({ shape: "surface" }), "[--glass-radius:24px]"]
              : "rounded-2xl border border-border bg-popover/95 text-popover-foreground shadow-xl backdrop-blur-md",
          )}
        >
          {(onPrevious || onNext) && (
            <>
              <Button
                variant="ghost"
                size="icon-sm"
                aria-label="the one before"
                aria-keyshortcuts="ArrowUp"
                disabled={!onPrevious}
                onClick={onPrevious}
              >
                <ChevronUp />
              </Button>
              <Button
                variant="ghost"
                size="icon-sm"
                aria-label="the one after"
                aria-keyshortcuts="ArrowDown"
                disabled={!onNext}
                onClick={onNext}
              >
                <ChevronDown />
              </Button>
            </>
          )}
          <span
            data-slot="verdict-bar-label"
            title={label}
            className="min-w-0 max-w-64 shrink truncate px-1 font-medium text-[13px]"
          >
            {label}
          </span>
          <VerdictControls
            value={value}
            label={label}
            size={roomy ? "md" : "sm"}
            material={m}
            keys
            onAct={(a) => act(a, "press")}
            className="shrink-0"
          />
          <span className="sr-only" aria-live="polite">
            {said}
          </span>
        </div>
      </MaterialProvider>
    </div>
  )
}