← ui

thumbs

ui

was this helpful? yes or no, then why in a few chips and words, then thanks

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

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

Latest changes · 2026-10-12
  • Fixed: a controlled value changed 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.
Updated 2026-10-12 · release notes

also called feedback, thumbs up, helpful

shelf ratings & feedback

uses button, material, textarea, toggle-group

the demo above, verbatim

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

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