Feedback

Feedback Widget

A corner feedback launcher and collect-and-send panel with a real idle → submitting → success → error state machine.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { createPortal } from "react-dom"
import { Annoyed, Check, Frown, Laugh, LoaderCircle, type LucideIcon, MessageSquare, Smile, X } from "lucide-react"

import { cn } from "@/lib/utils"

/**
 * Enter + success animations ship with the component: React 19 hoists
 * `<style href>` into <head> and dedupes by href, so several widgets on one page
 * still emit a single rule set. One keyframe per side — the panel always slides
 * *away* from the trigger it grew out of.
 */

Installation

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

Prompt

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 machineidle → submitting → success → auto close is the happy path, idle → submitting → error the 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 Promise and 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 mountedRef set to true in the effect body (not merely cleared in cleanup, which StrictMode's double mount would leave permanently false) gates every post-await write 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 into body removes 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-pressed buttons 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.

On This Page