Mischief

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.

Installation

Copy the source into your project, or keep it behind a package.

npx shadcn@latest add Tinkerers-Labs/mischief-ui/feedback-widget
import { 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.

registry/default/feedback-widget/feedback-widget.tsx
"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 | null

Usage

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.