AI

Response Feedback

Thumbs up or down on one AI answer, with a follow-up panel of reason chips and a comment, and exactly one submission per message — locked into a receipt when it lands.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Check, CircleAlert, LoaderCircle, ThumbsDown, ThumbsUp, X } from "lucide-react"
import { Popover as PopoverPrimitive } from "radix-ui"

import { cn } from "@/lib/utils"
import { useControllableState } from "@/registry/hooks/use-controllable-state"

/* -------------------------------------------------------------------------- *
 * Contract
 *
 * One answer, one submission. The thumb is the rating; the panel that follows
 * is the *reason*, and reasons are always optional — a reader who presses a

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/response-feedback.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "ResponseFeedback" component (lucide-react icons,
a Radix Popover for the follow-up panel, and the project's cn() helper) that collects
thumbs up / down on ONE assistant message plus an optional reason, and submits it exactly
once.

Contract
- forwardRef<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement> minus
  onSubmit and children. The root is role="group" with an accessible name.
- vote?: "up" | "down" | null            — controlled rating; null is "no rating",
                                           undefined means uncontrolled.
  defaultVote?: "up" | "down" | null     — initial rating, default null.
  onVoteChange?(vote)                    — fires when a thumb is pressed or withdrawn,
                                           BEFORE anything is submitted.
- onSubmit?(submission: { vote, reasons: string[], comment: string }) => void | Promise<unknown>
                                         — the one submission. Return a promise to get a
                                           pending state.
- upReasons?: string[]   (default ["Accurate", "Clear", "Well written"])
  downReasons?: string[] (default ["Inaccurate", "Unsafe", "Unhelpful", "Other"])
  otherReason?: string | null (default "Other") — the chip that makes the comment mandatory.
  allowComment?: boolean (default true)  — include the free-text box.
  maxLength?: number (default 500)       — comment budget.
  submitted?: boolean                    — this message already carries feedback (from your
                                           server): render the receipt, not a fresh control.
  disabled?: boolean                     — read-only transcript; the recorded rating still shows.
  question?: React.ReactNode             — optional lead-in, e.g. "Was this helpful?".
  labels?: Partial<Labels>               — every user-facing string, merged over defaults
                                           (group / up / down / upTitle / downTitle /
                                           commentPlaceholder / commentRequired /
                                           charactersLeft / send / sending / dismiss /
                                           thanks / ratedUp / ratedDown / cleared /
                                           error / retry).
- Use one controllable-state hook for `vote` so controlled and uncontrolled behave the
  same; the component never branches on which mode it is in.

Behavior — the state machine (this is the component)
- idle -> pressing a thumb SELECTS it (aria-pressed) and fires onVoteChange. It does NOT
  submit. If that vote has anything to ask (reasons for it, or allowComment), the follow-up
  panel opens; if it has nothing to ask, the vote goes straight to submit.
- Pressing the OTHER thumb switches the vote and clears reasons + comment: praise tags and
  complaint reasons are different vocabularies, and carrying a selection across would submit
  a reason nobody chose.
- Pressing the PRESSED thumb withdraws the rating: vote becomes null, the panel closes,
  the draft is dropped, nothing is sent. This is the only undo, and it is what makes the
  next rule safe.
- Dismissing is NOT cancelling. Escape, an outside click and the panel's ✕ all submit
  { vote, reasons: [], comment: "" }. The reader did press a thumb, and that rating is data;
  never make someone press twice to be counted. Name the ✕ accordingly ("Close and send the
  rating without details"), not "Cancel".
- Send submits { vote, reasons, comment.trim() }.
- ONE submission per message, enforced by a ref that flips BEFORE the handler is called —
  not by a state flag. A dismissal and a Send can both fire inside one tick and a state
  flag is still false in both closures. Release the ref only when a submission actually
  fails, so retry is possible and a double-submit is not.
- Async submit: if the handler returns a thenable, go busy — the panel stays open, chips /
  textarea / Send are disabled, Send shows a spinner, the panel gets aria-busy, and Escape
  is ignored so nothing tears the pending state out. Resolved -> lock. Rejected -> release
  the lock, keep the entire draft, show a destructive line and relabel Send "Try again".
  A synchronous handler locks immediately: no phantom spinner for a consumer that just
  pushes onto an array.
- A submission can also fail with NO panel on screen (a one-tap rating, or a dismissal).
  Render an inline destructive row with its own "Try again" that re-sends the exact payload
  kept in a ref — otherwise that failure is invisible.
- Locked (or `submitted`) renders a receipt: a check, "Thanks for the feedback" and the
  thumb that was recorded. The thumbs are gone — that is what "one submission" means.
- Comment rules: ticking `otherReason` makes the comment required (Send disabled until it
  is non-empty) and moves the caret into the box, because "Other" with no sentence is not
  a reason. The length budget is enforced by disabling Send, NEVER by the native maxLength
  attribute, which silently truncates a paste; show the counter only in the last ~60
  characters and turn it destructive past zero. Cmd/Ctrl+Enter sends; plain Enter is a
  newline, because this is a paragraph field.

Behavior — focus, announcements, cleanup
- The panel is portalled (a message footer usually lives in a scroller with
  overflow-hidden) and anchored to the ROW, not to one thumb, so switching votes does not
  make it hop sideways.
- The thumbs are outside the portalled panel, so pressing one reads as an outside
  interaction: intercept onInteractOutside and preventDefault when the target is inside the
  row, or the panel dismisses (and submits) out from under the press.
- onOpenAutoFocus: preventDefault and focus the panel container itself, so a screen reader
  reads the question ("What went wrong?") before the first chip.
- Locking unmounts the button that had focus, which drops focus on <body>. Move it to the
  receipt (tabIndex={-1}) — but only when the focus is still yours to hand on. Note that on
  the async path it is never still *held*: going busy puts the native disabled attribute on
  the control that was pressed and a dismissal unmounts the panel, so by the time the
  promise settles focus is already on <body>. So read focus at BOTH ends: remember whether
  it was inside the widget or the panel when the submission started, and at settle time hand
  it to the receipt if it is still in there OR nowhere at all (<body>/null) after a
  submission that owned it. Leave it alone once it has reached some other real element, and
  when the close came from an outside click/Tab that is already sending focus elsewhere: an
  async submission that settles minutes later must never yank focus back.
- One polite sr-only role="status" announces "marked good/bad", "rating removed" and the
  error; clear it on a timer so an identical next message is announced again. Skip the
  "thanks" announcement when focus moved to the receipt, which reads itself out.
- Cleanup: clear the announcement timer on unmount, and guard the promise callbacks with a
  mounted ref so a late resolution never sets state on an unmounted tree.

Rendering & styling
- Semantic tokens only: text-muted-foreground, hover:bg-accent / hover:text-accent-foreground,
  bg-primary/10 + text-primary for a pressed thumbs-up, bg-destructive/10 + text-destructive
  for a pressed thumbs-down, bg-primary + text-primary-foreground for selected chips and
  Send, border / bg-popover / text-popover-foreground / shadow-md for the panel,
  bg-background for the textarea, text-destructive for errors and the over-limit counter,
  focus-visible:ring-2 ring-ring everywhere. No hex, no rgb(), no oklch().
- cn() merges the consumer className onto the root; the root is w-fit so the control never
  stretches a message footer, and the panel is w-72 max-w-[calc(100vw-2rem)].
- Selected chips are expressed as aria-pressed toggles and styled through the
  aria-pressed: variant, so the visual state and the announced state cannot drift.
- Panel entrance is data-[state=open]:animate-in fade/zoom with motion-reduce:animate-none;
  the spinner is animate-spin motion-reduce:animate-none and the word "Sending…" carries
  the same information. Nothing stops working under reduced motion.
- Icons are aria-hidden; every icon-only button has an aria-label; the panel is a
  role="dialog" labelled by its visible title.

Customization levers
- Reason vocabularies: `upReasons` / `downReasons` per surface (a code assistant wants
  "Wrong API", "Did not compile"; a support bot wants "Rude", "Off-policy"). Pass [] to ask
  nothing on that side — with allowComment={false} that turns the thumb into the whole
  submission.
- Follow-up depth: allowComment={false} for chips only (no free text to moderate), or
  otherReason={null} to never require a sentence.
- Dismiss policy: the three lines in `dismiss()` that send the bare rating. Delete them if
  your product treats an abandoned panel as no feedback at all — then also give the reader
  a way back into the panel, because pressing the pressed thumb withdraws.
- Lock policy: `submitted` is the server's answer, the internal lock is the session's. Drop
  the internal one (keep the thumbs mounted after a submission) only if your backend
  accepts an update for the same message id.
- Copy and language: everything through `labels`; `question` adds a lead-in for surfaces
  where the thumbs alone are too cryptic.
- Density: size-8 thumbs and gap-1 in the row, p-3 in the panel — one place each. Wrap the
  thumbs in your app's Tooltip if you want the hint visible; the accessible names already
  exist.
- Placement: side / align / sideOffset on the popover, or replace the popover with an
  inline block under the message if your layout has room and you would rather not portal.

Concepts

  • The thumb is not the submission — pressing it only selects a rating and opens the follow-up; onVoteChange fires there so an optimistic UI can react, but nothing reaches onSubmit until the panel resolves. Splitting the two is what lets the reason be optional without costing you the rating.
  • Dismiss is not cancel — Escape, an outside click and the ✕ all send { vote, reasons: [], comment: "" }, because the reader did press a thumb. The undo is deliberately somewhere else: pressing the pressed thumb withdraws the rating before anything leaves the browser.
  • One-shot lock in a ref — a dismissal and a Send can fire inside the same tick, and a useState flag is still false in both closures; a ref flipped before the handler runs is the only thing that makes "one submission per message" true. It is released only by a failure, which is what makes retry possible without making a double possible.
  • Failure keeps the draft — a rejected promise unlocks the control with every chip and character intact and relabels Send "Try again"; a failure that happened with no panel on screen gets its own inline retry row, because an invisible failure is indistinguishable from success.
  • "Other" is a promise to explain — selecting the escape-hatch chip makes the comment mandatory and moves the caret into it, instead of leaving Send disabled with no visible reason.
  • Focus handoff — locking unmounts the button that had focus, so focus is handed to the receipt. An async submission no longer holds it by then — disabling the pressed Send blurs it, dismissing unmounts the panel — so focus sitting on <body> still counts as ours when the submission started with it: a drop we caused is not the reader walking away. The handoff is skipped once focus has reached some other real element, and when the close came from an outside click, so a submission settling minutes later can never yank focus back from whatever the reader is doing now.

On This Page