like, neutral or dislike, hide and a star, with their keys: a pass over a list
npx shadcn add @ag/verdictAlready installed? Preview an update
npx shadcn add @ag/verdict --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-10-12
- New:
Verdict(inline) andVerdictBar(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.
also called triage, keep or kill, like dislike
shelf ratings & feedback
uses keys, button, kbd, key-router, material, toggle-group
the demo above, verbatim
"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
"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>
)
}