# Feedback Widget

> A bottom-right feedback launcher with sentiment choice, message, optional email and a thank-you state.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/feedback-widget
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/feedback-widget.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/feedback-widget-demo.tsx"
"use client"

import { FeedbackWidget } from "@/components/feedback-widget"

export default function FeedbackWidgetDemo() {
  return (
    <div className="relative h-[520px] w-full overflow-hidden rounded-2xl border bg-card">
      <div className="grid content-start gap-5 p-5 sm:p-8">
        <div className="flex items-center gap-2 text-sm font-semibold">
          <span className="size-5 rounded-md bg-primary" aria-hidden /> Lumen Analytics
        </div>
        <div className="grid gap-2">
          <h3 className="text-2xl font-semibold tracking-tight sm:text-3xl">Weekly overview</h3>
          <p className="max-w-md text-sm text-muted-foreground">
            Sessions are up 12% on last week. Use the Feedback button in the corner to tell us how the new dashboard feels.
          </p>
        </div>
        <div className="grid grid-cols-3 gap-3">
          {[
            ["Sessions", "48.2k"],
            ["Signups", "1,204"],
            ["Churn", "1.8%"],
          ].map(([label, value]) => (
            <div key={label} className="min-w-0 rounded-xl border bg-background p-3">
              <p className="truncate text-xs text-muted-foreground">{label}</p>
              <p className="text-lg font-semibold tabular-nums">{value}</p>
            </div>
          ))}
        </div>
      </div>
      <FeedbackWidget />
    </div>
  )
}
```

## Source

```tsx title="components/feedback-widget.tsx"
"use client"

import * as React from "react"
import { Angry, CircleCheck, Frown, Laugh, Loader2, Meh, MessageSquare, Send, Smile, X } from "lucide-react"

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

export interface FeedbackSubmission {
  sentiment: string
  message: string
  email: string
}

export interface FeedbackWidgetProps extends Omit<React.ComponentProps<"div">, "children" | "onSubmit"> {
  /** May return a promise. Defaults to a short simulated delay. */
  onSubmit?: (feedback: FeedbackSubmission) => void | Promise<void>
  /** "absolute" anchors to the nearest positioned ancestor (good for previews), "fixed" to the viewport. */
  positioning?: "absolute" | "fixed"
  launcherLabel?: string
}

const sentiments = [
  { id: "awful", label: "Awful", Icon: Angry },
  { id: "bad", label: "Bad", Icon: Frown },
  { id: "okay", label: "Okay", Icon: Meh },
  { id: "good", label: "Good", Icon: Smile },
  { id: "great", label: "Great", Icon: Laugh },
] as const

const focusRing = "focus-visible:ring-4 focus-visible:ring-ring/40 focus-visible:outline-none"
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/

