was this helpful? yes or no, then why in a few chips and words, then thanks
npx shadcn add @ag/thumbsAlready installed? Preview an update
npx shadcn add @ag/thumbs --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-10-12
- Fixed: a controlled
valuechanged from outside (a saved answer restored, a press the parent refused) shows as answered or not as it stands, with nothing half told; the stage kept what it was at mount.
also called feedback, thumbs up, helpful
shelf ratings & feedback
uses button, material, textarea, toggle-group
the demo above, verbatim
"use client"
import * as React from "react"
import { Sample } from "@/app/samples"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import { Thumbs, type ThumbsAnswer } from "@/registry/base-nova/ui/thumbs"
// #region why
const WHY = {
up: ["clear", "had the example I needed"],
down: ["wrong", "hard to follow", "missing something"],
}
// #endregion
// #region docs
/** A docs page's question: the thumb is heard at once, why only if they say. */
function Docs() {
const [answer, setAnswer] = React.useState<ThumbsAnswer | null>(null)
return (
<div className="flex flex-col gap-3">
<Thumbs reasons={WHY} onSend={setAnswer} />
<span className="font-mono text-[11px] text-current/55">
{answer ? JSON.stringify(answer) : "nothing sent yet"}
</span>
</div>
)
}
// #endregion
export default function ThumbsDemo() {
return (
<div className="space-y-8">
<Sample name="glance" glance title="was this helpful?" with="why">
<Thumbs reasons={WHY} />
</Sample>
<Sample
name="docs"
title="yes, no, and why"
description="a thumb opens the reasons for its side and a line of words, in place; send thanks them and gives the focus back to the thumb. the same thumb again takes the answer back."
with="why docs"
>
<Docs />
</Sample>
<Sample
name="glass"
title="in glass"
description="a pane of glass holds it over the picture, the thumbs, the chips and the words of glass on it."
with="why docs"
>
<MaterialProvider material="glass" scheme="dark">
<div className="rounded-xl bg-[url(/lightbox/lake.jpg)] bg-cover bg-center p-6 text-white">
<Docs />
</div>
</MaterialProvider>
</Sample>
<Sample
name="quick"
title="nothing more to ask"
description="no reasons and no note: a thumb is thanked at once."
>
<Thumbs question="did this answer it?" size="sm" note={false} />
</Sample>
</div>
)
}the source, every file an install writes
"use client"
import { ThumbsDown, ThumbsUp } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
import { Button } from "@/registry/base-nova/ui/button"
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 { Textarea } from "@/registry/base-nova/ui/textarea"
import {
ToggleGroup,
ToggleGroupItem,
} from "@/registry/base-nova/ui/toggle-group"
// "WAS THIS HELPFUL?" A question, yes or no, and then, only if the answer has more
// to say, why: a few reasons as chips and a line of words, then sent and thanked.
// The docs page's and the answer's feedback (Stripe's docs, a chat's reply).
//
// asked the question and two thumbs (`Button`s with `aria-pressed`)
// telling a thumb chosen whose side has `reasons` or a `note`: they open under
// it in place, with send; the same thumb again takes the answer back,
// the other one changes side
// thanked sent (or a side with nothing to ask): the thanks in place of the
// follow-up, said politely, the focus back on the chosen thumb
//
// `onValueChange` hears the thumb at once (the cheap signal a page records even if
// no one says why); `onSend` hears the whole answer once, synchronously: sending it
// is the page's, and the thanks does not wait for it (nobody is helped by a spinner
// on a "thanks"). Plain, the row on the page; glass, a pane of glass holding it,
// the thumbs, the chips and the words of glass on it.
export type ThumbsValue = "up" | "down" | null
export interface ThumbsAnswer {
value: "up" | "down"
reasons: string[]
note: string
}
export interface ThumbsProps {
/** What is asked; "was this helpful?" unless given. */
question?: React.ReactNode
/** The thumb chosen; null for none. Controlled with `onValueChange`. */
value?: ThumbsValue
defaultValue?: ThumbsValue
onValueChange?: (value: ThumbsValue) => void
/** Why, offered as chips once a side is chosen. A side without reasons (and with
* no note) is thanked at once. */
reasons?: { up?: readonly string[]; down?: readonly string[] }
/** A line of words under the reasons: its placeholder, or false for none. */
note?: string | false
/** The whole answer, once, when it is sent. */
onSend?: (answer: ThumbsAnswer) => void
/** Said once it is sent; "thanks, noted." unless given. */
thanks?: React.ReactNode
size?: "sm" | "md"
material?: Material
/** The colour scheme it stands in (material): default the nearest provider's. */
scheme?: Scheme
className?: string
}
type Stage = "asked" | "telling" | "thanked"
export function Thumbs({
question = "was this helpful?",
value: given,
defaultValue = null,
onValueChange,
reasons = {},
note = "anything else? (optional)",
onSend,
thanks = "thanks, noted.",
size = "md",
material,
scheme,
className,
}: ThumbsProps) {
const m = useMaterial(material)
const [s, here] = useSurfaceScheme<HTMLDivElement>(scheme)
const id = React.useId()
const [own, setOwn] = React.useState<ThumbsValue>(defaultValue)
const value = given === undefined ? own : given
const [stage, setStage] = React.useState<Stage>(
value === null ? "asked" : "thanked",
)
const [chosen, setChosen] = React.useState<string[]>([])
const [words, setWords] = React.useState("")
// The value the stage was set for. A value that changes from outside (a saved
// answer restored, a parent that refused the press) is answered or not as it
// stands, with nothing half told: the reasons offered were for another side.
const [stageOf, setStageOf] = React.useState(value)
if (stageOf !== value) {
setStageOf(value)
setStage(value === null ? "asked" : "thanked")
setChosen([])
setWords("")
}
const thumb = {
up: React.useRef<HTMLButtonElement>(null),
down: React.useRef<HTMLButtonElement>(null),
}
// After a send the send button is gone: the focus goes back to the thumb chosen,
// after the render that took the button away.
const refocus = React.useRef<"up" | "down" | null>(null)
React.useEffect(() => {
if (!refocus.current) return
thumb[refocus.current].current?.focus()
refocus.current = null
})
const asks = (side: "up" | "down") =>
(reasons[side]?.length ?? 0) > 0 || note !== false
const send = (side: "up" | "down") => {
onSend?.({ value: side, reasons: chosen, note: words.trim() })
refocus.current = side
setStage("thanked")
}
const press = (side: "up" | "down") => {
const next = value === side ? null : side
if (given === undefined) setOwn(next)
onValueChange?.(next)
setStageOf(next)
setChosen([])
setWords("")
if (next === null) return setStage("asked")
if (asks(next)) return setStage("telling")
onSend?.({ value: next, reasons: [], note: "" })
setStage("thanked")
}
const offered = value ? (reasons[value] ?? []) : []
const open = stage === "telling" && value !== null
const text = size === "sm" ? "text-[12px]" : "text-[13px]"
return (
<MaterialProvider material={m} scheme={s}>
<div
ref={here}
data-slot="thumbs"
data-material={m}
data-stage={stage}
data-value={value ?? "none"}
className={cn(
"flex w-fit max-w-full flex-col",
schemeOf(s),
// Glass stands on a picture, where words with nothing under them are
// lost: the question and what follows it are a pane of glass.
m === "glass" &&
cn(
glassVariants({ shape: "surface" }),
"px-3 py-2 [--glass-radius:--theme(--radius-2xl)]",
),
text,
className,
)}
>
<div className="flex items-center gap-2">
<span id={`${id}-question`} className="text-current/80">
{question}
</span>
<fieldset
aria-labelledby={`${id}-question`}
className="flex min-w-0 items-center gap-0.5"
>
{(["up", "down"] as const).map((side) => {
const Glyph = side === "up" ? ThumbsUp : ThumbsDown
const on = value === side
return (
<Button
key={side}
ref={thumb[side]}
variant="ghost"
size={size === "sm" ? "icon-sm" : "icon"}
corners="pill"
pressed={on}
aria-label={side === "up" ? "yes" : "no"}
data-slot={`thumbs-${side}`}
onClick={() => press(side)}
className={on ? "text-current" : "text-current/60"}
>
<Glyph aria-hidden className={cn(on && "fill-current/25")} />
</Button>
)
})}
</fieldset>
</div>
{/* The follow-up opens in place, its row growing from nothing. */}
<div
className={cn(
"grid transition-[grid-template-rows,opacity] duration-200 ease-out motion-reduce:transition-none",
open ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0",
)}
inert={!open}
>
<div className="-m-1 min-h-0 overflow-hidden p-1">
<form
data-slot="thumbs-follow-up"
aria-label="tell us more"
className="flex flex-col items-start gap-2 pt-2"
onSubmit={(e) => {
e.preventDefault()
if (value) send(value)
}}
>
{offered.length > 0 && (
<ToggleGroup
multiple
size="sm"
aria-label={
value === "down" ? "what went wrong" : "what helped"
}
value={chosen}
onValueChange={setChosen}
className="h-auto flex-wrap"
>
{offered.map((r) => (
<ToggleGroupItem key={r} value={r}>
{r}
</ToggleGroupItem>
))}
</ToggleGroup>
)}
{note !== false && (
<Textarea
size="sm"
rows={1}
maxRows={4}
aria-label="anything else"
placeholder={note}
value={words}
onChange={(e) => setWords(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
if (value) send(value)
}
}}
/>
)}
<Button type="submit" size="sm" variant="secondary">
send
</Button>
</form>
</div>
</div>
<p
data-slot="thumbs-thanks"
aria-live="polite"
className={cn(
"text-current/60 transition-opacity duration-200 motion-reduce:transition-none",
stage === "thanked" && value !== null
? "pt-1 opacity-100"
: "h-0 opacity-0",
)}
>
{stage === "thanked" && value !== null ? thanks : ""}
</p>
</div>
</MaterialProvider>
)
}