Skip to content
Star on GitHub
ConversationFeedback sheet
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Feedback sheet

Rate an answer and tell the agent what missed. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.

$npx designme add feedback-sheet

I tightened the hero spacing to 24px and swapped the CTA to the accent blue. The card grid now wraps at 720px.

What went wrong?
Shared with the agent for this project
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add feedback-sheet

React

$npx designme add feedback-sheet --react

The CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.

02

About

What it is for and what it does.

Feedback sheet puts thumbs up and thumbs down under an agent's answer. A thumbs down opens a short sheet where the person picks what went wrong from quick reasons, adds a note, and sends it, so the agent can do better next time.

  • When you want ratings on agent answers and the reasons behind the bad ones.
  • When feedback should be stored per project, like a memory of what the agent should avoid.
  • When a rating should cost one click and the longer form should only appear when something went wrong.
03

Usage

Import it and drive it from your own state.

Import

import { FeedbackSheet } from '@/components/designme/feedback-sheet/FeedbackSheet';

Example

The demo above as a React component, 44 lines you can copy.

'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useRef, useState } from 'react';
import { useStory, useWait } from '../lib/useScript';
import { FeedbackSheet, type Feedback, type FeedbackRating } from './FeedbackSheet';

/* ---------- Feedback sheet: thumbs under an answer; thumbs down opens a short sheet. It plays a thumbs down by itself,
   and clicking, focusing or typing inside it takes over ---------- */
const NOTE = 'Keep our 8px spacing scale';

export default function FeedbackSheetDemo() {
  const [rating, setRating] = useState<FeedbackRating>(null);
  const [open, setOpen] = useState(false);
  const [selected, setSelected] = useState<string[]>([]);
  const [comment, setComment] = useState('');
  const [sending, setSending] = useState(false);
  const [sent, setSent] = useState<Feedback | null>(null);
  const ref = useRef<HTMLDivElement>(null), later = useWait();

  useStory(ref, async ({ wait, taken }) => {
    // taking over while it types takes its note back
    let writing = false;
    taken(() => { if (writing) setComment(''); });
    await wait(1500); setRating('down'); setOpen(true);
    await wait(1000); setSelected(['Ignored my tokens']);
    await wait(700); writing = true;
    for (let i = 1; i <= NOTE.length; i++) { setComment(NOTE.slice(0, i)); await wait(34 + Math.random() * 40); }
    await wait(650); writing = false; setSending(true);
    // once sent, it goes through whatever happens next
    await later(800); setSending(false); setOpen(false);
    await later(300); setSent({ reasons: ['Ignored my tokens'], comment: NOTE });
  });

  return (
    <div ref={ref} style={{ width: '100%' }}>
      <FeedbackSheet rating={rating} onRatingChange={setRating} open={open} onOpenChange={setOpen} selected={selected} onSelectedChange={setSelected}
        comment={comment} onCommentChange={setComment} sending={sending || undefined} sent={sent} onSentChange={setSent}
        onSubmit={() => new Promise(res => setTimeout(res, 800))}>
        I tightened the hero spacing to 24px and swapped the CTA to the accent blue. The card grid now wraps at 720px.
      </FeedbackSheet>
    </div>
  );
}
04

Props

Everything FeedbackSheet takes.

PropTypeWhat it does
childrenReactNodeThe answer being rated, shown above the thumbs
ratingFeedbackRatingWhether the answer was rated good or bad. Leave it out to let the thumbs set it
defaultRatingFeedbackRating
onRatingChange(rating: FeedbackRating) => void
openbooleanWhether the reasons sheet is open. Leave it out and thumbs down opens it
defaultOpenboolean
onOpenChange(open: boolean) => void
selectedstring[]The reasons picked. Leave it out to let the chips keep it
defaultSelectedstring[]
onSelectedChange(selected: string[]) => void
commentstringWhat is written in the box. Leave it out to let the box keep it
defaultCommentstring
onCommentChange(comment: string) => void
sendingbooleanShows the spinner on Send, for a scripted demo. A promise from onSubmit shows it by itself
sentFeedback | nullWhat was sent, which shows the thank-you line. Leave it out and Send sets it
onSentChange(sent: Feedback | null) => void
questionstringThe heading of the sheet that opens on thumbs down
reasonsstring[]Quick reasons to pick from
placeholderstring
notestringA quiet note beside the buttons, like who sees the feedback
onSubmit(feedback: Feedback) => void | Promise<void>Called with what the person picked and wrote. Return a promise to show a spinner until it settles
thanks(feedback: Feedback) => ReactNodeWhat to say once it is sent. Defaults to a line about what the agent will do next time
barebooleanSits flat under a message in a chat thread (the default, as on designme.dev). Set false to give it a card of its own
classNamestring
05

