# Feedback toast

> A confirmation toast that asks how it went, with three faces: the one you pick jumps out and lands in the thanks.

A toast confirms the agreement was sent and asks how it went. Hover a face and it acts its part: the frown deepens, the neutral face glances aside, the hearts beat. Pick one and it leaps out of the toast through a burst of sparkles, while the toast reshapes into a thanks that offers a comment field.

A React component by Bruno Delorence: https://brunodelorence.com/components/feedback-toast. MIT License, Copyright (c) 2026 Bruno Delorence.

Category: Feedback.

## Anatomy

1. **Toast**: One pill, clipped to the width of what it shows; when a face is chosen the clip and the dismiss button move on a spring (bounce 0.18).
2. **Title**: What just happened, in one line. It names the toast for screen readers.
3. **Question**: The question under it, muted. It labels the faces.
4. **Faces**: Three faces in a radio group: one tab stop, the arrow keys move, Enter or Space chooses.
5. **Hovered face**: Lifts, scales to 1.07 and gets a gold ring; each face acts its part (the frown deepens, the eyes glance, the hearts beat).
6. **Dismiss**: A 44 px target at the pill’s right end, wherever the clip puts it. It hides the toast and calls `onDismiss`.
7. **Page behind**: The made-up agreement the toast just confirmed, and its green foot. Everything scales with the stage’s width.

## When to use

- Right after a task the person cares about is done (a contract sent, an order placed), to ask how it went while it is fresh.
- A one-tap rating in three steps, where a full survey would be too much and a thumbs up too little.
- When an optional comment is welcome but must never be in the way of moving on.
- Over the page that just changed, as a pill above its foot, so the result stays in view.

## When not to use

- After every small action: asked too often, ratings turn into noise. Ask once per meaningful task.
- To report an error or something the person must act on. Use an alert or an inline message that stays.
- For a precise score (NPS, 1 to 10) or several questions. Use a short form or a survey page.
- When the confirmation alone is enough. Use a plain toast with no question.
- On a screen under 300 px wide: the toast scales down as one, and the faces get too small to tap.

## Installation

Install it with the shadcn CLI, into components/feedback-toast of any React project:

```sh
npx shadcn@latest add https://brunodelorence.com/r/feedback-toast.json
```

Or copy all the source files below into one folder of your project. Either way it needs React 19.2 and a bundler with CSS Modules (Next.js, Vite).

It has a dark colour scheme. Its colours are CSS light-dark() pairs, kept inside @supports so that builds which rewrite light-dark() for older browsers leave them alone: it follows your page’s color-scheme (light unless the page sets one) or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

## Usage

```tsx
import { FeedbackToast } from './feedback-toast';

export function AgreementSent() {
  return (
    <FeedbackToast
      title="Agreement sent successfully!"
      question="How was your experience?"
      onRate={(rating) => {
        // 'sad', 'neutral' or 'love': send it to your analytics.
      }}
      onComment={(comment, rating) => {
        // Store the comment with its rating.
      }}
      autoplay={false}
    />
  );
}
```

## States

- **Asking**: The title, the question and three faces; nothing chosen yet.
- **Face hovered**: Ringed and lifted. The love face’s hearts keep beating: that loop runs as long as it is hovered.
- **Thanks**: The chosen face lands in the thanks after a jump of 900 ms through sparkles; the pill narrows to fit.
- **Comment field**: Opens from the thanks, as wide as it. Send waits for words; Escape goes back.
- **Comment typed**: Send fills in. Enter or the arrow sends it with the rating to `onComment`.
- **Comment sent**: The thanks again, with Comment sent in place of the offer. Focus moves to Dismiss.
- **Dismissed**: The toast sinks away and only the page is left. Nothing brings it back: the host decides.

## How it works

- The toast is one pill as wide as its widest content, clipped with clip-path to the width of what it shows. Its contents share a grid cell and cross-fade, and the clip, the dismiss button and the contents move on one spring, written as a CSS linear() easing.
- The faces are drawn for it: a yellow disc shaded by a CSS gradient, under a small SVG of features. On hover the stylesheet moves each feature: the frown scales deeper, the neutral face’s eyes glance in a keyframe loop, the hearts beat and the mouth opens.
- A chosen face jumps on an arc computed once and played by the Web Animations API: 25 keyframes of a parabola in time, so it slows as it rises and speeds up as it falls, growing by a third at the top and tilting as it goes. It leaves an empty slot behind and lands exactly where the thanks keeps its place.
- At the top of the arc ten sparkles, stars and dots in three yellows, fly out and fade. Their elements are always in the page, invisible at rest, so nothing is created during the jump.
- The whole scene scales with one unit, --u, derived from the stage’s own width with a container query unit, so the toast keeps its proportions from a phone to a wide screen. The demo moves a pointer drawn like a presenter’s cursor: it hovers each face, presses the last, waits through the thanks and starts over.
- Every colour is a light-dark() token on the root, so it follows the page’s color-scheme, or the colorScheme prop: a white pill with a hairline and a soft shadow by day, a dark grey one with a lighter hairline at night. The pill’s clip-path would cut a shadow, so the hairline and the shadow are shapes behind it, clipped and moved on the same spring; the faces keep their yellow in both.

## API reference

- **title** (`string`, default `'Agreement sent successfully!'`): What just happened: the toast’s first line.
- **question** (`string`, default `'How was your experience?'`): The question under it.
- **thanks** (`string`, default `'Thank you for your feedback!'`): The thanks, once a face is chosen.
- **defaultState** (`FeedbackToastState`): Opens still, with the demo off: phase ('ask', 'thanks', 'comment' or 'sent'), choice (the face chosen, 'love' by default), hover (a face drawn hovered), comment (the words typed) and shown (false: already dismissed).
- **onRate** (`(rating: 'sad' | 'neutral' | 'love') => void`): Called with the face the reader chose.
- **onComment** (`(comment: string, rating: Rating) => void`): Called with the comment the reader sent, and their rating.
- **onDismiss** (`() => void`): Called when the reader dismisses the toast.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer hovers the faces, picks one, and starts over.
- **colorScheme** (`'light' | 'dark' | 'system'`): Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
- **className** (`string`): Extra class for the root, a stage of its own.

## Accessibility

- The toast is a group named by its title. The faces are a radiogroup named by the question, with radios “Sad”, “Neutral” and “Love it”: one tab stop, the arrow keys move between them, and Enter or Space chooses.
- The choice and a sent comment are announced in a polite status region; the demo’s own choices are not.
- Focus follows the reader: from the faces to “Click to add a comment”, into the field, to Dismiss once the comment is sent, and to the stage when the toast is dismissed. Escape closes the field.
- Under reduced motion there is no demo, no jump and no sparkles: the toast changes at once. On phones the comment field keeps 16px text so the browser does not zoom in on it.

## Source

### feedback-toast/feedback-toast.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import {
  useEffect,
  useEffectEvent,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent,
} from 'react';
import { DemoPointer, runDemo } from './demo-pointer';
import { Face, RATINGS, type Rating } from './faces';
import { spring } from './spring';
import { useAutoplay } from './use-autoplay';
import styles from './feedback-toast.module.css';

