Feedback Widget
A corner feedback launcher and collect-and-send panel with a real idle → submitting → success → error state machine.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/feedback-widget.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "FeedbackWidget" component (deps:
lucide-react for icons, react-dom's createPortal, a cn() class merger). It is a
corner "Feedback" launcher plus a small collect-and-send panel — the Linear /
Vercel shape. The component NEVER performs a network call.
Contract
- forwardRef<HTMLDivElement>, extends Omit<React.HTMLAttributes<HTMLDivElement>,
"onSubmit">. The ref and the remaining props land on the root wrapper.
- onSubmit: (payload: FeedbackPayload) => Promise<void> (required)
FeedbackPayload = { sentiment: "love"|"good"|"confused"|"bad" | null,
category: string | null,
message: string, // trimmed, never empty
email: string | null }
Resolve => success. Reject => error; an Error's message is shown verbatim,
anything else falls back to a generic sentence.
- position = "bottom-right" | "bottom-left" | "inline" (default "bottom-right")
- title = "Send feedback", description?, triggerLabel = "Feedback",
messageLabel, placeholder, sentimentLabel, emailLabel, submitLabel,
pendingLabel = "Sending…", retryLabel = "Try again", successMessage
- categories?: string[] — trimmed, blanks dropped, DEDUPLICATED (two identical
chips would otherwise share a React key). Omit to hide the row.
- showSentiment = true, showEmail = false
- defaultMessage = "" — seeds the textarea, truncated to maxLength
- maxLength = 500 — anything below 1 or non-finite falls back to 500
- successDuration = 1800 ms — 0 disables auto-close, negative/NaN falls back
- closeOnOutside = true, open?, defaultOpen = false, onOpenChange?
- className merges onto the root; panelClassName merges onto the panel.
Behavior — the state machine is the point
- status: "idle" -> "submitting" -> "success" -> (auto close after
successDuration) on the happy path; "idle" -> "submitting" -> "error" on a
rejection. An error NEVER auto-dismisses: the panel stays open, every field
keeps its value, the reason renders in a role="alert" and the button relabels
to retryLabel. A second press re-enters "submitting" from there.
- Snapshot the payload BEFORE awaiting, and re-check a mountedRef AFTER the
await in both the resolve and the reject path — a late settle must not
setState on an unmounted instance or arm a timer nobody will clear. Set that
ref to true in the effect BODY, not only false in cleanup: StrictMode's
mount -> cleanup -> mount would otherwise leave it false forever and no submit
could ever finish.
- The submit button is never `disabled` and is not aria-disabled while the
message is blank: a disabled control leaves the tab order mid focus-trap and
never explains itself. Pressing it empty sets an inline role="alert" ("Write a
message before sending.") and moves the caret into the textarea. Only the
in-flight moment is truly unavailable — that gets aria-busy + aria-disabled
plus a spinning icon, and the click handler returns early.
- The blank-message nudge clears as soon as the user types; a real submit
failure survives editing so the retry keeps its explanation.
- Closing without sending KEEPS the draft — dismissing by accident must not cost
someone their paragraph. A finished success round resets every field. Both are
render-phase adjust-state driven by a prevOpen compare, not an effect.
- Esc, outside pointerdown and the close button are all swallowed while
submitting.
- There is deliberately NO <form> element: the inline variant renders inside the
consumer's own markup, and a widget nested in their form would be invalid
HTML. Implicit submission is supplied by hand instead — Enter in the email
field and Cmd/Ctrl + Enter in the textarea (plain Enter there inserts a
newline, as it must).
Behavior — placement
- Floating positions render the panel through createPortal into document.body
with position: fixed. That is not decoration: a corner widget dropped inside a
rounded card, a docs preview stage or any overflow:hidden shell would be drawn
and unclickable otherwise. Guard the portal behind an "am I on the client"
useSyncExternalStore so SSR/defaultOpen does not touch document.
- One synchronous pass per measurement: read the panel's NATURAL size with our
own max-width/max-height momentarily set to "none" (and the scroll offset
saved and restored), then flip, then cap, then align, then clamp. Measuring
the already-capped size makes every panel "fit" and the side oscillates.
- Boundaries differ per mode and this is deliberate:
* floating = viewport intersected with overflow:auto|scroll ancestors only.
overflow:hidden ancestors are ignored — the portal already escaped them, and
honouring them would crush a 500px form into a decorative 150px card.
* inline = the nearest ancestor with overflow != visible, intersected with the
viewport. Here the panel IS a descendant, so that box really does clip it.
- Corner mode prefers opening upward, inline prefers downward; each flips only
when the opposite side is genuinely roomier, so a panel too tall for both
stays put and scrolls its own body instead of ping-ponging.
- Measure inside a ResizeObserver callback (observe() fires once immediately,
after layout and before paint — that first call IS the initial measurement, so
nothing setStates synchronously in an effect body). Add capture-phase scroll
(passive, rAF-throttled, ignoring scrolls originating inside the panel) and
resize. Disconnect everything on close. Guard setState with a placement
equality check or the observer loops on the max-height you just wrote.
- Before the first measurement the panel is opacity-0, never
visibility:hidden — hidden elements cannot take focus().
Behavior — focus and keyboard
- On open: remember document.activeElement, then focus the first focusable
element inside the panel. Put the close button LAST in DOM order (absolutely
positioned in the corner) so "first focusable" means the form, not dismiss.
- Tab and Shift+Tab wrap inside the panel. A portalled panel sits at the end of
<body>, so without the trap Tab walks into unrelated markup.
- Esc closes and returns focus to the launcher; check el.isConnected first,
because the action may already have unmounted it and focusing a detached node
drops focus on <body> instead of throwing. Outside clicks close WITHOUT
stealing focus back — the user is already somewhere else.
- Sentiment is a radiogroup with roving tabindex: one tab stop, Arrow keys move
and select, Home/End jump, and arrowing from "nothing selected" picks the end
you moved toward.
Rendering & styling
- Semantic tokens only, no hardcoded colours: bg-popover / text-popover-
foreground / bg-card / bg-primary / text-primary-foreground / border-input /
text-muted-foreground / bg-destructive/10 + text-destructive / ring-ring.
The default palette is monochrome, so selection is expressed by fill + border,
never by hue, and the picked sentiment is echoed as text (aria-hidden, the
checked radio already announces it).
- Panel: w-80, max-w-[calc(100vw-2rem)], rounded-xl border shadow-xl, a
min-h-0 flex-1 overflow-y-auto body under the height cap, and a pinned footer
so the submit button never scrolls out of reach.
- ARIA: panel is role="dialog" + aria-labelledby pointing at the heading;
sentiment is role="radiogroup" with role="radio" children as DIRECT children
(an unroled div in between makes screen readers announce an empty group);
categories are aria-pressed toggles, not radios, because clicking the active
chip clears it and no radiogroup allows that; the character counter is wired
through aria-describedby and turns high-contrast at the cap; a permanently
mounted sr-only role="status" in the ROOT (not in the panel) announces pending
and success, because a live region inserted together with its text is
unreliable.
- Animation: one React 19 hoisted <style href precedence> tag carrying three
keyframes (slide-up, slide-down, success pop); the panel picks the one that
moves away from its trigger. All of them are dropped under
motion-reduce:[animation:none] and the spinner under
motion-reduce:animate-none — nothing about sending stops working.
- "use client" is required (state, effects, portal, DOM measurement).
Customization levers
- Fields: showSentiment / showEmail / categories are independent switches. All
three off leaves a textarea and a button, and the panel shrinks to match — the
measurement pass re-runs on the size change, so nothing has to be re-tuned.
- Sentiment scale: SENTIMENTS is a 4-entry array of { value, label, Icon }. Swap
Laugh/Smile/Annoyed/Frown for emoji glyphs, or extend to 5 for an NPS-ish
ladder; the radiogroup, roving tabindex and payload follow automatically.
- Tone of voice: title / description / triggerLabel / messageLabel /
placeholder / submitLabel / pendingLabel / retryLabel / successMessage are the
full i18n surface — no strings are baked into the markup except the close
button's aria-label and the blank-message nudge.
- Pacing: successDuration 0 keeps the success panel until dismissed (good when
it carries a ticket number), 1200-2500 for a quick thank-you.
- Placement: TRIGGER_GAP (12px), EDGE_MARGIN (8px) and MIN_PANEL_HEIGHT (180px)
are the three placement constants. Raise MIN_PANEL_HEIGHT if you would rather
overflow a very short boundary than scroll a squeezed panel.
- Density: swap the panel's gap-4 for gap-3 and the textarea's min-h-22 for
min-h-16 to fit a shorter viewport; change w-80 for a wider composer.
- Prominence: the launcher is a bordered bg-card pill with shadow-lg. Make it
bg-primary text-primary-foreground for a classic FAB, or drop the label and
keep the icon in a size-11 rounded-full button.
- Persistence: pair defaultMessage with localStorage in the consumer to restore
a draft across reloads; the widget already keeps it across open/close.Concepts
- Submit state machine —
idle → submitting → success → auto closeis the happy path,idle → submitting → errorthe unhappy one, and the two are not symmetric on purpose: success is transient because there is nothing left to do, an error is sticky because the user's text is still unsaved and the only sensible next move is another attempt. - Consumer-owned transport — the widget takes a
Promiseand reports on it; it never picks an endpoint, a method or a serialiser. That is what lets the same component sit in front of a REST route, a server action, an analytics SDK or a Slack webhook without a fork. - Liveness after await — a promise settling on a component that has since unmounted is the classic async-form leak. A
mountedRefset totruein the effect body (not merely cleared in cleanup, which StrictMode's double mount would leave permanently false) gates every post-awaitwrite and every timer. - Portal over clip — a corner launcher is routinely dropped inside a rounded card or a docs stage with
overflow: hidden. Rendering the panel intobodyremoves it from that subtree entirely, and for the same reason those hidden ancestors are excluded from the boundary calculation: only genuinely scrollable ancestors are a real viewport worth clamping to. - Natural-size measurement — the flip decision reads the panel with its own caps lifted for one synchronous pass. A panel measured while already capped always looks like it fits, which is how hand-rolled overlays end up oscillating between sides on every resize.
- Radiogroup vs pressed toggles — the four faces are a radiogroup (exactly one, arrow keys move and select, one tab stop for the whole row); the category chips are
aria-pressedbuttons because clicking the active one clears it, and a radio has no "uncheck me" gesture. Same-looking row, different ARIA, on purpose. - Answering instead of refusing — the submit button is never disabled. Pressed with an empty message it names what is missing and puts the caret where it belongs, which is both more useful than a greyed-out control and safer inside a focus trap, where a control disappearing from the tab order dumps focus on
body. - Draft survival — closing without sending keeps every field; only a completed success round clears them. Dismissing an overlay by accident is common enough that losing the text to it would be the component's worst bug.
Hover Card
A hover-and-focus card for arbitrary rich content — avatar, stats, links, buttons — with an open delay, a close grace period the pointer can cross into, clip-aware flipping and tap-to-toggle on touch.
Loading Overlay
A scrim that freezes one region or the whole viewport while work is in flight — delay-gated so short requests never flash, min-duration held so it never blinks out, and inert underneath so Tab cannot walk into the frozen form.