export function FeedbackWidget({
  onSubmit,
  positioning = "absolute",
  launcherLabel = "Feedback",
  className,
  ...props
}: FeedbackWidgetProps) {
  const [open, setOpen] = React.useState(false)
  const [sentiment, setSentiment] = React.useState("")
  const [message, setMessage] = React.useState("")
  const [email, setEmail] = React.useState("")
  const [errors, setErrors] = React.useState<{ sentiment?: string; message?: string; email?: string }>({})
  const [status, setStatus] = React.useState<"idle" | "sending" | "sent">("idle")
  const root = React.useRef<HTMLDivElement>(null)
  const launcher = React.useRef<HTMLButtonElement>(null)
  const firstField = React.useRef<HTMLButtonElement>(null)
  const thanks = React.useRef<HTMLHeadingElement>(null)
  const id = React.useId()

  const close = React.useCallback((restoreFocus = true) => {
    setOpen(false)
    if (restoreFocus) launcher.current?.focus()
  }, [])

  React.useEffect(() => {
    if (open && status !== "sent") firstField.current?.focus()
  }, [open, status])

  React.useEffect(() => {
    if (status === "sent") thanks.current?.focus()
  }, [status])

  React.useEffect(() => {
    if (!open) return
    const onPointerDown = (event: PointerEvent) => {
      if (!root.current?.contains(event.target as Node)) setOpen(false)
    }
    const onKeyDown = (event: KeyboardEvent) => {
      if (event.key === "Escape") close()
    }
    document.addEventListener("pointerdown", onPointerDown)
    document.addEventListener("keydown", onKeyDown)
    return () => {
      document.removeEventListener("pointerdown", onPointerDown)
      document.removeEventListener("keydown", onKeyDown)
    }
  }, [open, close])

  async function submit(event: React.FormEvent) {
    event.preventDefault()
    if (status === "sending") return
    const next: typeof errors = {}
    if (!sentiment) next.sentiment = "Pick how it felt."
    if (!message.trim()) next.message = "Tell us a little more."
    if (email.trim() && !emailPattern.test(email.trim())) next.email = "Enter a valid email or leave it empty."
    setErrors(next)
    if (Object.keys(next).length) return
    setStatus("sending")
    try {
      if (onSubmit) await onSubmit({ sentiment, message: message.trim(), email: email.trim() })
      else await new Promise((resolve) => setTimeout(resolve, 1200))
      setStatus("sent")
    } catch {
      setStatus("idle")
      setErrors({ message: "Something went wrong. Please try again." })
    }
  }

  function reset() {
    setSentiment("")
    setMessage("")
    setEmail("")
    setErrors({})
    setStatus("idle")
  }

  function onSentimentKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) {
    const index = sentiments.findIndex((s) => s.id === sentiment)
    let next: number | null = null
    if (event.key === "ArrowRight" || event.key === "ArrowDown") next = (index + 1) % sentiments.length
    else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = (index - 1 + sentiments.length) % sentiments.length
    if (next === null) return
    event.preventDefault()
    setSentiment(sentiments[next].id)
    setErrors((prev) => ({ ...prev, sentiment: undefined }))
    event.currentTarget.parentElement?.querySelectorAll<HTMLElement>('[role="radio"]')[next]?.focus()
  }

  const fieldClass =
    "w-full min-w-0 rounded-lg border bg-background px-3 text-sm outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-4 focus-visible:ring-ring/20"

  return (
    <div
      ref={root}
      className={cn(
        "bottom-4 right-4 z-30 flex max-w-[calc(100%-2rem)] flex-col items-end gap-3",
        positioning === "fixed" ? "fixed" : "absolute",
        className
      )}
      {...props}
    >
      {open && (
        <div
          id={id}
          role="dialog"
          aria-label="Send feedback"
          className="w-80 max-w-full origin-bottom-right animate-in rounded-2xl border bg-popover p-4 text-popover-foreground shadow-xl duration-150 fade-in zoom-in-95 slide-in-from-bottom-2 motion-reduce:animate-none"
        >
          {status === "sent" ? (
            <div className="flex flex-col items-center gap-3 py-4 text-center">
              <span className="flex size-12 items-center justify-center rounded-full bg-emerald-500/15 text-emerald-600">
                <CircleCheck className="size-6" aria-hidden />
              </span>
              <div role="status">
                <h3 ref={thanks} tabIndex={-1} className="text-base font-semibold outline-none">
                  Thank you!
                </h3>
                <p className="mt-1 text-sm text-muted-foreground">Your feedback was sent. We read every message.</p>
              </div>
              <div className="flex gap-2">
                <button
                  type="button"
                  onClick={reset}
                  className={cn("inline-flex h-8 items-center rounded-lg border bg-background px-3 text-sm font-medium transition-colors hover:bg-accent", focusRing)}
                >
                  Send another
                </button>
                <button
                  type="button"
                  onClick={() => {
                    close()
                    reset()
                  }}
                  className={cn("inline-flex h-8 items-center rounded-lg bg-primary px-3 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90", focusRing)}
                >
                  Close
                </button>
              </div>
            </div>
          ) : (
            <form onSubmit={submit} noValidate className="grid gap-3">
              <div className="flex items-start justify-between gap-2">
                <div>
                  <h3 className="text-sm font-semibold">Send feedback</h3>
                  <p className="text-xs text-muted-foreground">How is your experience so far?</p>
                </div>
                <button
                  type="button"
                  aria-label="Close feedback"
                  onClick={() => close()}
                  className={cn("-mt-1 -mr-1 flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground", focusRing)}
                >
                  <X className="size-4" aria-hidden />
                </button>
              </div>

              <div className="grid gap-1.5">
                <div role="radiogroup" aria-label="How did it feel?" className="grid grid-cols-5 gap-1">
                  {sentiments.map(({ id: value, label, Icon }, index) => {
                    const active = sentiment === value
                    return (
                      <button
                        key={value}
                        ref={index === 0 ? firstField : undefined}
                        type="button"
                        role="radio"
                        aria-checked={active}
                        aria-label={label}
                        title={label}
                        tabIndex={active || (!sentiment && index === 0) ? 0 : -1}
                        onClick={() => {
                          setSentiment(value)
                          setErrors((prev) => ({ ...prev, sentiment: undefined }))
                        }}
                        onKeyDown={onSentimentKeyDown}
                        className={cn(
                          "flex h-10 items-center justify-center rounded-lg border text-muted-foreground transition-colors hover:bg-accent hover:text-foreground",
                          focusRing,
                          active && "border-primary bg-primary/10 text-primary"
                        )}
                      >
                        <Icon className="size-5" aria-hidden />
                      </button>
                    )
                  })}
                </div>
                {errors.sentiment && (
                  <p role="alert" className="text-xs text-destructive">
                    {errors.sentiment}
                  </p>
                )}
              </div>

              <div className="grid gap-1.5">
                <label htmlFor={`${id}-message`} className="sr-only">
                  Your feedback
                </label>
                <textarea
                  id={`${id}-message`}
                  value={message}
                  onChange={(event) => {
                    setMessage(event.target.value)
                    if (errors.message) setErrors((prev) => ({ ...prev, message: undefined }))
                  }}
                  rows={3}
                  placeholder="What could we do better?"
                  aria-invalid={Boolean(errors.message)}
                  aria-describedby={errors.message ? `${id}-message-error` : undefined}
                  className={cn(fieldClass, "resize-none py-2", errors.message && "border-destructive")}
                />
                {errors.message && (
                  <p id={`${id}-message-error`} role="alert" className="text-xs text-destructive">
                    {errors.message}
                  </p>
                )}
              </div>

              <div className="grid gap-1.5">
                <label htmlFor={`${id}-email`} className="sr-only">
                  Email (optional)
                </label>
                <input
                  id={`${id}-email`}
                  type="email"
                  value={email}
                  onChange={(event) => {
                    setEmail(event.target.value)
                    if (errors.email) setErrors((prev) => ({ ...prev, email: undefined }))
                  }}
                  placeholder="Email (optional)"
                  autoComplete="email"
                  aria-invalid={Boolean(errors.email)}
                  aria-describedby={errors.email ? `${id}-email-error` : undefined}
                  className={cn(fieldClass, "h-9", errors.email && "border-destructive")}
                />
                {errors.email && (
                  <p id={`${id}-email-error`} role="alert" className="text-xs text-destructive">
                    {errors.email}
                  </p>
                )}
              </div>

              <button
                type="submit"
                disabled={status === "sending"}
                aria-busy={status === "sending"}
                className={cn("inline-flex h-9 items-center justify-center gap-2 rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 disabled:pointer-events-none disabled:opacity-70", focusRing)}
              >
                {status === "sending" ? (
                  <Loader2 className="size-4 animate-spin motion-reduce:animate-none" aria-hidden />
                ) : (
                  <Send className="size-4" aria-hidden />
                )}
                {status === "sending" ? "Sending…" : "Send feedback"}
              </button>
            </form>
          )}
        </div>
      )}

      <button
        ref={launcher}
        type="button"
        aria-haspopup="dialog"
        aria-expanded={open}
        aria-controls={open ? id : undefined}
        onClick={() => (open ? close(false) : setOpen(true))}
        className={cn(
          "inline-flex h-11 items-center gap-2 rounded-full bg-primary px-4 text-sm font-medium text-primary-foreground shadow-lg transition-[box-shadow,scale] hover:shadow-xl active:scale-95 motion-reduce:transition-none",
          focusRing
        )}
      >
        <MessageSquare className="size-4" aria-hidden />
        {launcherLabel}
      </button>
    </div>
  )
}
```