/*
 * Feedback toast: a toast that confirms an action and asks, with three faces, how it went.
 *
 * - Hovering a face lifts it, rings it and plays its expression: the frown deepens, the neutral
 *   face glances aside, the hearts beat (CSS, in faces.tsx and the stylesheet).
 * - Choosing a face sends it jumping out of the toast, on an arc played by the Web Animations
 *   API, through a burst of sparkles, and down into its place in the thanks message. Meanwhile
 *   the toast morphs: the pill is one fixed shape, clipped to the width of its content, and
 *   the clip, the dismiss button and the contents move on a spring.
 * - The thanks message offers a comment: a field opens in the toast, and Enter sends it.
 * - The faces are a radio group: the arrow keys move between them, Enter or Space chooses. What
 *   the reader does is announced in a status region.
 * - While on screen and untouched, a demo pointer hovers the faces, picks one, and starts over.
 * - Its colours are light-dark() pairs in the stylesheet: it follows the page's color-scheme, or
 *   the one the colorScheme prop sets. By day a white pill over a white page, at night a dark one
 *   over a dark page; the faces, the sparkles and the green foot stay as they are.
 */

type Phase = 'ask' | 'thanks' | 'comment' | 'sent';

/** Where a toast opens, for a still picture of one of its moments (docs, tests). */
export type FeedbackToastState = {
  /** Asking, thanking, writing a comment, or the comment sent. Default 'ask'. */
  phase?: Phase;
  /** The face chosen, past the asking; 'love' when left out. */
  choice?: Rating;
  /** A face drawn as hovered, while asking. */
  hover?: Rating;
  /** What is typed in the comment field. */
  comment?: string;
  /** false: already dismissed. Default true. */
  shown?: boolean;
};

/** The toast's change of shape: a spring with a little bounce. */
const MORPH = spring(0.18, 520);
/** The jump, in ms, how high it goes (in toast heights), and how much the face grows on the way. */
const JUMP = 900;
const JUMP_HEIGHT = 2.05;
const JUMP_GROW = 0.35;
/** The toast changes as the face starts down, this long into the jump (ms). */
const MORPH_AT = 520;

/** The sparkles: where each one flies (an angle in degrees, a distance in faces) and what it is. */
const SPARKS: readonly { angle: number; reach: number; kind: 'star' | 'dot'; tone: number }[] = [
  { angle: -100, reach: 1.7, kind: 'star', tone: 0 },
  { angle: -40, reach: 1.4, kind: 'star', tone: 0 },
  { angle: -150, reach: 1.25, kind: 'dot', tone: 2 },
  { angle: 10, reach: 1.45, kind: 'dot', tone: 1 },
  { angle: 165, reach: 1.6, kind: 'star', tone: 1 },
  { angle: -70, reach: 1.1, kind: 'dot', tone: 0 },
  { angle: 60, reach: 1.3, kind: 'star', tone: 2 },
  { angle: 125, reach: 1.15, kind: 'dot', tone: 0 },
  { angle: -15, reach: 2, kind: 'dot', tone: 2 },
  { angle: -125, reach: 2.1, kind: 'dot', tone: 1 },
];

/** The table behind the toast, a made-up agreement. */
const TERMS: readonly (readonly [string, string])[] = [
  ['Start date', '1 March 2027'],
  ['Annual salary', '120,000 €'],
  ['Vacation days per year', '30'],
  ['Remote work', 'Yes'],
];

const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;

const labelOf = (rating: Rating) => RATINGS.find((item) => item.id === rating)?.label ?? rating;