Code

The full source, as the CLI installs it.

FeedbackSheet.tsxReact · 119 lines
'use client';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useControllable } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { Fold, Spin, Swap, Tip } from '../lib/motion';
import '../designme.css';
import './feedback-sheet.css';

export type FeedbackRating = 'up' | 'down' | null;
export interface Feedback { reasons: string[]; comment: string }

export interface FeedbackSheetProps {
  /** The answer being rated, shown above the thumbs */
  children?: ReactNode;
  /** Whether the answer was rated good or bad. Leave it out to let the thumbs set it */
  rating?: FeedbackRating;
  defaultRating?: FeedbackRating;
  onRatingChange?: (rating: FeedbackRating) => void;
  /** Whether the reasons sheet is open. Leave it out and thumbs down opens it */
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  /** The reasons picked. Leave it out to let the chips keep it */
  selected?: string[];
  defaultSelected?: string[];
  onSelectedChange?: (selected: string[]) => void;
  /** What is written in the box. Leave it out to let the box keep it */
  comment?: string;
  defaultComment?: string;
  onCommentChange?: (comment: string) => void;
  /** Shows the spinner on Send, for a scripted demo. A promise from onSubmit shows it by itself */
  sending?: boolean;
  /** What was sent, which shows the thank-you line. Leave it out and Send sets it */
  sent?: Feedback | null;
  onSentChange?: (sent: Feedback | null) => void;
  /** The heading of the sheet that opens on thumbs down */
  question?: string;
  /** Quick reasons to pick from */
  reasons?: string[];
  placeholder?: string;
  /** A quiet note beside the buttons, like who sees the feedback */
  note?: string;
  /** Called with what the person picked and wrote. Return a promise to show a spinner until it settles */
  onSubmit?: (feedback: Feedback) => void | Promise<void>;
  /** What to say once it is sent. Defaults to a line about what the agent will do next time */
  thanks?: (feedback: Feedback) => ReactNode;
  /** Sits flat under a message in a chat thread (the default, as on designme.dev). Set false to give it a card of its own */
  bare?: boolean;
  className?: string;
}

const REASONS = ['Wrong spacing', 'Ignored my tokens', 'Too many changes', 'Broke the layout'];
const THANKS = (f: Feedback) => `Thanks. The agent will ${f.reasons.length ? 'avoid this' : 'use this'} next time in this project.`;

