Feedback widget
Feedback from any page. A tab on the edge, a round button with quick reactions, marking the exact spot on the page, and a full bug report with the device filled in. Floating ones sit over the window, or inside a frame with contained.
One of 3 components in Surveys and feedback
Variants
Details
What Feedback widget brings along, read from the registry and the source.
- Category
- Surveys and feedback
- Area
- Content
- Variants
- 4
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/feedback-widget.tsx
- npm packages
- None
- Languages
- English and German, 47 texts of its own
- Studio
- All 4 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The FeedbackWidgetProps interface from components/ui/feedback-widget.tsx, with the comments from the code.
variantoptionalFeedbackWidgetVariantside-tabbubblescreenshotbug-report
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodecategoriesoptionalFeedbackCategory[]Kinds of feedback (side-tab, bubble reactions, bug-report severity)
askEmailoptionalbooleanAsk for an email address to reply to
containedoptionalbooleanSit inside the nearest positioned parent instead of over the window
pageoptionalReact.ReactNodecontained: the page drawn behind the widget, e.g. a screenshot of your site
defaultOpenoptionalbooleanOpen on first render, for previews
onSubmitoptional(data: FeedbackData) => void
Built-in texts
Feedback widget writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 47 texts
| Key | English | German |
|---|---|---|
| feedback | English Feedback | German Feedback |
| openFeedback | English Give feedback | German Feedback geben |
| close | English Close | German Schließen |
| panelTitle | English Tell us what you think | German Sag uns, was du denkst |
| panelText | English It goes straight to the team that builds this page. | German Es geht direkt an das Team, das diese Seite baut. |
| kind | English What is it about? | German Worum geht es? |
| message | English Your message | German Deine Nachricht |
| messageNeeded | English Please write a few words so we know what to look at. | German Bitte schreib ein paar Worte, damit wir wissen, wo wir hinschauen sollen. |
| English Email for a reply | German E-Mail für eine Antwort | |
| send | English Send feedback | German Feedback senden |
| sentTitle | English Thank you, it has arrived | German Danke, es ist angekommen |
| sentText | English We read every message. If you left an address, we reply within two working days. | German Wir lesen jede Nachricht. Mit Adresse antworten wir innerhalb von zwei Werktagen. |
| another | English Send more feedback | German Weiteres Feedback senden |
| reaction | English How is this page? | German Wie ist diese Seite? |
| addComment | English Want to say more? | German Möchtest du mehr sagen? |
| mark | English Mark the spot | German Stelle markieren |
| marking | English Click where something is off. Arrow keys move the crosshair, Enter places it, Escape cancels. | German Klicke dorthin, wo etwas nicht stimmt. Pfeiltasten bewegen das Fadenkreuz, Enter setzt es, Escape bricht ab. |
| spotSet | English Spot marked | German Stelle markiert |
| remark | English Mark again | German Neu markieren |
| spotText | English {x} % across, {y} % down | German {x} % von links, {y} % von oben |
| attach | English A screenshot of this view is attached when you send | German Beim Senden hängt ein Bildschirmfoto dieser Ansicht an |
| whatHere | English What is wrong at this spot? | German Was stimmt an dieser Stelle nicht? |
| spotNeeded | English Please mark the spot first, so we see what you mean. | German Bitte markiere zuerst die Stelle, damit wir sehen, was du meinst. |
| reportTitle | English Short title | German Kurzer Titel |
| reportTitleHint | English Empty page after invoice download | German Leere Seite nach Rechnungsdownload |
| titleNeeded | English Please give the problem a short title, it helps us sort it. | German Bitte gib dem Problem einen kurzen Titel, das hilft uns beim Einordnen. |
| severity | English How much does it affect you? | German Wie sehr betrifft es dich? |
| severityNeeded | English Please choose how much it affects you, so we can sort it right. | German Bitte wähle, wie sehr es dich betrifft, damit wir es richtig einordnen. |
| steps | English Steps to reproduce | German Schritte zum Nachstellen |
| step | English Step {n} | German Schritt {n} |
| addStep | English Add a step | German Schritt hinzufügen |
| removeStep | English Remove step {n} | German Schritt {n} entfernen |
| stepsNeeded | English Please describe at least the first step, so we can see it for ourselves. | German Bitte beschreib mindestens den ersten Schritt, damit wir es selbst sehen können. |
| expected | English What did you expect? | German Was hast du erwartet? |
| actual | English What happened instead? | German Was ist stattdessen passiert? |
| actualNeeded | English Please tell us what happened, it is the part we need most. | German Bitte schreib, was passiert ist, das brauchen wir am meisten. |
| device | English Filled in for you | German Für dich ausgefüllt |
| deviceHint | English Sent with the report so you do not have to look it up. | German Wird mitgeschickt, damit du nichts nachschlagen musst. |
| browser | English Browser | German Browser |
| system | English System | German System |
| screen | English Window | German Fenster |
| pageLabel | English Page | German Seite |
| detecting | English Read on send | German Wird beim Senden gelesen |
| sendReport | English Send report | German Bericht senden |
| reportSent | English Report {id} has arrived | German Bericht {id} ist angekommen |
| reportSentText | English You get an email with every change of status. Most reports are looked at the same day. | German Bei jeder Statusänderung bekommst du eine E-Mail. Die meisten Berichte sehen wir uns am selben Tag an. |
| newReport | English Report another problem | German Weiteres Problem melden |
Accessibility
Read from the source of the component.
- Feedback widget handles the keys Escape, Enter and Space itself.
- Sets aria-controls, aria-describedby, aria-expanded, aria-label, aria-labelledby and aria-modal, so screen readers announce purpose and state.
- Uses the roles alert, application, dialog, radiogroup and status.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 7 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 859 lines
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { cn, round, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Reveal } from '@/components/ui/reveal';
import { Section } from '@/components/ui/section';
import { ChipGroup, DoneMark, ErrorSummary, FlowHead, Unfold, useFocusFirstError, type FlowError } from '@/components/ui/form-flow';
/*
* Feedback from any page. Three floating ones and one inline report:
*
* side-tab a tab on the right edge opens a panel, a sheet on phones
* bubble a round button, one tap on a reaction, then an optional comment
* screenshot mark the spot on the page (pointer or arrow keys), then describe it
* bug-report a full report inline: steps, expected and actual, the device filled in
*
* The floating ones sit `fixed` over the window. With `contained` they sit
* `absolute` in the nearest positioned parent, for previews (the demos draw a
* page in a frame). Escape closes, focus returns to the button that opened.
* Sends { category, message, email, spot, device } to `onSubmit`.
*/
export type FeedbackWidgetVariant = 'side-tab' | 'bubble' | 'screenshot' | 'bug-report';
export interface FeedbackCategory {
value: string;
label: string;
icon?: IconName;
/** The question the text field asks for this category */
prompt?: string;
}
export interface FeedbackData {
category?: string;
message: string;
email?: string;
/** screenshot: the marked spot in percent of the page area */
spot?: { x: number; y: number };
/** bug-report */
title?: string;
steps?: string[];
expected?: string;
actual?: string;
device?: Record<string, string>;
}
export interface FeedbackWidgetProps {
variant?: FeedbackWidgetVariant;
eyebrow?: React.ReactNode;
title?: React.ReactNode;
description?: React.ReactNode;
/** Kinds of feedback (side-tab, bubble reactions, bug-report severity) */
categories?: FeedbackCategory[];
/** Ask for an email address to reply to */
askEmail?: boolean;
/** Sit inside the nearest positioned parent instead of over the window */
contained?: boolean;
/** contained: the page drawn behind the widget, e.g. a screenshot of your site */
page?: React.ReactNode;
/** Open on first render, for previews */
defaultOpen?: boolean;
onSubmit?: (data: FeedbackData) => void;
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
feedback: 'Feedback',
openFeedback: 'Give feedback',
close: 'Close',
panelTitle: 'Tell us what you think',
panelText: 'It goes straight to the team that builds this page.',
kind: 'What is it about?',
message: 'Your message',
messageNeeded: 'Please write a few words so we know what to look at.',
email: 'Email for a reply',
send: 'Send feedback',
sentTitle: 'Thank you, it has arrived',
sentText: 'We read every message. If you left an address, we reply within two working days.',
another: 'Send more feedback',
reaction: 'How is this page?',
addComment: 'Want to say more?',
mark: 'Mark the spot',
marking: 'Click where something is off. Arrow keys move the crosshair, Enter places it, Escape cancels.',
spotSet: 'Spot marked',
remark: 'Mark again',
spotText: '{x} % across, {y} % down',
attach: 'A screenshot of this view is attached when you send',
whatHere: 'What is wrong at this spot?',
spotNeeded: 'Please mark the spot first, so we see what you mean.',
reportTitle: 'Short title',
reportTitleHint: 'Empty page after invoice download',
titleNeeded: 'Please give the problem a short title, it helps us sort it.',
severity: 'How much does it affect you?',
severityNeeded: 'Please choose how much it affects you, so we can sort it right.',
steps: 'Steps to reproduce',
step: 'Step {n}',
addStep: 'Add a step',
removeStep: 'Remove step {n}',
stepsNeeded: 'Please describe at least the first step, so we can see it for ourselves.',
expected: 'What did you expect?',
actual: 'What happened instead?',
actualNeeded: 'Please tell us what happened, it is the part we need most.',
device: 'Filled in for you',
deviceHint: 'Sent with the report so you do not have to look it up.',
browser: 'Browser',
system: 'System',
screen: 'Window',
pageLabel: 'Page',
detecting: 'Read on send',
sendReport: 'Send report',
reportSent: 'Report {id} has arrived',
reportSentText: 'You get an email with every change of status. Most reports are looked at the same day.',
newReport: 'Report another problem',
},
de: {
feedback: 'Feedback',
openFeedback: 'Feedback geben',
close: 'Schließen',
panelTitle: 'Sag uns, was du denkst',
panelText: 'Es geht direkt an das Team, das diese Seite baut.',
kind: 'Worum geht es?',
message: 'Deine Nachricht',
messageNeeded: 'Bitte schreib ein paar Worte, damit wir wissen, wo wir hinschauen sollen.',
email: 'E-Mail für eine Antwort',
send: 'Feedback senden',
sentTitle: 'Danke, es ist angekommen',
sentText: 'Wir lesen jede Nachricht. Mit Adresse antworten wir innerhalb von zwei Werktagen.',
another: 'Weiteres Feedback senden',
reaction: 'Wie ist diese Seite?',
addComment: 'Möchtest du mehr sagen?',
mark: 'Stelle markieren',
marking: 'Klicke dorthin, wo etwas nicht stimmt. Pfeiltasten bewegen das Fadenkreuz, Enter setzt es, Escape bricht ab.',
spotSet: 'Stelle markiert',
remark: 'Neu markieren',
spotText: '{x} % von links, {y} % von oben',
attach: 'Beim Senden hängt ein Bildschirmfoto dieser Ansicht an',
whatHere: 'Was stimmt an dieser Stelle nicht?',
spotNeeded: 'Bitte markiere zuerst die Stelle, damit wir sehen, was du meinst.',
reportTitle: 'Kurzer Titel',
reportTitleHint: 'Leere Seite nach Rechnungsdownload',
titleNeeded: 'Bitte gib dem Problem einen kurzen Titel, das hilft uns beim Einordnen.',
severity: 'Wie sehr betrifft es dich?',
severityNeeded: 'Bitte wähle, wie sehr es dich betrifft, damit wir es richtig einordnen.',
steps: 'Schritte zum Nachstellen',
step: 'Schritt {n}',
addStep: 'Schritt hinzufügen',
removeStep: 'Schritt {n} entfernen',
stepsNeeded: 'Bitte beschreib mindestens den ersten Schritt, damit wir es selbst sehen können.',
expected: 'Was hast du erwartet?',
actual: 'Was ist stattdessen passiert?',
actualNeeded: 'Bitte schreib, was passiert ist, das brauchen wir am meisten.',
device: 'Für dich ausgefüllt',
deviceHint: 'Wird mitgeschickt, damit du nichts nachschlagen musst.',
browser: 'Browser',
system: 'System',
screen: 'Fenster',
pageLabel: 'Seite',
detecting: 'Wird beim Senden gelesen',
sendReport: 'Bericht senden',
reportSent: 'Bericht {id} ist angekommen',
reportSentText: 'Bei jeder Statusänderung bekommst du eine E-Mail. Die meisten Berichte sehen wir uns am selben Tag an.',
newReport: 'Weiteres Problem melden',
},
};
type Texts = (typeof TEXTS)['en'];
const DEFAULT_CATEGORIES: Record<'en' | 'de', FeedbackCategory[]> = {
en: [
{ value: 'idea', label: 'Idea', icon: 'lightbulb', prompt: 'What would make this better?' },
{ value: 'problem', label: 'Problem', icon: 'alert-circle', prompt: 'What went wrong?' },
{ value: 'praise', label: 'Praise', icon: 'heart', prompt: 'What do you like?' },
],
de: [
{ value: 'idea', label: 'Idee', icon: 'lightbulb', prompt: 'Was würde es besser machen?' },
{ value: 'problem', label: 'Problem', icon: 'alert-circle', prompt: 'Was ist schiefgelaufen?' },
{ value: 'praise', label: 'Lob', icon: 'heart', prompt: 'Was gefällt dir?' },
],
};
/* ------------------------------------------------------------------ */
/* Shared */
/* ------------------------------------------------------------------ */
/** Escape closes, focus moves into the panel on open and back to the opener on close */
function usePanel(defaultOpen?: boolean) {
const [open, setOpen] = useState(Boolean(defaultOpen));
const opener = useRef<HTMLButtonElement>(null);
const panel = useRef<HTMLDivElement>(null);
const userOpened = useRef(false);
useEffect(() => {
if (!open) return;
if (userOpened.current) panel.current?.querySelector<HTMLElement>('[data-autofocus]')?.focus({ preventScroll: true });
const key = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setOpen(false);
opener.current?.focus();
}
};
document.addEventListener('keydown', key);
return () => document.removeEventListener('keydown', key);
}, [open]);
const toggle = () => {
userOpened.current = true;
setOpen((o) => !o);
};
const close = () => {
setOpen(false);
opener.current?.focus();
};
return { open, toggle, close, opener, panel };
}
function Sent({ t, onAgain, title, text }: { t: Texts; onAgain: () => void; title?: string; text?: string }) {
const ref = useRef<HTMLParagraphElement>(null);
useEffect(() => ref.current?.focus({ preventScroll: true }), []);
return (
<div role="status" className="kit-rise flex flex-col items-center px-2 py-6 text-center">
<DoneMark size={52} />
<p ref={ref} tabIndex={-1} className="mt-4 text-[17px] font-semibold text-foreground outline-none">
{title ?? t.sentTitle}
</p>
<p className="mt-1.5 text-pretty text-[14px] leading-relaxed text-muted-foreground">{text ?? t.sentText}</p>
<Button variant="ghost" size="sm" iconLeft="plus" onClick={onAgain} className="mt-3">
{t.another}
</Button>
</div>
);
}
function CloseButton({ t, onClick }: { t: Texts; onClick: () => void }) {
return (
<button type="button" onClick={onClick} aria-label={t.close} className="-me-2 grid size-11 shrink-0 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-[color-mix(in_oklab,var(--foreground)_6%,transparent)] hover:text-foreground focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_28%,transparent)] focus-visible:outline-none">
<Icon name="x" size={18} />
</button>
);
}
/** Message form with category, text and optional email, used by side-tab */
function MessageForm({ p, t, cats, onDone }: { p: FeedbackWidgetProps; t: Texts; cats: FeedbackCategory[]; onDone: () => void }) {
const id = useId();
const [cat, setCat] = useState(cats[0]?.value ?? '');
const [message, setMessage] = useState('');
const [email, setEmail] = useState('');
const [error, setError] = useState(false);
const [attempt, setAttempt] = useState(0);
const ref = useRef<HTMLFormElement>(null);
useFocusFirstError(ref, attempt);
const prompt = cats.find((c) => c.value === cat)?.prompt ?? t.message;
return (
<form
ref={ref}
noValidate
className="grid gap-5"
onSubmit={(e) => {
e.preventDefault();
if (!message.trim()) {
setError(true);
setAttempt((a) => a + 1);
return;
}
p.onSubmit?.({ category: cat, message, email: email || undefined });
onDone();
}}
>
{/* Category as a segmented row with a sliding tile */}
<fieldset className="min-w-0">
<legend className="text-[13px] font-medium text-foreground">{t.kind}</legend>
<div className="relative mt-2 grid rounded-lg bg-[color-mix(in_oklab,var(--foreground)_6%,transparent)] p-1" style={{ gridTemplateColumns: `repeat(${cats.length}, minmax(0, 1fr))` }}>
<span
aria-hidden
className="absolute inset-y-1 start-1 rounded-md bg-card shadow-[var(--shadow-sm)] transition-[translate] duration-[420ms] ease-[cubic-bezier(0.3,1.25,0.5,1)] motion-reduce:transition-none"
style={{ width: `calc(${round(100 / cats.length, 4)}% - ${round(8 / cats.length, 2)}px)`, translate: `calc(${Math.max(0, cats.findIndex((c) => c.value === cat))} * 100%)` }}
/>
{cats.map((c, i) => (
<label key={c.value} htmlFor={`${id}-c${i}`} className="relative z-10 flex min-h-11 cursor-pointer items-center justify-center gap-1.5 rounded-md text-[13.5px] font-medium text-muted-foreground transition-colors has-[:checked]:text-foreground has-[:focus-visible]:shadow-[0_0_0_2px_color-mix(in_oklab,var(--primary)_45%,transparent)]">
<input id={`${id}-c${i}`} data-autofocus={i === 0 || undefined} type="radio" name={`${id}-cat`} value={c.value} checked={cat === c.value} onChange={() => setCat(c.value)} className="sr-only" />
{c.icon && <Icon name={c.icon} size={15} />}
{c.label}
</label>
))}
</div>
</fieldset>
<Textarea id={`${id}-m`} label={prompt} value={message} onChange={(e) => (setMessage(e.target.value), setError(false))} error={error ? t.messageNeeded : undefined} minRows={4} maxLength={1000} required />
{p.askEmail !== false && <Input id={`${id}-e`} type="email" label={t.email} optional autoComplete="email" value={email} onChange={(e) => setEmail(e.target.value)} />}
<Button type="submit" icon="send" className="w-full">
{t.send}
</Button>
</form>
);
}
/* ------------------------------------------------------------------ */
/* side-tab */
/* ------------------------------------------------------------------ */
function SideTab({ p, t, cats, pos }: { p: FeedbackWidgetProps; t: Texts; cats: FeedbackCategory[]; pos: string }) {
const s = usePanel(p.defaultOpen);
const [sent, setSent] = useState(false);
const hid = useId();
return (
<>
<button
ref={s.opener}
type="button"
aria-expanded={s.open}
aria-controls={hid}
onClick={s.toggle}
// Slides out of the frame on purpose while the panel is open (hidden and inert then)
data-clip-ok
className={cn(
pos,
'right-0 top-1/2 z-30 flex -translate-y-1/2 items-center gap-2 rounded-s-lg border border-e-0 border-border bg-card px-2.5 py-4 text-[13px] font-medium text-foreground shadow-[var(--shadow-md)] transition-[transform,opacity,background-color] duration-500 ease-[var(--ease-kit)] [writing-mode:vertical-rl] hover:bg-surface focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_28%,transparent)] focus-visible:outline-none motion-reduce:transition-none',
s.open && 'pointer-events-none translate-x-full opacity-0',
)}
>
<Icon name="message-circle" size={16} className="rotate-90" />
{t.feedback}
</button>
<div
ref={s.panel}
id={hid}
role="dialog"
aria-modal="false"
aria-labelledby={`${hid}-t`}
className={cn(
pos,
'z-40 flex flex-col bg-card shadow-[var(--shadow-lg)] transition-[transform,opacity] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none',
// Phone: a sheet from the bottom. Tablet and up: a panel from the right edge.
'inset-x-0 bottom-0 max-h-[88%] rounded-t-2xl border-t border-border sm:inset-x-auto sm:inset-y-0 sm:end-0 sm:max-h-none sm:w-[23rem] sm:rounded-none sm:border-s sm:border-t-0',
s.open ? 'translate-y-0 opacity-100 sm:translate-x-0' : 'pointer-events-none translate-y-full opacity-0 sm:translate-x-full sm:translate-y-0',
)}
inert={!s.open || undefined}
>
<span aria-hidden className="mx-auto mt-2.5 h-1 w-10 shrink-0 rounded-full bg-[color-mix(in_oklab,var(--foreground)_18%,transparent)] sm:hidden" />
<div className="flex items-start gap-3 border-b border-border px-5 pb-4 pt-3 sm:pt-5">
<div className="min-w-0 flex-1">
<h3 id={`${hid}-t`} className="text-[17px] font-semibold text-foreground">
{t.panelTitle}
</h3>
<p className="mt-1 text-[13px] leading-5 text-muted-foreground">{t.panelText}</p>
</div>
<CloseButton t={t} onClick={s.close} />
</div>
<div className="kit-scrollbar relative min-h-0 flex-1 overflow-y-auto px-5 py-5">
{sent ? <Sent t={t} onAgain={() => setSent(false)} /> : <MessageForm p={p} t={t} cats={cats} onDone={() => setSent(true)} />}
</div>
</div>
</>
);
}
/* ------------------------------------------------------------------ */
/* bubble */
/* ------------------------------------------------------------------ */
function Bubble({ p, t, cats, pos }: { p: FeedbackWidgetProps; t: Texts; cats: FeedbackCategory[]; pos: string }) {
const s = usePanel(p.defaultOpen);
const id = useId();
const [reaction, setReaction] = useState<string | null>(null);
const [message, setMessage] = useState('');
const [sent, setSent] = useState(false);
const prompt = cats.find((c) => c.value === reaction)?.prompt ?? t.addComment;
return (
<div className={cn(pos, 'pointer-events-none inset-x-4 bottom-4 z-40 flex flex-col items-end gap-3 sm:inset-x-auto sm:bottom-6 sm:end-6')}>
<div
ref={s.panel}
id={id}
role="dialog"
aria-modal="false"
aria-labelledby={`${id}-t`}
className={cn(
'w-full sm:w-[21rem] origin-bottom-right rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-lg)] transition-[transform,opacity] duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none',
s.open ? 'pointer-events-auto scale-100 opacity-100' : 'pointer-events-none translate-y-2 scale-95 opacity-0',
)}
inert={!s.open || undefined}
>
{sent ? (
<Sent
t={t}
onAgain={() => {
setSent(false);
setReaction(null);
setMessage('');
}}
/>
) : (
<form
noValidate
onSubmit={(e) => {
e.preventDefault();
p.onSubmit?.({ category: reaction ?? undefined, message });
setSent(true);
}}
>
<div className="flex items-center justify-between gap-3">
<p id={`${id}-t`} className="text-[15px] font-semibold text-foreground">
{t.reaction}
</p>
<CloseButton t={t} onClick={s.close} />
</div>
<div role="radiogroup" aria-labelledby={`${id}-t`} className="mt-2 grid grid-cols-4 gap-1.5">
{cats.map((c, i) => {
const on = reaction === c.value;
return (
<label key={c.value} htmlFor={`${id}-r${i}`} className={cn('group/r flex cursor-pointer flex-col items-center gap-1.5 rounded-xl border px-1 py-3 text-center transition-[border-color,background-color,box-shadow] duration-200 has-[:focus-visible]:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_28%,transparent)]', on ? 'border-[color-mix(in_oklab,var(--primary)_60%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_9%,var(--card))]' : 'border-border hover:border-border-strong')}>
<input id={`${id}-r${i}`} data-autofocus={i === 0 || undefined} type="radio" name={`${id}-reaction`} value={c.value} checked={on} onChange={() => setReaction(c.value)} className="sr-only" />
<span className={cn('grid size-9 place-items-center rounded-full transition-[transform,background-color,color] duration-500 ease-[cubic-bezier(0.34,1.7,0.64,1)]', on ? 'scale-110 bg-primary text-primary-foreground' : 'bg-surface-2 text-muted-foreground group-hover/r:-translate-y-0.5')}>
<Icon name={c.icon ?? 'smile'} size={18} />
</span>
<span className={cn('text-[11.5px] leading-tight', on ? 'font-semibold text-foreground' : 'text-muted-foreground')}>{c.label}</span>
</label>
);
})}
</div>
<Unfold open={reaction !== null}>
<div className="pt-4">
<Textarea label={prompt} optional value={message} onChange={(e) => setMessage(e.target.value)} minRows={3} maxLength={500} />
<Button type="submit" icon="send" className="mt-4 w-full">
{t.send}
</Button>
</div>
</Unfold>
</form>
)}
</div>
<button
ref={s.opener}
type="button"
aria-expanded={s.open}
aria-controls={id}
aria-label={s.open ? t.close : t.openFeedback}
onClick={s.toggle}
className="group/b pointer-events-auto relative grid size-14 place-items-center rounded-full bg-primary text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.2),var(--shadow-lg)] transition-transform duration-300 ease-[var(--ease-kit)] hover:scale-105 focus-visible:shadow-[0_0_0_4px_color-mix(in_oklab,var(--primary)_30%,transparent),var(--shadow-lg)] focus-visible:outline-none active:scale-95 motion-reduce:hover:scale-100"
>
{/* The two icons turn into each other */}
<Icon name="message-circle" size={22} className={cn('absolute transition-[transform,opacity] duration-300', s.open ? 'rotate-90 scale-50 opacity-0' : 'opacity-100')} />
<Icon name="x" size={22} className={cn('absolute transition-[transform,opacity] duration-300', s.open ? 'opacity-100' : '-rotate-90 scale-50 opacity-0')} />
</button>
</div>
);
}
/* ------------------------------------------------------------------ */
/* screenshot */
/* ------------------------------------------------------------------ */
function Screenshot({ p, t, pos }: { p: FeedbackWidgetProps; t: Texts; pos: string }) {
const s = usePanel(p.defaultOpen);
const id = useId();
const [marking, setMarking] = useState(false);
const [cross, setCross] = useState({ x: 50, y: 40 });
const [spot, setSpot] = useState<{ x: number; y: number } | null>(p.defaultOpen ? { x: 26, y: 46 } : null);
const [message, setMessage] = useState('');
const [error, setError] = useState<'spot' | 'message' | null>(null);
const [sent, setSent] = useState(false);
const layer = useRef<HTMLDivElement>(null);
const markBtn = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!marking) return;
layer.current?.focus({ preventScroll: true });
}, [marking]);
const place = (x: number, y: number) => {
setSpot({ x: round(x, 1), y: round(y, 1) });
setMarking(false);
setError(null);
requestAnimationFrame(() => document.getElementById(`${id}-m`)?.focus({ preventScroll: true }));
};
const fromPointer = (e: React.PointerEvent<HTMLDivElement>) => {
const r = e.currentTarget.getBoundingClientRect();
return { x: Math.min(100, Math.max(0, ((e.clientX - r.left) / r.width) * 100)), y: Math.min(100, Math.max(0, ((e.clientY - r.top) / r.height) * 100)) };
};
return (
<>
{/* The marking layer covers the page area, crosshair follows pointer and arrow keys */}
{marking && (
<div
ref={layer}
tabIndex={0}
role="application"
aria-label={t.marking}
className="absolute inset-0 z-30 cursor-crosshair bg-[color-mix(in_oklab,var(--background)_45%,transparent)] outline-none focus-visible:shadow-[inset_0_0_0_3px_color-mix(in_oklab,var(--primary)_45%,transparent)]"
onPointerMove={(e) => setCross(fromPointer(e))}
onPointerDown={(e) => {
const c = fromPointer(e);
place(c.x, c.y);
}}
onKeyDown={(e) => {
const step = e.shiftKey ? 10 : 2;
const move: Record<string, [number, number]> = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] };
if (move[e.key]) {
e.preventDefault();
setCross((c) => ({ x: Math.min(100, Math.max(0, c.x + move[e.key][0])), y: Math.min(100, Math.max(0, c.y + move[e.key][1])) }));
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
place(cross.x, cross.y);
} else if (e.key === 'Escape') {
e.stopPropagation();
setMarking(false);
markBtn.current?.focus();
}
}}
>
<span aria-hidden className="pointer-events-none absolute inset-x-0 h-px bg-[color-mix(in_oklab,var(--primary)_70%,transparent)]" style={{ top: `${round(cross.y, 2)}%` }} />
<span aria-hidden className="pointer-events-none absolute inset-y-0 w-px bg-[color-mix(in_oklab,var(--primary)_70%,transparent)]" style={{ left: `${round(cross.x, 2)}%` }} />
<p className="pointer-events-none absolute inset-x-4 top-4 mx-auto max-w-[30rem] rounded-xl border border-border bg-card px-4 py-3 text-center text-[13.5px] leading-snug text-foreground shadow-[var(--shadow-lg)]">{t.marking}</p>
</div>
)}
{/* The marked spot on the page, with a soft pulse */}
{spot && !marking && (
<span aria-hidden className="pointer-events-none absolute z-20 -translate-x-1/2 -translate-y-1/2" style={{ left: `${spot.x}%`, top: `${spot.y}%` }}>
<span className="absolute inset-0 rounded-full bg-primary opacity-40 motion-safe:animate-ping" />
<span className="relative grid size-7 place-items-center rounded-full border-2 border-background bg-primary text-[12px] font-bold text-primary-foreground shadow-[var(--shadow-md)]">1</span>
</span>
)}
<div className={cn(pos, 'pointer-events-none inset-x-4 bottom-4 z-40 flex flex-col items-end gap-3 sm:inset-x-auto sm:bottom-6 sm:end-6', marking && 'pointer-events-none opacity-0')}>
<div
ref={s.panel}
id={id}
role="dialog"
aria-modal="false"
aria-labelledby={`${id}-t`}
className={cn('w-full sm:w-[22rem] origin-bottom-right rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-lg)] transition-[transform,opacity] duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none', s.open ? 'pointer-events-auto scale-100 opacity-100' : 'pointer-events-none translate-y-2 scale-95 opacity-0')}
inert={!s.open || undefined}
>
{sent ? (
<Sent
t={t}
onAgain={() => {
setSent(false);
setSpot(null);
setMessage('');
}}
/>
) : (
<form
noValidate
onSubmit={(e) => {
e.preventDefault();
if (!spot) {
setError('spot');
markBtn.current?.focus();
return;
}
if (!message.trim()) {
setError('message');
document.getElementById(`${id}-m`)?.focus();
return;
}
p.onSubmit?.({ message, spot });
setSent(true);
}}
>
<div className="flex items-center justify-between gap-3">
<p id={`${id}-t`} className="text-[15px] font-semibold text-foreground">
{t.panelTitle}
</p>
<CloseButton t={t} onClick={s.close} />
</div>
{/* A small outline of the view with the spot in it */}
<div className="mt-3 flex items-center gap-3.5 rounded-xl border border-border bg-surface p-3">
<span aria-hidden className="relative h-14 w-20 shrink-0 overflow-hidden rounded-md border border-border bg-background">
<span className="absolute inset-x-2 top-2 h-1 rounded-full bg-[color-mix(in_oklab,var(--foreground)_14%,transparent)]" />
<span className="absolute start-2 top-5 h-1.5 w-9 rounded-full bg-[color-mix(in_oklab,var(--foreground)_22%,transparent)]" />
<span className="absolute inset-x-2 bottom-2 grid h-4 grid-cols-3 gap-1">
<span className="rounded-sm bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)]" />
<span className="rounded-sm bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)]" />
<span className="rounded-sm bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)]" />
</span>
{spot && <span className="absolute size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border border-background bg-primary" style={{ left: `${spot.x}%`, top: `${spot.y}%` }} />}
</span>
<span className="min-w-0 flex-1">
<span className="block text-[13.5px] font-medium text-foreground">{spot ? t.spotSet : t.mark}</span>
<span className="block text-[12.5px] leading-snug text-muted-foreground">{spot ? t.spotText.replace('{x}', String(Math.round(spot.x))).replace('{y}', String(Math.round(spot.y))) : t.attach}</span>
</span>
</div>
<Button ref={markBtn} type="button" variant={spot ? 'ghost' : 'outline'} size="sm" iconLeft="locate" onClick={() => (setMarking(true), setError(null))} aria-describedby={error === 'spot' ? `${id}-se` : undefined} className="mt-3 w-full" data-autofocus>
{spot ? t.remark : t.mark}
</Button>
{error === 'spot' && (
<p id={`${id}-se`} role="alert" className="kit-rise mt-2 text-[13px] font-medium text-destructive">
{t.spotNeeded}
</p>
)}
<Unfold open={spot !== null}>
<div className="pt-4">
<Textarea id={`${id}-m`} label={t.whatHere} value={message} onChange={(e) => (setMessage(e.target.value), setError(null))} error={error === 'message' ? t.messageNeeded : undefined} minRows={3} maxLength={500} required />
<p className="mt-3 flex items-start gap-2 text-[12.5px] leading-snug text-muted-foreground">
<Icon name="camera" size={14} className="mt-0.5 shrink-0" />
{t.attach}
</p>
<Button type="submit" icon="send" className="mt-4 w-full">
{t.send}
</Button>
</div>
</Unfold>
</form>
)}
</div>
<button ref={s.opener} type="button" aria-expanded={s.open} aria-controls={id} onClick={s.toggle} className="pointer-events-auto inline-flex h-11 items-center gap-2 rounded-full border border-border bg-card px-4 text-[14px] font-medium text-foreground shadow-[var(--shadow-lg)] transition-[transform,background-color] duration-300 hover:-translate-y-0.5 hover:bg-surface focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_28%,transparent),var(--shadow-lg)] focus-visible:outline-none motion-reduce:hover:translate-y-0">
<Icon name="locate" size={17} className="text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]" />
{t.feedback}
</button>
</div>
</>
);
}
/* ------------------------------------------------------------------ */
/* bug-report */
/* ------------------------------------------------------------------ */
function readDevice(t: Texts) {
const ua = navigator.userAgent;
const browser = /Edg\//.test(ua) ? 'Edge' : /Chrome\//.test(ua) ? 'Chrome' : /Firefox\//.test(ua) ? 'Firefox' : /Safari\//.test(ua) ? 'Safari' : 'Browser';
const version = (ua.match(/(?:Edg|Chrome|Firefox|Version)\/(\d+)/) ?? [])[1];
const system = /Windows/.test(ua) ? 'Windows' : /Mac OS X/.test(ua) ? (/(iPhone|iPad)/.test(ua) ? 'iOS' : 'macOS') : /Android/.test(ua) ? 'Android' : /Linux/.test(ua) ? 'Linux' : '';
return {
[t.browser]: version ? `${browser} ${version}` : browser,
[t.system]: system || t.detecting,
[t.screen]: `${window.innerWidth} × ${window.innerHeight}`,
[t.pageLabel]: window.location.pathname,
};
}
function BugReport({ p, t, cats }: { p: FeedbackWidgetProps; t: Texts; cats: FeedbackCategory[] }) {
const id = useId();
const [title, setTitle] = useState('');
const [severity, setSeverity] = useState<string[]>([]);
const [steps, setSteps] = useState<string[]>(['', '']);
const [expected, setExpected] = useState('');
const [actual, setActual] = useState('');
const [errors, setErrors] = useState<Record<string, string>>({});
const [attempt, setAttempt] = useState(0);
const [device, setDevice] = useState<Record<string, string> | null>(null);
const [sent, setSent] = useState<string | null>(null);
const formRef = useRef<HTMLFormElement>(null);
const summaryRef = useRef<HTMLDivElement>(null);
const added = useRef(false);
useFocusFirstError(formRef, attempt, summaryRef);
useEffect(() => setDevice(readDevice(t)), [t]);
useEffect(() => {
if (!added.current) return;
added.current = false;
document.getElementById(`${id}-s${steps.length - 1}`)?.focus();
}, [steps.length, id]);
const summary: FlowError[] = [
errors.title && { field: `${id}-title`, message: t.reportTitle },
errors.severity && { field: `${id}-sev`, message: t.severity },
errors.steps && { field: `${id}-s0`, message: t.steps },
errors.actual && { field: `${id}-act`, message: t.actual },
].filter(Boolean) as FlowError[];
const clear = (k: string) => errors[k] && setErrors((e) => Object.fromEntries(Object.entries(e).filter(([x]) => x !== k)));
if (sent)
return (
<div className="kit-card mx-auto max-w-[48rem] p-6 sm:p-10">
<div role="status" className="kit-rise">
<DoneMark />
<h3 className="mt-5 text-h3 font-semibold text-foreground">{t.reportSent.replace('{id}', sent)}</h3>
<p className="mt-2 max-w-[34rem] text-pretty text-[15px] leading-relaxed text-muted-foreground">{t.reportSentText}</p>
<Button
variant="outline"
iconLeft="plus"
className="mt-6"
onClick={() => {
setSent(null);
setTitle('');
setSeverity([]);
setSteps(['', '']);
setExpected('');
setActual('');
}}
>
{t.newReport}
</Button>
</div>
</div>
);
return (
<Reveal effect="rise" className="mx-auto max-w-[56rem]">
<form
ref={formRef}
noValidate
className="kit-card grid grid-cols-1 overflow-hidden lg:grid-cols-[minmax(0,1fr)_17rem]"
onSubmit={(e) => {
e.preventDefault();
const found: Record<string, string> = {};
if (!title.trim()) found.title = t.titleNeeded;
if (!severity.length) found.severity = t.severityNeeded;
if (!steps[0]?.trim()) found.steps = t.stepsNeeded;
if (!actual.trim()) found.actual = t.actualNeeded;
setErrors(found);
if (Object.keys(found).length) {
setAttempt((a) => a + 1);
return;
}
p.onSubmit?.({ title, category: severity[0], message: actual, steps: steps.filter((s) => s.trim()), expected, actual, device: device ?? undefined });
setSent(`#${String(Date.now()).slice(-5)}`);
}}
>
<div className="grid min-w-0 gap-6 p-6 sm:p-8">
<ErrorSummary ref={summaryRef} errors={summary} />
<Input id={`${id}-title`} label={t.reportTitle} placeholder={t.reportTitleHint} required value={title} onChange={(e) => (setTitle(e.target.value), clear('title'))} error={errors.title} />
<ChipGroup id={`${id}-sev`} legend={t.severity} required options={cats.map((c) => ({ value: c.value, label: c.label, icon: c.icon }))} value={severity} onChange={(v) => (setSeverity(v), clear('severity'))} error={errors.severity} />
<fieldset className="min-w-0">
<legend className="text-sm font-medium text-foreground">
{t.steps}
<span aria-hidden className="ms-0.5 text-[color-mix(in_oklab,var(--destructive)_85%,var(--foreground))]">
*
</span>
</legend>
<ol className="mt-3 grid gap-2">
{steps.map((s, i) => (
<li key={i} className="kit-rise flex items-center gap-2">
<span aria-hidden className="grid size-7 shrink-0 place-items-center rounded-full bg-surface-2 text-[12px] font-semibold tabular-nums text-muted-foreground">
{i + 1}
</span>
<Input
id={`${id}-s${i}`}
aria-label={t.step.replace('{n}', String(i + 1))}
value={s}
invalid={i === 0 && Boolean(errors.steps)}
aria-describedby={i === 0 && errors.steps ? `${id}-se` : undefined}
onChange={(e) => {
const v = e.target.value;
setSteps((x) => x.map((y, j) => (j === i ? v : y)));
if (i === 0) clear('steps');
}}
className="flex-1"
/>
{steps.length > 1 && (
<button type="button" aria-label={t.removeStep.replace('{n}', String(i + 1))} onClick={() => setSteps((x) => x.filter((_, j) => j !== i))} className="grid size-11 shrink-0 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-[color-mix(in_oklab,var(--foreground)_6%,transparent)] hover:text-foreground focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_28%,transparent)] focus-visible:outline-none">
<Icon name="minus-circle" size={17} />
</button>
)}
</li>
))}
</ol>
{errors.steps && (
<p id={`${id}-se`} className="kit-rise mt-2 flex items-start gap-1.5 text-[13px] font-medium text-destructive">
<Icon name="alert-circle" size={15} strokeWidth={1.8} className="mt-[2.5px] shrink-0" />
{errors.steps}
</p>
)}
{steps.length < 8 && (
<Button
type="button"
variant="ghost"
size="sm"
iconLeft="plus"
className="-ms-3 mt-2"
onClick={() => {
added.current = true;
setSteps((x) => [...x, '']);
}}
>
{t.addStep}
</Button>
)}
</fieldset>
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
<Textarea label={t.expected} optional value={expected} onChange={(e) => setExpected(e.target.value)} minRows={3} />
<Textarea id={`${id}-act`} label={t.actual} required value={actual} onChange={(e) => (setActual(e.target.value), clear('actual'))} error={errors.actual} minRows={3} />
</div>
<div className="flex justify-end border-t border-border pt-6">
<Button type="submit" icon="send" className="max-sm:w-full">
{t.sendReport}
</Button>
</div>
</div>
{/* What the browser can tell, filled in after load, shown so nothing is sent unseen */}
<aside className="border-t border-border bg-surface p-6 sm:p-8 lg:border-s lg:border-t-0">
<p className="flex items-center gap-2 text-[14px] font-semibold text-foreground">
<Icon name="monitor" size={16} className="text-muted-foreground" />
{t.device}
</p>
<p className="mt-1 text-[13px] leading-5 text-muted-foreground">{t.deviceHint}</p>
<dl className="mt-5 grid grid-cols-2 gap-x-4 gap-y-4 sm:grid-cols-4 lg:grid-cols-1">
{[t.browser, t.system, t.screen, t.pageLabel].map((k, i) => (
<div key={k} className="min-w-0">
<dt className="text-[12px] text-subtle-foreground">{k}</dt>
<dd className={cn('mt-0.5 truncate text-[13.5px] font-medium tabular-nums', device ? 'kit-rise text-foreground' : 'text-subtle-foreground')} style={{ '--kit-delay': `${i * 90}ms` } as React.CSSProperties}>
{device?.[k] ?? t.detecting}
</dd>
</div>
))}
</dl>
</aside>
</form>
</Reveal>
);
}
/* ------------------------------------------------------------------ */
/* Section */
/* ------------------------------------------------------------------ */
const BUG_SEVERITY: Record<'en' | 'de', FeedbackCategory[]> = {
en: [
{ value: 'blocking', label: 'Blocks my work', icon: 'ban' },
{ value: 'annoying', label: 'Slows me down', icon: 'hourglass' },
{ value: 'cosmetic', label: 'Looks wrong', icon: 'eye' },
],
de: [
{ value: 'blocking', label: 'Blockiert mich', icon: 'ban' },
{ value: 'annoying', label: 'Bremst mich', icon: 'hourglass' },
{ value: 'cosmetic', label: 'Sieht falsch aus', icon: 'eye' },
],
};
/**
* Feedback from any page.
* <FeedbackWidget variant="side-tab" /> fixed over the window
* <FeedbackWidget variant="bubble" contained page={…} /> inside a framed preview
* <FeedbackWidget variant="bug-report" categories={[…]} onSubmit={(report) => …} />
*/
export function FeedbackWidget(props: FeedbackWidgetProps) {
const { variant = 'side-tab', eyebrow, title, description, contained, page, labels, className } = props;
const t = useTexts(TEXTS, labels);
const locale = useLocale();
if (variant === 'bug-report') {
const cats = props.categories ?? BUG_SEVERITY[locale];
return (
<Section className={className}>
<FlowHead eyebrow={eyebrow} title={title} description={description} className="mx-auto mb-10 max-w-[56rem] sm:mb-14" />
<BugReport p={props} t={t} cats={cats} />
</Section>
);
}
const cats = props.categories ?? DEFAULT_CATEGORIES[locale];
const pos = contained ? 'absolute' : 'fixed';
const widget =
variant === 'bubble' ? <Bubble p={props} t={t} cats={cats} pos={pos} /> : variant === 'screenshot' ? <Screenshot p={props} t={t} pos={pos} /> : <SideTab p={props} t={t} cats={cats} pos={pos} />;
if (!contained) return widget;
return (
<Section className={className}>
<FlowHead eyebrow={eyebrow} title={title} description={description} className="mb-10 sm:mb-14" />
<Reveal effect="scale">
<div className="relative h-[40rem] overflow-hidden rounded-2xl border border-border bg-background shadow-[var(--highlight),var(--shadow-lg)] sm:h-[36rem]">
<div aria-hidden className="pointer-events-none absolute inset-0 select-none">
{page}
</div>
{widget}
</div>
</Reveal>
</Section>
);
}Frequent questions
How do I install Feedback widget?
With aiw add @ai-website/feedback-widget. The command puts components/ui/feedback-widget.tsx into your project, the code is yours from then on. It brings along button, icon, input, textarea, reveal, section and form-flow. It needs no further npm packages.
Which variants does Feedback widget have?
4 variants, Side tab, Bubble with reactions, Mark the spot and Bug report. You pick one with the variant prop, for example variant="bubble".
Can I translate the texts of Feedback widget?
Yes. Feedback widget carries 47 texts of its own in English and German, such as “Feedback”, “Give feedback” and “Close”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Feedback widget?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Feedback widget with aiw_components and install it with aiw_add @ai-website/feedback-widget. Name the variant in your prompt, for example variant="side-tab", and the assistant writes your content straight into the props.
Can I adjust Feedback widget without code?
Yes, in the Studio. All 4 variants are editable there, texts, lists and switches. The Studio writes the matching FeedbackWidget code to copy.