export function FeedbackToast({
  title = 'Agreement sent successfully!',
  question = 'How was your experience?',
  thanks = 'Thank you for your feedback!',
  onRate,
  onComment,
  onDismiss,
  autoplay = true,
  colorScheme,
  defaultState,
  className = '',
}: {
  /** What just happened: the toast's first line. */
  title?: string;
  /** The question under it. */
  question?: string;
  /** The thanks, once a face is chosen. */
  thanks?: string;
  /** Called with the face the reader chose. */
  onRate?: (rating: Rating) => void;
  /** Called with the comment the reader sent, and their rating. */
  onComment?: (comment: string, rating: Rating) => void;
  /** Called when the reader dismisses the toast. */
  onDismiss?: () => void;
  /** While on screen and untouched, a demo pointer hovers the faces, picks one, and starts over. */
  autoplay?: boolean;
  /** Its colours: light, dark, or 'system' for the reader's setting; unset, it follows the page. */
  colorScheme?: 'light' | 'dark' | 'system';
  /** Opens in this state, still: the demo pointer stays off. */
  defaultState?: FeedbackToastState;
  /** Extra class for the root, a stage of its own. */
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const [phase, setPhase] = useState<Phase>(defaultState?.phase ?? 'ask');
  const [shown, setShown] = useState(defaultState?.shown ?? true);
  const [choice, setChoice] = useState<Rating | null>(
    defaultState?.phase && defaultState.phase !== 'ask' ? (defaultState.choice ?? 'love') : null,
  );
  /** The chosen face is in the air: its slot is empty, and its place in the thanks waits. */
  const [flying, setFlying] = useState(false);
  const [comment, setComment] = useState(defaultState?.comment ?? '');
  /** The face the keyboard is on (the radio group's one tab stop). */
  const [current, setCurrent] = useState(0);
  /** The demo's hover and press, shown as a pointer's would be. */
  const [demoHover, setDemoHover] = useState<Rating | null>(defaultState?.hover ?? null);
  const [demoPress, setDemoPress] = useState<Rating | null>(null);
  const [message, setMessage] = useState('');
  /** Keyboard focus is on the stage: the demo leaves it alone. */
  const [focused, setFocused] = useState(false);
  const titleId = useId();
  const questionId = useId();

  const scene = useRef<HTMLDivElement>(null);
  const toast = useRef<HTMLDivElement>(null);
  const layers = useRef<Record<'ask' | 'thanks', HTMLDivElement | null>>({
    ask: null,
    thanks: null,
  });
  const options = useRef<Partial<Record<Rating, HTMLButtonElement | null>>>({});
  const facesRow = useRef<HTMLDivElement>(null);
  const landing = useRef<HTMLSpanElement>(null);
  const flyer = useRef<HTMLSpanElement>(null);
  const sparks = useRef<(HTMLSpanElement | null)[]>([]);
  const pointer = useRef<HTMLSpanElement>(null);
  const commentButton = useRef<HTMLButtonElement>(null);
  const field = useRef<HTMLInputElement>(null);
  const close = useRef<HTMLButtonElement>(null);
  const timers = useRef(new Set<number>());
  const motion = useRef<Animation[]>([]);
  /** Where focus goes once the next phase is on the page. */
  const focusNext = useRef<'comment' | 'field' | 'close' | 'stage' | null>(null);

  /** Repeats are made to differ, so screen readers announce them again. */
  const announce = (text: string) => setMessage((now) => (now === text ? `${text}\u00a0` : text));

  const later = (action: () => void, ms: number) => {
    const timer = window.setTimeout(() => {
      timers.current.delete(timer);
      action();
    }, ms);
    timers.current.add(timer);
  };

  /** Stops the jump and the sparkles, wherever they are. */
  const stopMotion = () => {
    motion.current.forEach((animation) => animation.cancel());
    motion.current = [];
    timers.current.forEach((timer) => window.clearTimeout(timer));
    timers.current.clear();
  };

  // The width of each content, for the pill's clip and the dismiss button's place: measured
  // once laid out, and again when the stage's size or the fonts change.
  useLayoutEffect(() => {
    const element = toast.current;
    if (!element) return;
    const measure = () => {
      const ask = layers.current.ask?.offsetWidth ?? 0;
      const thanksWidth = layers.current.thanks?.offsetWidth ?? 0;
      const widest = Math.max(ask, thanksWidth);
      element.style.setProperty('--shift-ask', `${(widest - ask) / 2}px`);
      element.style.setProperty('--shift-thanks', `${(widest - thanksWidth) / 2}px`);
      element.style.setProperty('--thanks-width', `${thanksWidth}px`);
    };
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(element);
    let alive = true;
    void document.fonts.ready.then(() => {
      if (alive) measure();
    });
    return () => {
      alive = false;
      observer.disconnect();
    };
  }, [title, question, thanks]);

  // Focus follows the reader into each new phase.
  useEffect(() => {
    const target = focusNext.current;
    focusNext.current = null;
    if (target === 'comment') commentButton.current?.focus();
    else if (target === 'field') field.current?.focus();
    else if (target === 'close') close.current?.focus();
    else if (target === 'stage') root.current?.focus();
  }, [phase, shown, root]);

  useEffect(() => () => stopMotion(), []);

  /** In the scene's own px, the box of `element` (the scene may sit in a scaled container). */
  const boxOf = (element: Element) => {
    const frame = scene.current?.getBoundingClientRect();
    const scale = frame && scene.current ? frame.width / scene.current.offsetWidth || 1 : 1;
    const box = element.getBoundingClientRect();
    return {
      x: (box.left - (frame?.left ?? 0)) / scale,
      y: (box.top - (frame?.top ?? 0)) / scale,
      size: box.width / scale,
    };
  };

  /** The face leaps from its slot to its place in the thanks, through a burst of sparkles. */
  const jump = (rating: Rating) => {
    const row = facesRow.current;
    const face = flyer.current;
    if (!row || !face || !landing.current || !toast.current) return;
    // The slot is read from the row of faces, which no hover or press moves or scales, and the
    // landing from the thanks, which waits in place, unscaled.
    const faces = boxOf(row);
    const b = boxOf(landing.current);
    const size = b.size;
    const index = RATINGS.findIndex((item) => item.id === rating);
    const a = { x: faces.x + (index * (faces.size - size)) / (RATINGS.length - 1), y: faces.y };
    const lift = JUMP_HEIGHT * toast.current.offsetHeight;
    const at = (t: number) => ({
      x: a.x + (b.x - a.x) * t,
      y: a.y + (b.y - a.y) * t - lift * 4 * t * (1 - t),
    });
    // A parabola in time: it slows as it rises and speeds up as it falls, as a thrown thing does.
    const keyframes: Keyframe[] = [];
    for (let i = 0; i <= 24; i += 1) {
      const t = i / 24;
      const { x, y } = at(t);
      // From pressed (0.9) up to JUMP_GROW larger at the top, and down to its size.
      const grow = 1 + JUMP_GROW * Math.sin(Math.PI * t) - 0.1 * (1 - t) ** 3;
      const tilt = -9 * Math.sin(2 * Math.PI * t);
      keyframes.push({ transform: `translate(${x}px, ${y}px) scale(${grow}) rotate(${tilt}deg)` });
    }
    motion.current.push(face.animate(keyframes, { duration: JUMP, fill: 'forwards' }));

    // The sparkles burst around the top of the arc, as the face gets there.
    const top = at(0.5);
    sparks.current.forEach((spark, index) => {
      if (!spark) return;
      const { angle, reach } = SPARKS[index];
      const radians = (angle * Math.PI) / 180;
      const x = top.x + size / 2 + Math.cos(radians) * reach * size;
      const y = top.y + size / 2 + Math.sin(radians) * reach * size;
      const start = `translate(${top.x + size / 2}px, ${top.y + size / 2}px)`;
      motion.current.push(
        spark.animate(
          [
            { transform: `${start} scale(0.2) rotate(0deg)`, opacity: 0 },
            { opacity: 1, offset: 0.2 },
            { transform: `translate(${x}px, ${y}px) scale(1) rotate(${angle / 3}deg)`, opacity: 0 },
          ],
          {
            duration: 680,
            delay: JUMP * 0.32 + index * 12,
            easing: 'cubic-bezier(0.15, 0.7, 0.3, 1)',
            fill: 'both',
          },
        ),
      );
    });
  };

  /** A face is chosen: it jumps, the toast thanks, the reader hears it. */
  const choose = (rating: Rating, byReader: boolean) => {
    if (phase !== 'ask' || choice) return;
    setChoice(rating);
    onRate?.(rating);
    if (byReader) {
      announce(`${labelOf(rating)}. ${thanks}`);
      if (toast.current?.contains(document.activeElement)) focusNext.current = 'comment';
    }
    if (reducedMotion()) {
      setPhase('thanks');
      return;
    }
    setFlying(true);
    jump(rating);
    later(() => setPhase('thanks'), MORPH_AT);
    later(() => setFlying(false), JUMP);
    // The face has landed and the last sparkle faded: their animations let go of the elements.
    later(() => {
      motion.current.forEach((animation) => animation.cancel());
      motion.current = [];
    }, JUMP + 300);
  };

  /** Back to a fresh toast: the demo's way to start over. */
  const reset = () => {
    stopMotion();
    setPhase('ask');
    setChoice(null);
    setFlying(false);
    setComment('');
    setCurrent(0);
    setDemoHover(null);
    setDemoPress(null);
    setShown(true);
  };

  const dismiss = () => {
    if (!shown) return;
    if (toast.current?.contains(document.activeElement)) focusNext.current = 'stage';
    setShown(false);
    onDismiss?.();
  };

  const send = () => {
    const text = comment.trim();
    if (!text || !choice) return;
    onComment?.(text, choice);
    focusNext.current = 'close';
    setPhase('sent');
    announce('Comment sent.');
  };

  const onFacesKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    const last = RATINGS.length - 1;
    const moves: Partial<Record<string, number>> = {
      ArrowRight: current === last ? 0 : current + 1,
      ArrowDown: current === last ? 0 : current + 1,
      ArrowLeft: current === 0 ? last : current - 1,
      ArrowUp: current === 0 ? last : current - 1,
      Home: 0,
      End: last,
    };
    const next = moves[event.key];
    if (next === undefined) return;
    event.preventDefault();
    setCurrent(next);
    options.current[RATINGS[next].id]?.focus();
  };

  // The demo: the pointer hovers each face in turn, presses the last one, lets the thanks show,
  // and starts over with a fresh toast.
  const readState = useEffectEvent(() => ({ phase, shown }));
  const demoAct = useEffectEvent((act: 'reset' | 'hide' | Rating | null, press = false) => {
    if (act === 'reset') reset();
    else if (act === 'hide') {
      setDemoHover(null);
      setShown(false);
    } else if (press) setDemoPress(act);
    else {
      setDemoPress(null);
      setDemoHover(act);
    }
  });
  const demoChoose = useEffectEvent((rating: Rating) => choose(rating, false));
  const demo = autoplay && !defaultState && playing && !focused;
  useEffect(() => {
    const stage = scene.current;
    const arrow = pointer.current;
    if (!demo || !stage || !arrow) return;
    const stop = runDemo(stage, arrow, async ({ wait, moveTo, moveToPoint, press }) => {
      await wait(900);
      for (let turn = 0; ; turn += 1) {
        // A toast left answered or dismissed makes way for a fresh one.
        const state = readState();
        if (turn > 0 || state.phase !== 'ask' || !state.shown) {
          demoAct('hide');
          await wait(450);
          demoAct('reset');
          await wait(700);
        }
        for (const { id } of RATINGS) {
          // The tip comes from below, to the face's lower edge.
          await moveTo(options.current[id], { x: 0.56, y: 1.02 });
          demoAct(id);
          await wait(id === 'love' ? 800 : 1100);
        }
        await press(
          () => demoAct('love', true),
          () => {
            demoAct(null);
            demoChoose('love');
          },
        );
        await wait(650);
        // Aside, below the toast's end, and inside the stage.
        const end = close.current ? boxOf(close.current) : null;
        if (end) {
          await moveToPoint({
            x: Math.min(end.x + end.size * 1.6, stage.offsetWidth - 28),
            y: Math.min(end.y + end.size * 2.2, stage.offsetHeight - 28),
          });
        }
        await wait(2600);
      }
    });
    return () => {
      stop();
      setDemoHover(null);
      setDemoPress(null);
    };
  }, [demo]);

  // The face the thanks shows (laid out before any choice, so the toast can measure it).
  const chosen = choice ?? 'love';

  return (
    <div
      ref={root}
      className={`${styles.stage} ${className}`}
      data-color-scheme={colorScheme}
      tabIndex={-1}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
      onFocus={(event) => {
        if (event.target.matches(':focus-visible')) setFocused(true);
      }}
      onBlur={(event) => {
        if (!event.currentTarget.contains(event.relatedTarget)) setFocused(false);
      }}
    >
      <div
        ref={scene}
        className={styles.scene}
        style={{ '--spring': MORPH.easing, '--spring-ms': `${MORPH.duration}ms` } as CSSProperties}
      >
        <div className={styles.app} aria-hidden="true" data-part="backdrop">
          <div className={styles.terms}>
            {TERMS.map(([term, value]) => (
              <div key={term} className={styles.term}>
                <span>{term}</span>
                <span>{value}</span>
              </div>
            ))}
          </div>
          <div className={styles.band} />
        </div>

        <div
          className={styles.dock}
          data-part="dock"
          data-shown={shown || undefined}
          inert={!shown}
        >
          <div
            ref={toast}
            className={styles.toast}
            data-part="toast"
            data-phase={phase}
            role="group"
            aria-labelledby={titleId}
          >
            <span className={styles.pill} aria-hidden="true" />

            <div
              ref={(element) => {
                layers.current.ask = element;
              }}
              className={`${styles.layer} ${styles.ask}`}
              data-active={phase === 'ask' || undefined}
              inert={phase !== 'ask'}
            >
              <div className={styles.text}>
                <p id={titleId} className={styles.title} data-part="title">
                  {title}
                </p>
                <p id={questionId} className={styles.subtitle} data-part="question">
                  {question}
                </p>
              </div>
              <div
                ref={facesRow}
                className={styles.faces}
                data-part="faces"
                role="radiogroup"
                aria-labelledby={questionId}
                onKeyDown={onFacesKeyDown}
              >
                {RATINGS.map(({ id, label }, index) => (
                  <button
                    key={id}
                    ref={(element) => {
                      options.current[id] = element;
                    }}
                    type="button"
                    role="radio"
                    aria-checked={choice === id}
                    aria-label={label}
                    tabIndex={index === current ? 0 : -1}
                    className={styles.option}
                    data-part="face"
                    data-hover={demoHover === id || undefined}
                    data-pressed={demoPress === id || undefined}
                    data-gone={choice === id || undefined}
                    onFocus={() => setCurrent(index)}
                    onClick={() => choose(id, true)}
                  >
                    <Face rating={id} />
                  </button>
                ))}
              </div>
            </div>

            <div
              ref={(element) => {
                layers.current.thanks = element;
              }}
              className={`${styles.layer} ${styles.thanks}`}
              data-part="thanks"
              data-active={phase === 'thanks' || phase === 'sent' || undefined}
              data-waiting={flying || undefined}
              inert={phase !== 'thanks' && phase !== 'sent'}
            >
              <span ref={landing} className={styles.landing}>
                <Face rating={chosen} excited />
              </span>
              <div className={styles.text} data-centered="">
                <p className={styles.title}>{thanks}</p>
                <div className={styles.follow} data-sent={phase === 'sent' || undefined}>
                  <button
                    ref={commentButton}
                    type="button"
                    className={styles.more}
                    data-part="add-comment"
                    tabIndex={phase === 'thanks' ? 0 : -1}
                    onClick={() => {
                      focusNext.current = 'field';
                      setPhase('comment');
                    }}
                  >
                    Click to add a comment
                    <svg viewBox="0 0 8 12" aria-hidden="true">
                      <path d="m1.5 1 5 5-5 5" />
                    </svg>
                  </button>
                  <span className={styles.subtitle} aria-hidden={phase !== 'sent'}>
                    Comment sent
                  </span>
                </div>
              </div>
            </div>

            <form
              className={`${styles.layer} ${styles.comment}`}
              data-part="comment"
              data-active={phase === 'comment' || undefined}
              inert={phase !== 'comment'}
              onSubmit={(event) => {
                event.preventDefault();
                send();
              }}
            >
              <Face rating={chosen} excited />
              <span className={styles.field} data-part="field">
                <input
                  ref={field}
                  type="text"
                  value={comment}
                  placeholder="Add a comment…"
                  aria-label="Comment"
                  enterKeyHint="send"
                  autoComplete="off"
                  onChange={(event) => setComment(event.target.value)}
                  onKeyDown={(event) => {
                    if (event.key !== 'Escape') return;
                    event.preventDefault();
                    focusNext.current = 'comment';
                    setPhase('thanks');
                  }}
                />
                <button
                  type="submit"
                  className={styles.send}
                  data-part="send"
                  aria-label="Send comment"
                  disabled={!comment.trim()}
                >
                  <svg viewBox="0 0 16 16" aria-hidden="true">
                    <path d="M8 12.5v-9M4.2 7.2 8 3.4l3.8 3.8" />
                  </svg>
                </button>
              </span>
            </form>

            <button
              ref={close}
              type="button"
              className={styles.close}
              data-part="dismiss"
              aria-label="Dismiss"
              onClick={dismiss}
            >
              <svg viewBox="0 0 16 16" aria-hidden="true">
                <path d="m1.5 1.5 13 13m0-13-13 13" />
              </svg>
            </button>
          </div>
        </div>

        <span ref={flyer} className={styles.flyer} data-flying={flying || undefined}>
          <Face rating={chosen} excited />
        </span>
        <span className={styles.burst} aria-hidden="true">
          {SPARKS.map(({ kind, tone }, index) => (
            <span
              key={index}
              ref={(element) => {
                sparks.current[index] = element;
              }}
              className={styles.spark}
              data-kind={kind}
              data-tone={tone}
            >
              {kind === 'star' && (
                <svg viewBox="-5 -5 10 10">
                  <path d="M0-5C.5-1.2 1.2-.5 5 0 1.2.5.5 1.2 0 5-.5 1.2-1.2.5-5 0-1.2-.5-.5-1.2 0-5z" />
                </svg>
              )}
            </span>
          ))}
        </span>
        <DemoPointer ref={pointer} />
      </div>
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}

export type { Rating };
```

### feedback-toast/feedback-toast.module.css

```css
/* Feedback toast (feedback-toast.tsx). Every length is a multiple of --u, the stage's unit: 1px
   when the stage is wide enough for the toast at full size, less on a narrow one, so the whole
   scene shrinks as one. --spring and --spring-ms, the toast's change of shape, come from the
   component; --shift-ask, --shift-thanks and --thanks-width from its measures.

   Colours are the tokens below, each light-dark(light, dark): the component takes the
   color-scheme it inherits from the page (light unless the page says otherwise), or the one its
   colorScheme prop sets (data-color-scheme). By day the toast is a white pill with dark words, a
   hairline and a soft shadow over a white page; at night a dark grey pill with a lighter hairline
   over a dark one. The faces, the sparkles and the green foot are the same in both. */

.stage {
  /* The page behind the toast and the stage's edge; its values, its terms and the lines between
     them; the light strip under the green foot. */
  --surface: #fff;
  --edge: rgb(0 0 0 / 0.06);
  --ink: #111;
  --ink-muted: #3a3a3c;
  --line: #efefef;
  --foot: #d8d9de;
  /* The toast's pill, its hairline and its shadow (near and soft; none at night); a chosen face's
     empty place, and the comment field, a step off the pill; the field's placeholder. */
  --surface-2: #fff;
  --surface-2-edge: rgb(0 0 0 / 0.08);
  --toast-shadow-near: rgb(0 0 0 / 0.06);
  --toast-shadow: rgb(0 0 0 / 0.13);
  --slot: #ececef;
  --field: #f0f0f2;
  --placeholder: #6b6b70;
  /* The toast's words: the title, the field's text and a hovered link or dismiss; the question,
     the offer of a comment and the dismiss cross; under the pointer, the dismiss button's disc. */
  --toast-ink: #111;
  --toast-ink-muted: #6b6b70;
  --close: #6e6e73;
  --close-hover: rgb(0 0 0 / 0.05);
  /* The send button and its arrow; the ring round a hovered face; the focus rings. */
  --accent: #111;
  --accent-ink: #fff;
  --face-ring: #c58200;
  --focus: #0a7aff;
  position: relative;
  container-type: inline-size;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  outline: none;
  background: var(--surface);
  box-shadow: inset 0 0 0 1px var(--edge);
  color: var(--ink);
  font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', Inter, 'Segoe UI', sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .stage {
    --surface: light-dark(#fff, #111113);
    --edge: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.07));
    --ink: light-dark(#111, #f2f2f4);
    --ink-muted: light-dark(#3a3a3c, #b8b8bd);
    --line: light-dark(#efefef, #242427);
    --foot: light-dark(#d8d9de, #232326);
    --surface-2: light-dark(#fff, #232325);
    --surface-2-edge: light-dark(rgb(0 0 0 / 0.08), rgb(255 255 255 / 0.1));
    --toast-shadow-near: light-dark(rgb(0 0 0 / 0.06), transparent);
    --toast-shadow: light-dark(rgb(0 0 0 / 0.13), transparent);
    --slot: light-dark(#ececef, #343437);
    --field: light-dark(#f0f0f2, #333336);
    --placeholder: light-dark(#6b6b70, #a1a1a6);
    --toast-ink: light-dark(#111, #fff);
    --toast-ink-muted: light-dark(#6b6b70, #c9c9c9);
    --close: light-dark(#6e6e73, #b8b8b8);
    --close-hover: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.08));
    --accent: light-dark(#111, #fff);
    --accent-ink: light-dark(#fff, #181818);
    --face-ring: light-dark(#c58200, rgb(250 214 90 / 0.58));
    --focus: light-dark(#0a7aff, #4c9dff);
  }
}
/* The colorScheme prop. Without it nothing is set: color-scheme is inherited from the page. */
.stage[data-color-scheme='light'] {
  color-scheme: light;
}
.stage[data-color-scheme='dark'] {
  color-scheme: dark;
}
.stage[data-color-scheme='system'] {
  color-scheme: light dark;
}
/* The buttons start from the stage's text; :where() keeps this below every class. */
:where(.stage) button {
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  font: inherit;
  color: inherit;
}
:where(.stage) p {
  margin: 0;
}

.scene {
  --u: clamp(0.44px, (100cqi - 24px) / 482, 1px);
  position: relative;
  height: calc(477 * var(--u));
  -webkit-user-select: none;
  user-select: none;
}

/* The app behind the toast: a made-up agreement, and the page's green foot. */
.app {
  position: absolute;
  inset: 0;
}
.terms {
  padding-top: calc(20 * var(--u));
}
.term {
  display: grid;
  grid-template-columns: 1fr 1fr;
  align-items: center;
  height: calc(82 * var(--u));
  padding: 0 calc(40 * var(--u));
  border-bottom: 1px solid var(--line);
  font-size: calc(16 * var(--u));
  color: var(--ink-muted);
}
.term span:last-child {
  padding-left: calc(25 * var(--u));
  font-size: calc(19 * var(--u));
  font-weight: 600;
  color: var(--ink);
}
.band {
  position: absolute;
  right: 0;
  bottom: 0;
  left: 0;
  height: calc(70 * var(--u));
  background: linear-gradient(
    #24440f 0 calc(24 * var(--u)),
    #376a1e 0 calc(42 * var(--u)),
    var(--foot) 0
  );
}

/* The dock holds the toast above the green foot; the toast rises in and sinks away. */
.dock {
  position: absolute;
  bottom: calc(96 * var(--u));
  left: 50%;
  opacity: 0;
  visibility: hidden;
  transform: translateX(-50%) translateY(calc(18 * var(--u))) scale(0.96);
  transition:
    opacity 0.2s ease,
    transform 0.25s ease-in,
    visibility 0s linear 0.25s;
}
.dock[data-shown] {
  opacity: 1;
  visibility: visible;
  transform: translateX(-50%);
  transition:
    opacity 0.3s ease,
    transform var(--spring-ms) var(--spring),
    visibility 0s;
}

/* The toast: one pill, clipped to the width of what it shows. The contents share one grid cell,
   so the toast is as wide as the widest; each phase brings its own to the front. Behind the pill,
   the same shape a pixel larger is its hairline, clipped and moved as the pill is; and under
   both, by day, its shadow: a box as wide as the clip leaves the pill, moved on the same spring. */
.toast {
  --shift: var(--shift-ask, 0px);
  position: relative;
  display: grid;
  height: calc(74 * var(--u));
  color: var(--toast-ink);
}
.toast:is([data-phase='thanks'], [data-phase='comment'], [data-phase='sent']) {
  --shift: var(--shift-thanks, 0px);
}
.toast::before,
.pill {
  position: absolute;
  inset: 0;
  background: var(--surface-2);
  clip-path: inset(0 var(--shift) round 999px);
  transition: clip-path var(--spring-ms) var(--spring);
}
.toast::before {
  inset: -1px;
  background: var(--surface-2-edge);
  content: '';
}
.toast::after {
  position: absolute;
  z-index: -1;
  top: 0;
  right: var(--shift);
  bottom: 0;
  left: var(--shift);
  border-radius: 999px;
  box-shadow:
    0 calc(1 * var(--u)) calc(3 * var(--u)) var(--toast-shadow-near),
    0 calc(10 * var(--u)) calc(26 * var(--u)) var(--toast-shadow);
  content: '';
  transition:
    left var(--spring-ms) var(--spring),
    right var(--spring-ms) var(--spring);
}
.dock:not([data-shown]) :is(.pill, .layer, .close),
.dock:not([data-shown]) .toast::before,
.dock:not([data-shown]) .toast::after {
  transition: none;
}

.layer {
  position: relative;
  grid-area: 1 / 1;
  justify-self: center;
  display: flex;
  align-items: center;
  height: 100%;
  opacity: 0;
  visibility: hidden;
  transform: scale(0.97);
  transition:
    opacity 0.18s ease,
    transform 0.25s ease,
    visibility 0s linear 0.25s;
}
.layer[data-active] {
  opacity: 1;
  visibility: visible;
  transform: none;
  transition:
    opacity 0.3s ease 0.1s,
    transform var(--spring-ms) var(--spring) 0.06s,
    visibility 0s;
}

.text {
  display: grid;
  white-space: nowrap;
}
.text[data-centered] {
  justify-items: center;
  text-align: center;
}
.title {
  font-size: calc(19 * var(--u));
  font-weight: 500;
  line-height: 1.16;
  letter-spacing: -0.005em;
}
.subtitle,
.more {
  font-size: calc(16.5 * var(--u));
  line-height: 1.16;
  color: var(--toast-ink-muted);
}

/* Asking: the message, the three faces, and room for the dismiss button. */
.ask {
  padding: 0 calc(62 * var(--u)) 0 calc(41 * var(--u));
}
.faces {
  display: flex;
  gap: calc(12 * var(--u));
  margin-left: calc(21 * var(--u));
}
.option {
  position: relative;
  display: block;
  width: calc(28 * var(--u));
  height: calc(28 * var(--u));
  border-radius: 50%;
  cursor: pointer;
  transition: transform 0.35s var(--spring);
}
/* The ring around a hovered face, with a sliver of the toast between. */
.option::after {
  content: '';
  position: absolute;
  inset: calc(-2.6 * var(--u));
  border: calc(1.4 * var(--u)) solid var(--face-ring);
  border-radius: 50%;
  opacity: 0;
  transform: scale(0.88);
  transition:
    opacity 0.2s ease,
    transform 0.35s var(--spring);
}
.option[data-hover] {
  transform: translateY(calc(-1.5 * var(--u))) scale(1.07);
}
.option[data-hover]::after {
  opacity: 1;
  transform: none;
}
@media (hover: hover) {
  .option:hover {
    transform: translateY(calc(-1.5 * var(--u))) scale(1.07);
  }
  .option:hover::after {
    opacity: 1;
    transform: none;
  }
}
.option:is(:active, [data-pressed]) {
  transform: scale(0.88);
  transition-duration: 0.12s;
}
.option:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: calc(4 * var(--u));
}
/* A face that has jumped out leaves its place empty. */
.option[data-gone] {
  transform: none;
  background: var(--slot);
}
.option[data-gone] > .face,
.option[data-gone]::after {
  visibility: hidden;
}

/* Thanking: the chosen face, the thanks, the offer of a comment. It only fades, unscaled, so
   the jumping face lands exactly in its place. */
.thanks,
.thanks[data-active] {
  padding: 0 calc(73.75 * var(--u)) 0 calc(28.6 * var(--u));
  transform: none;
}
.thanks .text {
  margin-left: calc(26.25 * var(--u));
}
.landing {
  display: block;
}
/* Its face is still in the air. */
.thanks[data-waiting] .landing {
  visibility: hidden;
}
.follow {
  display: grid;
}
.follow > * {
  grid-area: 1 / 1;
  transition: opacity 0.25s ease;
}
.follow[data-sent] .more,
.follow:not([data-sent]) .subtitle {
  opacity: 0;
  visibility: hidden;
}
.more {
  display: inline-flex;
  align-items: center;
  gap: calc(9 * var(--u));
  cursor: pointer;
  transition: color 0.2s ease;
}
.more svg {
  width: calc(8 * var(--u));
  height: calc(12 * var(--u));
  fill: none;
  stroke: currentColor;
  stroke-width: 1.9;
  stroke-linecap: round;
  stroke-linejoin: round;
}
@media (hover: hover) {
  .more:hover {
    color: var(--toast-ink);
  }
}
.more:focus-visible {
  border-radius: calc(5 * var(--u));
  outline: 2px solid var(--focus);
  outline-offset: 1px;
}

/* Commenting: the face and a field, as wide as the thanks. */
.comment {
  width: var(--thanks-width, auto);
  margin: 0;
  padding: 0 calc(66 * var(--u)) 0 calc(28.6 * var(--u));
}
.field {
  display: flex;
  flex: 1;
  align-items: center;
  min-width: 0;
  height: calc(42 * var(--u));
  margin-left: calc(20 * var(--u));
  padding: 0 calc(6 * var(--u)) 0 calc(16 * var(--u));
  border-radius: 999px;
  background: var(--field);
  transition: box-shadow 0.2s ease;
}
.field:focus-within {
  box-shadow: 0 0 0 2px var(--focus);
}
.field input {
  flex: 1;
  min-width: 0;
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  outline: none;
  color: var(--toast-ink);
  /* 16px at least: a smaller field makes phones zoom in on it. */
  font: inherit;
  font-size: max(16px, calc(16.5 * var(--u)));
  -webkit-user-select: text;
  user-select: text;
}
.field input::placeholder {
  color: var(--placeholder);
}
.send {
  display: grid;
  flex: none;
  place-items: center;
  width: calc(30 * var(--u));
  height: calc(30 * var(--u));
  border-radius: 50%;
  background: var(--accent);
  color: var(--accent-ink);
  cursor: pointer;
  transition: opacity 0.2s ease;
}
.send:disabled {
  opacity: 0.3;
  cursor: default;
}
.send svg {
  width: calc(15 * var(--u));
  height: calc(15 * var(--u));
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.send:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* Dismiss: at the pill's right end, wherever its clip puts it. */
.close {
  position: absolute;
  z-index: 1;
  top: 50%;
  right: calc(10 * var(--u));
  display: grid;
  place-items: center;
  width: calc(44 * var(--u));
  height: calc(44 * var(--u));
  margin-top: calc(-22 * var(--u));
  border-radius: 50%;
  color: var(--close);
  cursor: pointer;
  transform: translateX(calc(var(--shift) * -1));
  transition:
    transform var(--spring-ms) var(--spring),
    color 0.2s ease,
    background-color 0.2s ease;
}
.close svg {
  width: calc(19.5 * var(--u));
  height: calc(19.5 * var(--u));
  fill: none;
  stroke: currentColor;
  stroke-width: 1.9;
  stroke-linecap: round;
}
@media (hover: hover) {
  .close:hover {
    background: var(--close-hover);
    color: var(--toast-ink);
  }
}
.close:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: -2px;
}

/* A face: a yellow disc, lit from above, under its features (faces.tsx). */
.face {
  display: block;
  width: calc(28 * var(--u));
  height: calc(28 * var(--u));
  border-radius: 50%;
  background: radial-gradient(circle at 50% 30%, #fcd94a 0%, #f6c12f 50%, #e99d28 100%);
  box-shadow: inset 0 calc(-1.2 * var(--u)) calc(1.6 * var(--u)) rgb(176 98 0 / 0.28);
}
.face svg {
  display: block;
  width: 100%;
  height: 100%;
  overflow: visible;
}
.eyes {
  fill: #3b2507;
}
.frown,
.flat {
  fill: none;
  stroke: #3b2507;
  stroke-width: 2.3;
  stroke-linecap: round;
  transform-box: fill-box;
  transition: transform 0.35s var(--spring);
}
.frown {
  transform-origin: 50% 100%;
}
.flat {
  transform-origin: 50% 50%;
}
.heart {
  fill: #f2645f;
  transform-box: fill-box;
  transform-origin: 50% 50%;
  transition: transform 0.3s var(--spring);
}
.smile {
  fill: #3b2507;
  transform-box: fill-box;
  transform-origin: 50% 0;
  transform: scale(0.62);
  transition: transform 0.35s var(--spring);
}
.tongue {
  fill: #f17a84;
  opacity: 0;
  transition: opacity 0.2s ease;
}

/* Hovered, each face acts its part; a chosen one keeps the look. */
:is(.option[data-hover], .face[data-excited]) .frown {
  transform: scale(1.12, 1.35);
}
:is(.option[data-hover], .face[data-excited]) .flat {
  transform: translateX(0.4px) rotate(-8deg);
}
.option[data-hover] [data-face='neutral'] .eyes {
  animation: glance 1.8s ease-in-out infinite;
}
.option[data-hover] .heart {
  animation: beat 0.8s ease-in-out infinite;
}
:is(.option[data-hover], .face[data-excited]) .smile {
  transform: none;
}
:is(.option[data-hover], .face[data-excited]) .tongue {
  opacity: 1;
}
.face[data-excited] .heart {
  transform: scale(1.18);
}
@media (hover: hover) {
  .option:hover .frown {
    transform: scale(1.12, 1.35);
  }
  .option:hover .flat {
    transform: translateX(0.4px) rotate(-8deg);
  }
  .option:hover [data-face='neutral'] .eyes {
    animation: glance 1.8s ease-in-out infinite;
  }
  .option:hover .heart {
    animation: beat 0.8s ease-in-out infinite;
  }
  .option:hover .smile {
    transform: none;
  }
  .option:hover .tongue {
    opacity: 1;
  }
}
@keyframes glance {
  0%,
  100% {
    transform: none;
  }
  18%,
  42% {
    transform: translateX(1.7px);
  }
  58%,
  82% {
    transform: translateX(-1.7px);
  }
}
@keyframes beat {
  0%,
  100% {
    transform: scale(1.1);
  }
  30% {
    transform: scale(1.32);
  }
  60% {
    transform: scale(1.16);
  }
}

/* The chosen face in flight, and its sparkles: placed by their animations. */
.flyer,
.spark {
  position: absolute;
  top: 0;
  left: 0;
  pointer-events: none;
}
.flyer {
  z-index: 5;
  opacity: 0;
}
.flyer[data-flying] {
  opacity: 1;
}
.burst {
  position: absolute;
  inset: 0;
  z-index: 4;
  pointer-events: none;
}
.spark {
  width: calc(10 * var(--u));
  height: calc(10 * var(--u));
  margin: calc(-5 * var(--u)) 0 0 calc(-5 * var(--u));
  opacity: 0;
  color: #f7da5c;
}
.spark[data-tone='1'] {
  color: #f8c43c;
}
.spark[data-tone='2'] {
  color: #f39b38;
}
.spark[data-kind='dot'] {
  width: calc(5 * var(--u));
  height: calc(5 * var(--u));
  margin: calc(-2.5 * var(--u)) 0 0 calc(-2.5 * var(--u));
  border-radius: 50%;
  background: currentColor;
}
.spark svg {
  display: block;
  width: 100%;
  height: 100%;
  fill: currentColor;
}

/* The demo's pointer: its box is the tip; the arrow hangs from it. */
.pointer {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 10;
  width: 0;
  height: 0;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.3s ease;
}
.pointer svg {
  position: absolute;
  top: calc(-1 * var(--u));
  left: calc(-9 * var(--u));
  width: calc(32 * var(--u));
  height: calc(32 * var(--u));
  overflow: visible;
  transform-origin: calc(9 * var(--u)) calc(1 * var(--u));
}

/* For screen readers only. */
.hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@media (prefers-reduced-motion: reduce) {
  .dock,
  .toast::before,
  .toast::after,
  .pill,
  .layer,
  .close,
  .option,
  .option::after,
  .follow > *,
  .frown,
  .flat,
  .heart,
  .smile,
  .tongue {
    transition: none;
  }
  .option .eyes,
  .option .heart {
    animation: none;
  }
}
```

### feedback-toast/faces.tsx

```tsx
import styles from './feedback-toast.module.css';

/*
 * The three faces: a yellow disc, shaded by a CSS gradient, under an SVG of its features. Each
 * feature has a class the stylesheet moves while the face is hovered: the sad face's frown
 * deepens, the neutral one glances aside, the third one's hearts beat and its mouth opens wide.
 * `excited` draws that last look for good, as the face jumps out of the toast.
 */

export type Rating = 'sad' | 'neutral' | 'love';

/** The faces in order, with the names screen readers say. */
export const RATINGS: readonly { id: Rating; label: string }[] = [
  { id: 'sad', label: 'Sad' },
  { id: 'neutral', label: 'Neutral' },
  { id: 'love', label: 'Love it' },
];

/** A heart around (0, 0), about 8.6 wide. */
const HEART =
  'M0-1.9C-.9-4.1-4.3-3.9-4.3-1.1-4.3 1.2-1.6 2.9 0 4.4 1.6 2.9 4.3 1.2 4.3-1.1 4.3-3.9.9-4.1 0-1.9z';

function Eyes() {
  return (
    <g className={styles.eyes}>
      <ellipse cx="12.7" cy="13.8" rx="1.55" ry="2.25" />
      <ellipse cx="23.3" cy="13.8" rx="1.55" ry="2.25" />
    </g>
  );
}

export function Face({
  rating,
  excited = false,
  className = '',
}: {
  rating: Rating;
  /** The love face's hovered look, kept: hearts large, mouth wide open. */
  excited?: boolean;
  className?: string;
}) {
  return (
    <span
      className={`${styles.face} ${className}`}
      data-face={rating}
      data-excited={excited || undefined}
      aria-hidden="true"
    >
      <svg viewBox="0 0 36 36">
        {rating === 'sad' && (
          <>
            <Eyes />
            <path className={styles.frown} d="M11.6 25Q18 19.2 24.4 25" />
          </>
        )}
        {rating === 'neutral' && (
          <>
            <Eyes />
            <path className={styles.flat} d="M13.4 23.4h9.2" />
          </>
        )}
        {rating === 'love' && (
          <>
            <g transform="translate(12.3 13.4)">
              <path className={styles.heart} d={HEART} />
            </g>
            <g transform="translate(23.7 13.4)">
              <path className={styles.heart} d={HEART} />
            </g>
            <g className={styles.smile}>
              <path d="M12.5 21h11c0 3.4-2.5 6-5.5 6s-5.5-2.6-5.5-6z" />
              <path
                className={styles.tongue}
                d="M14.6 25.3c1.1-1.2 2.3-1.7 3.4-1.7s2.3.5 3.4 1.7c-1 1.1-2.1 1.6-3.4 1.6s-2.4-.5-3.4-1.6z"
              />
            </g>
          </>
        )}
      </svg>
    </span>
  );
}
```

### feedback-toast/demo-pointer.tsx

```tsx
'use client';

import { useId, type Ref } from 'react';
import styles from './feedback-toast.module.css';

/*
 * The demo's pointer, and the timeline that moves it: a script of awaited steps (wait, glide onto
 * an element, press). Each glide is a curved flight played by the Web Animations API. Stopping the
 * demo (the reader takes over, or it scrolls away) ends the step in progress at once: its promise
 * rejects, which ends the script right there, and the pointer fades out.
 */

export type Point = { x: number; y: number };

export type Demo = {
  /** Resolves after `ms`. */
  wait: (ms: number) => Promise<void>;
  /** Glides the tip onto `target`, at a point given as shares of its size (its middle by default). */
  moveTo: (target: Element | null | undefined, at?: Point) => Promise<void>;
  /** Glides the tip to a point in the stage's own px. */
  moveToPoint: (to: Point) => Promise<void>;
  /** Presses and lets go; `down` runs as the pointer goes down, `up` as it comes back up. */
  press: (down: () => void, up: () => void) => Promise<void>;
};

/** What a stopped demo's pending step rejects with. */
class Stopped extends Error {}

/** Plays `script` with the pointer over `stage`; returns the function that stops it. */
export function runDemo(
  stage: HTMLElement,
  pointer: HTMLElement,
  script: (demo: Demo) => Promise<void>,
): () => void {
  let alive = true;
  let flight: Animation | null = null;
  const timers = new Set<number>();
  const pending = new Set<() => void>();
  // The pointer comes in from the lower right.
  let at: Point = { x: stage.offsetWidth * 0.82, y: stage.offsetHeight * 0.96 };
  const place = ({ x, y }: Point) => `translate(${x}px, ${y}px)`;

  /** Settles like `promise`, or rejects as soon as the demo stops. */
  const step = (promise: Promise<unknown>) =>
    new Promise<void>((resolve, reject) => {
      if (!alive) return reject(new Stopped());
      const cancel = () => reject(new Stopped());
      pending.add(cancel);
      promise.then(
        () => {
          pending.delete(cancel);
          if (alive) resolve();
        },
        () => {
          pending.delete(cancel);
          reject(new Stopped());
        },
      );
    });

  const wait = (ms: number) =>
    step(
      new Promise<void>((resolve) => {
        const timer = window.setTimeout(() => {
          timers.delete(timer);
          resolve();
        }, ms);
        timers.add(timer);
      }),
    );

  const moveToPoint: Demo['moveToPoint'] = (to) => {
    const from = at;
    at = to;
    const dx = to.x - from.x;
    const dy = to.y - from.y;
    // A gentle curve, as a hand moves: the control point sits off the straight line.
    const bend = { x: (from.x + to.x) / 2 - dy * 0.16, y: (from.y + to.y) / 2 + dx * 0.16 };
    const keyframes: Keyframe[] = [];
    for (let i = 0; i <= 12; i += 1) {
      const t = i / 12;
      const u = 1 - t;
      keyframes.push({
        transform: place({
          x: u * u * from.x + 2 * u * t * bend.x + t * t * to.x,
          y: u * u * from.y + 2 * u * t * bend.y + t * t * to.y,
        }),
      });
    }
    flight?.cancel();
    // Where it rests once the flight is over.
    pointer.style.transform = place(to);
    flight = pointer.animate(keyframes, {
      duration: Math.min(950, Math.max(380, Math.hypot(dx, dy) * 1.5)),
      easing: 'cubic-bezier(0.45, 0, 0.2, 1)',
    });
    return step(flight.finished);
  };

  const moveTo: Demo['moveTo'] = (target, share = { x: 0.5, y: 0.5 }) => {
    if (!target) return step(Promise.resolve());
    const frame = stage.getBoundingClientRect();
    // Screen pixels to the stage's own, should the stage sit in a scaled container.
    const scale = frame.width / stage.offsetWidth || 1;
    const box = target.getBoundingClientRect();
    return moveToPoint({
      x: (box.left + box.width * share.x - frame.left) / scale,
      y: (box.top + box.height * share.y - frame.top) / scale,
    });
  };

  const press: Demo['press'] = async (down, up) => {
    const arrow = pointer.firstElementChild;
    const dip = arrow?.animate(
      [{ transform: 'none' }, { transform: 'scale(0.84)', offset: 0.4 }, { transform: 'none' }],
      { duration: 300, easing: 'ease-in-out' },
    );
    await wait(110);
    down();
    await wait(120);
    up();
    if (dip) await step(dip.finished);
  };

  pointer.style.transform = place(at);
  pointer.style.opacity = '1';
  script({ wait, moveTo, moveToPoint, press }).catch((error: unknown) => {
    if (!(error instanceof Stopped)) throw error;
  });

  return () => {
    if (!alive) return;
    alive = false;
    timers.forEach((timer) => window.clearTimeout(timer));
    flight?.cancel();
    pending.forEach((cancel) => cancel());
    pointer.style.opacity = '0';
  };
}

/** The arrow: its tip at (9, 1), pointing up, over a hollowed base. */
const ARROW = 'M9 1 29.5 24.6Q17.6 19.4 2.4 29.6z';

/**
 * The arrow the demo moves around: orange, lit from the top, with a darker lower edge and a soft
 * shadow. Its box is the tip, which the timeline places; the drawing hangs from it and follows
 * the stage's scale. It is decorative: the demo's effects are shown, not announced.
 */
export function DemoPointer({ ref }: { ref: Ref<HTMLSpanElement> }) {
  const fill = `pointer-${useId().replace(/[^\w-]/g, '')}`;
  return (
    <span ref={ref} className={styles.pointer} aria-hidden="true">
      <svg viewBox="0 0 32 32">
        <defs>
          <linearGradient id={fill} x1="0.2" y1="0" x2="0.7" y2="1">
            <stop offset="0" stopColor="#ffd07c" />
            <stop offset="0.55" stopColor="#f7aa48" />
            <stop offset="1" stopColor="#ec9030" />
          </linearGradient>
        </defs>
        {/* The shadow, then the darker lower edge, then the arrow itself. */}
        <path d={ARROW} fill="rgb(52 40 90 / 0.3)" transform="translate(0.8 2.6)" />
        <path d={ARROW} fill="#b4621c" transform="translate(0 1.6)" />
        <path
          d={ARROW}
          fill={`url(#${fill})`}
          stroke="#f5a23f"
          strokeWidth="1.4"
          strokeLinejoin="round"
        />
      </svg>
    </span>
  );
}
```

### feedback-toast/spring.ts

```tsx
/*
 * Springs as CSS easings. A spring pulls a value to its target and lets it settle: fast at first,
 * then slowing down, overshooting a little and coming back if it is bouncy. CSS `linear()` draws
 * any curve through a list of points, so the spring's motion is sampled once, and the same curve
 * serves CSS transitions and the Web Animations API.
 */

export type Spring = {
  /** The curve, as a CSS `linear()` easing. */
  easing: string;
  /** How long it takes to settle, in ms: the duration to play the curve over. */
  duration: number;
};

/** Points sampled along the curve: plenty for a smooth motion. */
const SAMPLES = 40;

/**
 * A spring that covers most of the distance in about `response` ms. With a `bounce` of 0 it
 * settles without overshooting; at 0.3 it overshoots by about 5% and comes back.
 */
export function spring(bounce: number, response: number): Spring {
  // The damping ratio (1: no overshoot) and the natural frequency, in radians per second.
  const damping = 1 - bounce;
  const omega = (2 * Math.PI * 1000) / response;
  const position = (t: number) => {
    if (damping >= 1) return 1 - Math.exp(-omega * t) * (1 + omega * t);
    const wobble = omega * Math.sqrt(1 - damping * damping);
    const decay = Math.exp(-damping * omega * t);
    return 1 - decay * (Math.cos(wobble * t) + ((damping * omega) / wobble) * Math.sin(wobble * t));
  };
  // Settled: from then on, within 0.1% of the target.
  let settled = 0;
  for (let t = 0; t < 3; t += 0.002) if (Math.abs(1 - position(t)) > 0.001) settled = t;
  const points = Array.from({ length: SAMPLES + 1 }, (_, i) =>
    i === SAMPLES ? 1 : Math.round(position((i / SAMPLES) * settled) * 1e4) / 1e4,
  );
  return { easing: `linear(${points.join(', ')})`, duration: Math.round(settled * 1000) };
}
```

### feedback-toast/use-autoplay.ts

```tsx
'use client';

import { useCallback, useEffect, useRef, useState } from 'react';

/*
 * When a demo may play on its own: while part of it is on screen and the tab is visible, never
 * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it
 * takes back over `idle` ms after their last input.
 */
export function useAutoplay<T extends HTMLElement>(idle = 6000) {
  const ref = useRef<T>(null);
  const [visible, setVisible] = useState(false);
  const [reduced, setReduced] = useState(true);
  const [taken, setTaken] = useState(false);
  const timer = useRef(0);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;
    let onScreen = false;
    const update = () => setVisible(onScreen && !document.hidden);
    const observer = new IntersectionObserver(
      ([entry]) => {
        onScreen = entry.isIntersecting;
        update();
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => setReduced(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', readMotion);
    };
  }, []);

  const take = useCallback(() => {
    setTaken(true);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setTaken(false), idle);
  }, [idle]);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  return { ref, take, playing: visible && !reduced && !taken };
}
```