/** Rate an answer, and say what missed when it was wrong. */
export function FeedbackSheet({ children, rating, defaultRating = null, onRatingChange, open: openProp, defaultOpen = false, onOpenChange,
  selected, defaultSelected = [], onSelectedChange, comment: commentProp, defaultComment = '', onCommentChange, sending: sendingProp, sent: sentProp, onSentChange, question = 'What went wrong?', reasons = REASONS, placeholder = 'Anything else the agent should know', note = 'Shared with the agent for this project', onSubmit, thanks = THANKS, bare = true, className }: FeedbackSheetProps) {
  const [rate, setRate] = useControllable<FeedbackRating>(rating, defaultRating, onRatingChange);
  const [open, setOpen] = useControllable(openProp, defaultOpen, onOpenChange);
  const [picked, setPicked] = useControllable(selected, defaultSelected, onSelectedChange);
  const [comment, setComment] = useControllable(commentProp, defaultComment, onCommentChange);
  const [busy, setBusy] = useState(false);
  const sending = sendingProp ?? busy;
  const [sent, setSent] = useControllable<Feedback | null>(sentProp, null, onSentChange);
  const status = sent ? 'Feedback sent' : rate === 'up' ? 'Thanks, noted' : '';
  const box = useRef<HTMLTextAreaElement>(null);
  const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
  const later = (ms: number, fn: () => void) => { timers.current.push(setTimeout(fn, ms)); };
  useEffect(() => () => timers.current.forEach(clearTimeout), []);

  const up = () => { const on = rate !== 'up'; setRate(on ? 'up' : null); setOpen(false); };
  const down = () => {
    const on = rate !== 'down'; setRate(on ? 'down' : null); setOpen(on);
    // the sheet opens first, then the cursor goes to the box
    if (on) later(350, () => box.current?.focus({ preventScroll: true }));
  };
  const send = async () => {
    if (sending) return;
    const f = { reasons: picked, comment: comment.trim() };
    setBusy(true);
    // if sending fails the sheet stays open so the person can try again
    try { await onSubmit?.(f); } catch (e) { setBusy(false); throw e; }
    setBusy(false); setOpen(false);
    later(300, () => setSent(f));
  };

  return (
    <div data-dm="" className={cx('dm-fbx', !bare && 'dm-panel', className)} style={bare ? undefined : { padding: 16 }}>
      {children && <div className="dm-msg">{children}</div>}
      <div className="dm-fbx-bar">
        <Tip label="Good answer"><button type="button" className={cx('dm-ib dm-fbx-up', rate === 'up' && 'dm-on')} aria-label="Good answer" aria-pressed={rate === 'up'} onClick={up}><Icon name="up-thumb" /></button></Tip>
        <Tip label="Bad answer"><button type="button" className={cx('dm-ib dm-fbx-dn', rate === 'down' && 'dm-on')} aria-label="Bad answer" aria-pressed={rate === 'down'} onClick={down}><Icon name="down-thumb" /></button></Tip>
        <Swap className="dm-fbx-st dm-muted">{status}</Swap>
      </div>
      <Fold open={open} className="dm-fbx-box">
        <div className="dm-fbx-in dm-panel">
          <div className="dm-fbx-q">{question}</div>
          {reasons.length > 0 && (
            <div className="dm-fbx-ch">
              {reasons.map(r => {
                const on = picked.includes(r);
                return <button key={r} type="button" className={cx('dm-chip dm-sm', on && 'dm-on')} aria-pressed={on} onClick={() => setPicked(on ? picked.filter(x => x !== r) : [...picked, r])}>{r}</button>;
              })}
            </div>
          )}
          <textarea ref={box} className="dm-fbx-ta" rows={2} placeholder={placeholder} value={comment} onChange={e => setComment(e.target.value)} />
          <div className="dm-fbx-f">
            <span className="dm-muted dm-fbx-n">{note}</span>
            <button type="button" className="dm-pill dm-ghost dm-sm dm-fbx-x" onClick={() => { setOpen(false); setRate(null); }}>Cancel</button>
            <button type="button" className="dm-pill dm-primary dm-sm dm-fbx-go" disabled={!(picked.length || comment.trim())} aria-busy={sending} onClick={send}>{sending ? <Spin /> : 'Send'}</button>
          </div>
        </div>
      </Fold>
      {sent && <div className="dm-fbx-thx dm-enter" role="status"><Icon name="ok" /><span>{thanks(sent)}</span></div>}
    </div>
  );
}
feedback-sheet.cssReact · 22 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Feedback sheet ---------- */
.dm-fbx{display:flex;flex-direction:column;gap:var(--dm-s-2)}
.dm-fbx-bar{display:flex;align-items:center;gap:2px;margin-left:-6px}
.dm-fbx-bar .dm-ib svg{transition:transform .45s var(--dm-ease)}
.dm-fbx-bar .dm-ib.dm-on svg{transform:scale(1.12) rotate(-8deg)}
.dm-fbx-dn.dm-on{background:var(--dm-bad-bg);color:var(--dm-bad)}
.dm-fbx-st{font-size:12.5px;margin-left:6px}
.dm-fbx-box>div{margin-inline:-16px;padding-inline:var(--dm-s-4)}
.dm-fbx-in{padding:var(--dm-s-4);display:flex;flex-direction:column;gap:10px;margin:var(--dm-s-1) 0 18px}
.dm-fbx-q{font-size:13.5px;font-weight:500}
.dm-fbx-ch{display:flex;flex-wrap:wrap;gap:6px}
.dm-fbx-ch .dm-chip.dm-on{background:var(--dm-sel);color:var(--dm-accent-fg,var(--dm-accent));box-shadow:none}
.dm-fbx-ta{display:block;width:100%;resize:none;border:0;outline:0;border-radius:var(--dm-r-field);background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line) 50%,transparent);padding:10px var(--dm-s-3);font:inherit;font-size:13.5px;line-height:1.5;color:var(--dm-fg);transition:box-shadow var(--dm-t-fast)}
@supports (corner-shape:squircle){.dm-fbx-ta{corner-shape:squircle;border-radius:var(--dm-r-field-sq)}}
.dm-fbx-ta:focus{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line-strong) 50%,transparent)}
.dm-fbx-ta::placeholder{color:var(--dm-muted)}
.dm-fbx-f{display:flex;align-items:center;gap:6px}
.dm-fbx-n{font-size:12px;flex:1;min-width:0}
.dm-fbx-thx{display:flex;align-items:center;gap:var(--dm-s-2);font-size:13px;padding:10px var(--dm-s-3);border-radius:var(--dm-r-in);background:var(--dm-ok-bg);color:var(--dm-ok)}
@media (max-width:480px){.dm-fbx-n{display:none}}
feedback-sheet.htmlHTML · 19 lines
<div class="card bare" data-c="feedback-sheet">
  <div class="stage-in">
    <div class="fbx">
      <p class="msg">I tightened the hero spacing to 24px and swapped the CTA to the accent blue. The card grid now wraps at 720px.</p>
      <div class="fbx-bar">
        <button class="ib fbx-up" aria-label="Good answer" data-tip="Good answer"><svg class="i s"><use href="#i-up-thumb"/></svg></button>
        <button class="ib fbx-dn" aria-label="Bad answer" data-tip="Bad answer"><svg class="i s"><use href="#i-down-thumb"/></svg></button>
        <span class="fbx-st muted"></span>
      </div>
      <div class="fold shut fbx-box"><div><div class="fbx-in panel keep">
        <div class="fbx-q">What went wrong?</div>
        <div class="fbx-ch"><button class="chip sm">Wrong spacing</button><button class="chip sm">Ignored my tokens</button><button class="chip sm">Too many changes</button><button class="chip sm">Broke the layout</button></div>
        <textarea class="fbx-ta" rows="2" placeholder="Anything else the agent should know"></textarea>
        <div class="fbx-f"><span class="muted fbx-n">Shared with the agent for this project</span><button class="pill ghost sm fbx-x">Cancel</button><button class="pill primary sm fbx-go" disabled>Send</button></div>
      </div></div></div>
    </div>
  </div>
