Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Feedback toast

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

feedback-toast/feedback-toast.tsx
Live · interactive

The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.

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.

You can add this component with the shadcn CLI or by hand:

Into components/feedback-toast of any React project, with no setup:

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

Either way it needs React 19.2 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: 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

agreement-sent.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}    />  );}

Each state below is a live instance, locked so you can study it.

The pieces it is built from, each with its measurements read live.

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.

  • 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.
PropTypeDefaultDescription
titlestring'Agreement sent successfully!'What just happened: the toast’s first line.
questionstring'How was your experience?'The question under it.
thanksstring'Thank you for your feedback!'The thanks, once a face is chosen.
defaultStateFeedbackToastState—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.
autoplaybooleantrueWhile 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.
classNamestring—Extra class for the root, a stage of its own.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

feedback-toast.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
feedback-toast.module.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);
faces.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 (
demo-pointer.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>;};
spring.ts
/* * 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;
use-autoplay.ts
'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;
  • 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.