127 / Controls
Feedback Widget
A button in the corner that opens into a small form: how it is going, a message, and Send. A failed send keeps the text.
Billing settings
The feedback button sits in the corner of the page. Open it, pick a face, and write a line.
"use client"
import * as React from "react"
import {
FeedbackWidget,
type FeedbackWidgetValue,
} from "mischief-ui/feedback-widget"
export function FeedbackWidgetDemo() {
const [fail, setFail] = React.useState(false)
const [last, setLast] = React.useState<FeedbackWidgetValue | null>(null)
return (
<div className="bg-background border-border relative flex min-h-[30rem] w-full max-w-[34rem] flex-col gap-4 overflow-hidden rounded-[1.25rem] border p-6 shadow-sm">
<div>
<h3 className="text-sm font-semibold">Billing settings</h3>
<p className="text-muted-foreground mt-1 text-sm">
The feedback button sits in the corner of the page. Open it, pick a
face, and write a line.
</p>
</div>
<label className="text-muted-foreground flex items-center gap-2.5 text-xs">
<input
checked={fail}
className="accent-destructive size-3.5"
onChange={(event) => setFail(event.target.checked)}
type="checkbox"
/>
Make the next send fail
</label>
{last ? (
<p className="text-muted-foreground text-xs">
Last sent: {last.sentiment ?? "no sentiment"}
{last.message ? `, "${last.message}"` : ""}
</p>
) : null}
<FeedbackWidget
className="absolute"
onSubmit={async (value) => {
await new Promise((resolve) => setTimeout(resolve, 900))
if (fail) throw new Error("The feedback service did not answer.")
setLast(value)
}}
/>
</div>
)
}Installation
Copy the source into your project, or keep it behind a package.
npx shadcn@latest add Tinkerers-Labs/mischief-ui/feedback-widgetimport { FeedbackWidget } from "mischief-ui/feedback-widget"Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.
"use client" import * as React from "react" import { cn } from "@/lib/utils" export interface FeedbackSentiment { value: string label: string icon?: React.ReactNode} export interface FeedbackWidgetValue { sentiment: string | nullUsage
export function AppShell({ children }: { children: React.ReactNode }) {
return (
<>
{children}
<FeedbackWidget
onSubmit={({ sentiment, message }) =>
fetch("/api/feedback", {
method: "POST",
body: JSON.stringify({ sentiment, message, page: location.pathname }),
}).then((response) => {
if (!response.ok) throw new Error(response.statusText)
})
}
/>
</>
)
}Sending and failing
onSubmit receives the chosen sentiment, or null, and the trimmed message. Return a promise and the button says Sending until it settles. A resolution draws a check, says thanks, clears the form, and closes on its own. A rejection is the failed state: the message stays in the box and Send becomes Try again. onSubmitError hands you the rejection.
Send waits until there is a sentiment or a message. Cmd+Enter and Ctrl+Enter send from the message box.
The sentiment question
Four faces come by default, from Great to Bad, exported as defaultFeedbackSentiments. Pass your own list of { value, label, icon? } to change them, or an empty array to leave the question out.
<FeedbackWidget
sentiments={[
{ value: "yes", label: "Yes" },
{ value: "no", label: "No" },
]}
sentimentLabel="Did this page answer your question?"
onSubmit={send}
/>Placement
It is fixed to the bottom right corner and grows up and to the left from there. Pass a className to move it, or absolute to keep it inside a container. A draft survives closing, so an accidental click outside costs nothing.
API
onSubmit(value: FeedbackWidgetValue) => void | Promise<void>Sends { sentiment, message }. A rejection is the failed state.onSubmitError(error: unknown) => voidReceives the rejection.sentiments (FeedbackSentiment){ value: string; label: string; icon?: ReactNode }[]The sentiment choices. Defaults to defaultFeedbackSentiments. An empty array hides the question.triggerLabel, title, sentimentLabel, messageLabel, placeholderstringThe trigger, the panel heading, and the field labels.sendLabel, sendingLabel, retryLabel, closeLabelstringButton copy.sentMessage, errorMessagestringThe thanks and the failure line.data-state (FeedbackWidgetState)"closed" | "open" | "sending" | "sent" | "error"The step the widget is on, written to the root for styling.classNamestringClasses for placement....divPropsHTMLAttributesNative div attributes.Accessibility
The trigger is a native button that says it opens a dialog. Open, the panel is a non-modal dialog named by its heading, focus moves to the message box, and Tab cycles inside the panel. Escape closes it and returns focus to the trigger, and so does the automatic close after a successful send. A click outside closes it without moving focus. The sentiments are native radio buttons in a fieldset with a legend, so arrow keys move between them and each face has a visible text label. Sending, the thanks and the failure are announced politely, and Send stays focusable while it is unavailable rather than dropping focus. Reduced motion removes the morph, the spinner's turn and the drawn check.