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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/response-feedback.jsonPrompt
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;
onVoteChangefires there so an optimistic UI can react, but nothing reachesonSubmituntil 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
useStateflag is stillfalsein 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.
Regenerate Menu
A split retry control for one assistant answer — the button half re-runs it, the chevron opens a menu with a model submenu that marks the current model and an inline instruction field, and every path emits one discriminated action.
Share Conversation
A publish-a-link share dialog for a chat thread — server-issued URL, optimistic visibility and attachment writes with rollback, copy feedback and a destructive revoke confirm.