Skip to contentVibraUI
Feedback & status

Feedback dialog

Rating, category and comment, sent through an action that answers.

The send is awaited and its answer is shown where it happened: a refusal stays in the dialog beside the form that caused it, as a real alert, and a success replaces the form with a thank-you rather than closing on the reader before they have read it. Everything resets on the way out — score, category, comment, and any error — so the next report never opens on the last one's half-written sentence. The score is the one required field and it gates the Send button rather than failing on the far side of a round trip, because a report nobody can rank is a comment. Without categories there is no select at all. floating parks the default trigger in the bottom-right corner of the viewport; trigger replaces it with your own element, rendered as the trigger itself rather than wrapped in one. onSubmit's result type is the sample-data Result narrowed to what this dialog reads, so a server action returning Result<T> is assignable as it stands.

Install

npx shadcn@latest add @vibra/feedback-dialog

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
onSubmit(value: { rating: number; category?: string; comment: string }) => Promise<FeedbackDialogResult>—Awaited; { ok: true } shows the thank-you, { ok: false, error: { message } } keeps the dialog open and reports why.
categoriesstring[]—Offered in a select above the comment, with "Not sure" first. Without them there is no select.
triggerReact.ReactNode—The element that opens the dialog, rendered as the trigger itself. Defaults to a Feedback button.
floatingbooleanfalseParks the trigger in the bottom-right corner of the viewport.
classNamestring—Merged onto the dialog content.

Dependencies

Source

components/ui/feedback-dialog.tsx
"use client"

import * as React from "react"
import { MessageSquareIcon } from "lucide-react"

import { cn } from "@/lib/utils"
import { AsyncButton } from "@/components/ui/async-button"
import { Button } from "@/components/ui/button"
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog"
import { Field, FieldGroup, FieldLabel, FieldTitle } from "@/components/ui/field"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
import { Rating } from "@/components/ui/rating"
import { Textarea } from "@/components/ui/textarea"

/** What the dialog collects. `category` is absent when none was chosen. */
export type FeedbackValue = {
  rating: number
  category?: string
  comment: string
}

/**
 * What `onSubmit` answers with — the sample-data `Result` narrowed to the two
 * things this dialog reads, so a server action returning `Result<T>` is
 * assignable as it stands and nothing here drags the store in behind it.
 */
export type FeedbackDialogResult =
  | { ok: true }
  | { ok: false; error: { message: string } }

export type FeedbackDialogProps = {
  className?: string
  /** The element that opens the dialog, rendered as the trigger itself. */
  trigger?: React.ReactNode
  /** Offered in a select above the comment; without them there is no select. */
  categories?: string[]
  onSubmit: (value: FeedbackValue) => Promise<FeedbackDialogResult>
  /** Parks the default trigger in the bottom-right corner of the viewport. */
  floating?: boolean
}

/**
 * Rating, category and comment, sent through an action that answers.
 *
 * The send is awaited and its answer is shown where it happened: a refusal
 * stays in the dialog beside the form that caused it, and a success replaces
 * the form with a thank-you rather than closing on the reader before they have
 * read it. Everything resets on the way out, so the next report never opens on
 * the last one's half-written sentence.
 */
