Skip to content
UI Kit
Accent color

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

4 variants

Variants

Side tab

A tab on the right edge slides away and a panel slides in, a sheet from the bottom on phones. Kind of feedback in a segmented row with a sliding tile, the text field asks what fits the kind.

Open in Studio

Bubble with reactions

A round button whose icon turns into a close mark. One tap on a reaction is already feedback, a comment field opens below it for those who want to say more.

Open in Studio

Mark the spot

Visitors mark the exact spot with a crosshair, by pointer or with the arrow keys, a numbered pin pulses there. The panel shows the spot in a small outline of the view and asks what is wrong at it.

Open in Studio

Bug report

A full report inline. Steps to reproduce as a numbered list that grows, expected and actual side by side, and the device read from the browser in a side column, so people see what is sent.

Open in Studio

Details

What Feedback widget brings along, read from the registry and the source.

Variants
4
Version
1.0.0 · since 1.1.0
File
components/ui/feedback-widget.tsx
Brings along
utilskitbuttoniconinputtextarearevealsectionform-flow
npm packages
None
Languages
English and German, 47 texts of its own

Props

The FeedbackWidgetProps interface from components/ui/feedback-widget.tsx, with the comments from the code.

  • variantoptional
    FeedbackWidgetVariant

    side-tabbubblescreenshotbug-report

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • categoriesoptional
    FeedbackCategory[]

    Kinds of feedback (side-tab, bubble reactions, bug-report severity)

  • askEmailoptional
    boolean

    Ask for an email address to reply to

  • containedoptional
    boolean

    Sit inside the nearest positioned parent instead of over the window

  • pageoptional
    React.ReactNode

    contained: the page drawn behind the widget, e.g. a screenshot of your site

  • defaultOpenoptional
    boolean

    Open 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
feedbackEnglish FeedbackGerman Feedback
openFeedbackEnglish Give feedbackGerman Feedback geben
closeEnglish CloseGerman Schließen
panelTitleEnglish Tell us what you thinkGerman Sag uns, was du denkst
panelTextEnglish It goes straight to the team that builds this page.German Es geht direkt an das Team, das diese Seite baut.
kindEnglish What is it about?German Worum geht es?
messageEnglish Your messageGerman Deine Nachricht
messageNeededEnglish 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.
emailEnglish Email for a replyGerman E-Mail für eine Antwort
sendEnglish Send feedbackGerman Feedback senden
sentTitleEnglish Thank you, it has arrivedGerman Danke, es ist angekommen
sentTextEnglish 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.
anotherEnglish Send more feedbackGerman Weiteres Feedback senden
reactionEnglish How is this page?German Wie ist diese Seite?
addCommentEnglish Want to say more?German Möchtest du mehr sagen?
markEnglish Mark the spotGerman Stelle markieren
markingEnglish 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.
spotSetEnglish Spot markedGerman Stelle markiert
remarkEnglish Mark againGerman Neu markieren
spotTextEnglish {x} % across, {y} % downGerman {x} % von links, {y} % von oben
attachEnglish A screenshot of this view is attached when you sendGerman Beim Senden hängt ein Bildschirmfoto dieser Ansicht an
whatHereEnglish What is wrong at this spot?German Was stimmt an dieser Stelle nicht?
spotNeededEnglish Please mark the spot first, so we see what you mean.German Bitte markiere zuerst die Stelle, damit wir sehen, was du meinst.
reportTitleEnglish Short titleGerman Kurzer Titel
reportTitleHintEnglish Empty page after invoice downloadGerman Leere Seite nach Rechnungsdownload
titleNeededEnglish 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.
severityEnglish How much does it affect you?German Wie sehr betrifft es dich?
severityNeededEnglish 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.
stepsEnglish Steps to reproduceGerman Schritte zum Nachstellen
stepEnglish Step {n}German Schritt {n}
addStepEnglish Add a stepGerman Schritt hinzufügen
removeStepEnglish Remove step {n}German Schritt {n} entfernen
stepsNeededEnglish 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.
expectedEnglish What did you expect?German Was hast du erwartet?
actualEnglish What happened instead?German Was ist stattdessen passiert?
actualNeededEnglish Please tell us what happened, it is the part we need most.German Bitte schreib, was passiert ist, das brauchen wir am meisten.
deviceEnglish Filled in for youGerman Für dich ausgefüllt
deviceHintEnglish Sent with the report so you do not have to look it up.German Wird mitgeschickt, damit du nichts nachschlagen musst.
browserEnglish BrowserGerman Browser
systemEnglish SystemGerman System
screenEnglish WindowGerman Fenster
pageLabelEnglish PageGerman Seite
detectingEnglish Read on sendGerman Wird beim Senden gelesen
sendReportEnglish Send reportGerman Bericht senden
reportSentEnglish Report {id} has arrivedGerman Bericht {id} ist angekommen
reportSentTextEnglish 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.
newReportEnglish Report another problemGerman 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
components/ui/feedback-widget.tsx
'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.

Often searched

Jump to

Copied to the clipboard