</div>
feedback-sheet.cssHTML · 21 lines
/* ---------- Feedback sheet ---------- */
.fbx{display:flex;flex-direction:column;gap:var(--s-2)}
.fbx-bar{display:flex;align-items:center;gap:2px;margin-left:-6px}
.fbx-bar .ib svg{transition:transform .45s var(--ease)}
.fbx-bar .ib.on svg{transform:scale(1.12) rotate(-8deg)}
.fbx-dn.on{background:var(--bad-bg);color:var(--bad)}
.fbx-st{font-size:12.5px;margin-left:6px}
.fbx-box>div{margin-inline:-16px;padding-inline:var(--s-4)}
.fbx-in{padding:var(--s-4);display:flex;flex-direction:column;gap:10px;margin:var(--s-1) 0 18px}
.fbx-q{font-size:13.5px;font-weight:500}
.fbx-ch{display:flex;flex-wrap:wrap;gap:6px}
.fbx-ch .chip.on{background:var(--sel);color:var(--accent-fg,var(--accent));box-shadow:none}
.fbx-ta{display:block;width:100%;resize:none;border:0;outline:0;border-radius:var(--r-field);background:var(--surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line) 50%,transparent);padding:10px var(--s-3);font:inherit;font-size:13.5px;line-height:1.5;color:var(--fg);transition:box-shadow var(--t-fast)}
@supports (corner-shape:squircle){.fbx-ta{corner-shape:squircle;border-radius:var(--r-field-sq)}}
.fbx-ta:focus{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line-strong) 50%,transparent)}
.fbx-ta::placeholder{color:var(--muted)}
.fbx-f{display:flex;align-items:center;gap:6px}
.fbx-n{font-size:12px;flex:1;min-width:0}
.fbx-thx{display:flex;align-items:center;gap:var(--s-2);font-size:13px;padding:10px var(--s-3);border-radius:var(--r-in);background:var(--ok-bg);color:var(--ok)}
@media (max-width:480px){.fbx-n{display:none}}
feedback-sheet.jsHTML · 29 lines
DM.def('feedback-sheet', (r, c) => {
  r.innerHTML = r.innerHTML.replace('{up}', I('up-thumb')).replace('{dn}', I('down-thumb'));
  const up = $('.fbx-up', r), dn = $('.fbx-dn', r), box = $('.fbx-box', r), st = $('.fbx-st', r), go = $('.fbx-go', r), ta = $('.fbx-ta', r), chips = $$('.fbx-ch .chip', r);
  const ready = () => go.disabled = !(chips.some(x => x.classList.contains('on')) || ta.value.trim());
  chips.forEach(ch => ch.onclick = () => { ch.classList.toggle('on'); ready(); });
  ta.oninput = ready;
  up.onclick = () => { const on = !up.classList.contains('on'); up.classList.toggle('on', on); dn.classList.remove('on'); box.classList.add('shut'); swap(st, on ? 'Thanks, noted' : ''); };
  // the demo opens the sheet without taking the focus from wherever you are on the page
  const down = focus => { const on = !dn.classList.contains('on'); dn.classList.toggle('on', on); up.classList.remove('on'); box.classList.toggle('shut', !on); swap(st, ''); if (on && focus) c.later(350, () => ta.focus({ preventScroll:true })); };
  dn.onclick = e => down(e.isTrusted);
  $('.fbx-x', r).onclick = () => { box.classList.add('shut'); dn.classList.remove('on'); };
  go.onclick = () => c.run(async () => {
    go.innerHTML = '<span class="spin"></span>'; await c.wait(800);
    box.classList.add('shut'); await c.wait(300);
    const n = chips.filter(x => x.classList.contains('on')).length;
    const thx = html(`<div class="fbx-thx">${I('ok')}<span>Thanks. The agent will ${n ? 'avoid this' : 'use this'} next time in this project.</span></div>`);
    r.firstElementChild.appendChild(thx); enter(thx); swap(st, 'Feedback sent');
  });
  /* once it has entered, it rates the answer down and fills in the sheet by itself. Clicking, focusing or typing in it
     takes over, and what it had typed goes */
  c.run(async () => {
    await c.step(1500); down(false);
    await c.step(1000); chips[1].click();
    await c.step(700);
    await c.type(ta, 'Keep our 8px spacing scale', { ms:() => 34 + Math.random() * 40, each:ready, keep:true });
    await c.step(650); go.click();
  });
});
06

Questions

What people ask about it.

How do I save the feedback to my backend?
Pass an async onSubmit. The component shows a spinner while the promise is pending and closes the sheet only when it resolves. If it rejects, the sheet stays open.
Can I use my own reasons?
Yes. Pass reasons as a list of strings. Pass an empty list to hide the chips and keep only the comment box.
Does thumbs up open the sheet too?
No. Thumbs up closes the sheet if it was open and shows a short thanks. Only thumbs down opens it, unless you control open yourself.
07

More in Conversation

Everything for asking, answering and replying, by text or by voice.

Live answerAnswers that stream in with numbered sources and next steps.
ComposerMessage input with attachments, what to work on, a mode and dictation.
Thread panelSwitch between agent threads and reply in place.
Artifact cardA file the agent made, with its versions, Open, Copy and Download.
Rich text editorA minimal editor with an inline AI menu that streams a suggestion to accept or discard.
Tool callEach tool the agent calls, with its input and output.
Voice captureTalk to the agent, then fix what it misheard before sending.
Voice orbA hands-free voice chat, with an orb that shows who is talking.
All 15 in Conversation →