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-dialogNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { FeedbackDialog, type FeedbackValue } from "@/components/ui/feedback-dialog"
export default function FeedbackDialogDemo() {
const [last, setLast] = React.useState<FeedbackValue | null>(null)
const [refuse, setRefuse] = React.useState(false)
return (
<div className="flex w-full max-w-sm flex-col gap-4">
<FeedbackDialog
trigger={<Button variant="outline">Tell us how it went</Button>}
categories={["Bug", "Idea", "Pricing", "Something else"]}
onSubmit={async (value) => {
await new Promise((resolve) => setTimeout(resolve, 600))
if (refuse)
return { ok: false as const, error: { message: "No connection — try again." } }
setLast(value)
return { ok: true as const }
}}
/>
<Button
variant="outline"
size="sm"
aria-pressed={refuse}
onClick={() => setRefuse((current) => !current)}
className="w-fit"
>
{refuse ? "The send will fail" : "The send will succeed"}
</Button>
<p className="text-xs text-muted-foreground">
{last
? `Last report: ${last.rating}/5${last.category ? ` · ${last.category}` : ""}`
: "Nothing sent yet."}
</p>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| 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. |
| categories | string[] | — | Offered in a select above the comment, with "Not sure" first. Without them there is no select. |
| trigger | React.ReactNode | — | The element that opens the dialog, rendered as the trigger itself. Defaults to a Feedback button. |
| floating | boolean | false | Parks the trigger in the bottom-right corner of the viewport. |
| className | string | — | Merged onto the dialog content. |
Dependencies
Source
"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 }