function FeedbackDialog({
  className,
  trigger,
  categories,
  onSubmit,
  floating = false,
}: FeedbackDialogProps) {
  const commentId = React.useId()
  const categoryId = React.useId()

  const [open, setOpen] = React.useState(false)
  const [rating, setRating] = React.useState(0)
  const [category, setCategory] = React.useState("")
  const [comment, setComment] = React.useState("")
  const [sent, setSent] = React.useState(false)
  const [error, setError] = React.useState<string | null>(null)

  function handleOpenChange(next: boolean) {
    setOpen(next)
    if (next) return
    // Closed is closed: the next report opens on an empty form, not on the last
    // one's leftovers or its error.
    setRating(0)
    setCategory("")
    setComment("")
    setSent(false)
    setError(null)
  }

  async function handleSubmit() {
    if (rating < 1) return
    setError(null)
    try {
      const result = await onSubmit({ rating, category: category || undefined, comment })
      if (result.ok) setSent(true)
      else setError(result.error.message)
    } catch (caught) {
      // A lost connection rejects rather than answering { ok: false } — the
      // same alert either way, so the reader is never left looking at a form
      // that silently gave up.
      setError(caught instanceof Error ? caught.message : "Something went wrong. Try again.")
    }
  }

  // `trigger` is `React.ReactNode`, so a string or a fragment can arrive here —
  // Base UI's `render` clones a single element and throws on anything else. An
  // element is used as the render target as it is; anything else (including no
  // trigger at all) is wrapped in the default trigger button.
  const triggerElement: React.ReactElement =
    trigger == null ? (
      <Button variant="outline">
        <MessageSquareIcon />
        Feedback
      </Button>
    ) : React.isValidElement(trigger) ? (
      trigger
    ) : (
      <Button variant="outline">{trigger}</Button>
    )

  return (
    <Dialog open={open} onOpenChange={handleOpenChange}>
      <DialogTrigger
        data-slot="feedback-dialog-trigger"
        data-floating={floating ? "true" : undefined}
        className={cn(floating && "fixed end-4 bottom-4 z-40 shadow-(--shadow-float)")}
        render={triggerElement}
      />

      <DialogContent data-slot="feedback-dialog" className={cn("sm:max-w-md", className)}>
        <DialogHeader>
          <DialogTitle>Send feedback</DialogTitle>
          <DialogDescription>
            {sent ? "Nothing else is needed." : "It reaches the team that owns this page."}
          </DialogDescription>
        </DialogHeader>

        {sent ? (
          <div
            role="status"
            data-slot="feedback-dialog-sent"
            className="rounded-md bg-success-muted px-3 py-6 text-center text-sm text-success"
          >
            Thank you — your feedback is in.
          </div>
        ) : (
          <FieldGroup>
            <Field>
              <FieldTitle>How is this working for you?</FieldTitle>
              <Rating value={rating} onValueChange={setRating} label="Overall" />
            </Field>

            {categories && categories.length > 0 ? (
              <Field>
                <FieldLabel htmlFor={categoryId}>Category</FieldLabel>
                <NativeSelect
                  id={categoryId}
                  className="w-full"
                  value={category}
                  onChange={(event) => setCategory(event.target.value)}
                >
                  <NativeSelectOption value="">Not sure</NativeSelectOption>
                  {categories.map((name) => (
                    <NativeSelectOption key={name} value={name}>
                      {name}
                    </NativeSelectOption>
                  ))}
                </NativeSelect>
              </Field>
            ) : null}

            <Field>
              <FieldLabel htmlFor={commentId}>What happened?</FieldLabel>
              <Textarea
                id={commentId}
                value={comment}
                onChange={(event) => setComment(event.target.value)}
                placeholder="What you expected, and what you got instead."
                rows={4}
              />
            </Field>

            {error ? (
              <div
                role="alert"
                data-slot="feedback-dialog-error"
                className="text-sm text-danger"
              >
                {error}
              </div>
            ) : null}
          </FieldGroup>
        )}

        <DialogFooter>
          {sent ? (
            <DialogClose render={<Button variant="outline" />}>Close</DialogClose>
          ) : (
            <>
              <DialogClose render={<Button variant="outline" />}>Cancel</DialogClose>
              <AsyncButton
                data-slot="feedback-dialog-send"
                // A report with no score is a comment nobody can rank, so the
                // one required field gates the button rather than failing on
                // the far side of a round trip.
                disabled={rating < 1}
                onClick={handleSubmit}
              >
                Send feedback
              </AsyncButton>
            </>
          )}
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}

export { FeedbackDialog }