Feedback

Social Proof Toast

A rotating recent-activity notice: one entry every N seconds at most, paused by hover, focus and a hidden tab, with a mute control that reports back through a callback.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { BellOff, X } from "lucide-react"
import { cn } from "@/lib/utils"

/**
 * Entrance and exit are declared together on the card, the exit delayed by the
 * notice's own dwell. One declaration covers the whole life of a notice, so
 * there is no second timer that could drift away from the first.
 */
const KEYFRAMES = `@keyframes zspt-in{from{opacity:0;transform:translateY(10px) scale(0.98)}}
@keyframes zspt-out{to{opacity:0;transform:translateY(-8px) scale(0.98)}}`

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/social-proof-toast.json

Prompt

Build a React + TypeScript + Tailwind "Social Proof Toast" component (React 19, lucide-react,
cn from clsx + tailwind-merge). It is the rotating "Riley in Berlin just subscribed" widget,
built so it cannot be used to lie: it renders the entries it is handed, in the order it is
handed them, and never invents a name, a city or a counter.

Contract
- export const SocialProofToast = React.forwardRef<HTMLDivElement, SocialProofToastProps>(...)
  plus export interface SocialProofEntry. Remaining native div props spread onto the root.
- SocialProofEntry: id (string, React key only), name, action (past tense — "subscribed to
  Pro"), location?, at? (string | number | Date), avatarUrl?, href?.
- Props: entries (SocialProofEntry[]); interval = 9000 (ms — the frequency cap);
  visibleFor = 5200 (ms requested on screen); initialDelay = 4000 (0 allowed); loop = false;
  limit? (notices per session; non-positive or NaN means uncapped); paused = false;
  muted? (force-off, e.g. read back from storage); now? (string | number | Date);
  locale = "en"; showMute = true; muteLabel; mutedText; dismissLabel; label = "Recent
  activity"; onShow?(entry, index); onMute?().
- Time: the component reads no clock. Relative stamps are derived from `now` alone, so with no
  `now` — or an entry with no `at` — no stamp is rendered rather than a guessed one.
  performance.now() is used for scheduling only and is never rendered. A stamp in the future
  is clock skew, not a prediction: clamp it to zero, which Intl renders as "now".
- Guard every numeric prop: NaN and Infinity must never reach setTimeout. interval and
  visibleFor fall back to their defaults unless finite and > 0; initialDelay also accepts 0.

Behavior
- State machine: waiting -> showing -> waiting -> … One state object holds
  { stage, entry, cursor, shown, tick, total }. `tick` is bumped on every transition so
  exactly one timer effect re-arms per phase; `total` is that phase's length in ms.
- Cadence maths. dwell = max(visibleFor, floor), where
  floor = clamp(1200 + 55 * characters, 2600, 12000) over name + action + location — per
  character, not per word, so a Chinese or Japanese line is not counted as a single word.
  gap = max(interval - dwell, 1200). Therefore dwell + gap = max(dwell + 1200, interval): the
  cap is a floor a long notice cannot blow past, and a thousand entries speed nothing up.
- Four equal pause sources: pointer hover, focus anywhere inside, document.hidden
  (visibilitychange), and the `paused` prop. Pausing banks the remainder — the effect cleanup
  stores deadline - performance.now() — so hovering three times does not hand out three fresh
  dwells, and a hidden tab never burns the queue in an empty room.
- The entry on screen is held by value in state, so replacing or emptying `entries` mid-notice
  cannot yank the visible sentence out from under a reader.
- Running out is a first-class ending. When the cursor passes the last entry with loop = false,
  or `shown` reaches `limit`, the widget goes quiet permanently: no recycling, no shortened
  gap, no placeholder. Derive the next entry on every render instead of storing it, so
  appending to `entries` revives a quiet widget on the next render — that is how a live feed
  keeps it going honestly.
- Dismiss (the × button, or Escape while focus is inside) ends the notice on screen and moves
  to the gap: it shortens the notice, never the gap. One-shot guard — the tick it consumed is
  written to a ref synchronously inside the handler, so a double click cannot skip the gap and
  pull the next notice up early.
- Mute is one-way. A ref guard read and written inside the handler makes onMute() fire at most
  once; the rotation then stops for good and a short line replaces the card. Persisting that
  decision belongs to the consumer (localStorage, an account setting) and comes back in
  through `muted`, which is a force-off switch rather than a two-way controlled value — that
  way the in-widget button can never be dead.
- Focus discipline. Nothing is ever natively disabled. The card unmounts under the reader's
  hands only on × or Escape; when the button that vanished had :focus-visible, focus is handed
  one frame later to a deliberate successor — the muted line if there is one, otherwise the
  region itself, made focusable only while it is the landing spot, never <body>. That same
  frame re-derives both pause flags from the DOM: focus-within from document.activeElement and
  hover from :hover. The browser fires no focusout for a node that was removed and no
  pointerleave for a card that collapsed under a stationary cursor, and either stale flag
  would hang the rotation for good.
- Keyboard map: Tab / Shift+Tab reach the headline link (only when entry.href is set), the
  mute button and the dismiss button; focus alone pauses, so nothing can vanish on the way to
  a control. Escape closes the notice on screen and is swallowed only when there was one, so a
  surrounding dialog still closes on Escape the rest of the time. No gesture-only path exists:
  hover is a convenience, every action has a real button.
- Cleanup: the phase timeout is cleared on unmount and on every dependency change (banking the
  remainder as it goes), the pending focus rAF is cancelled on unmount, and the reduced-motion
  and visibilitychange subscriptions are torn down by useSyncExternalStore.

Rendering & styling
- Semantic tokens only: bg-popover / text-popover-foreground / border / shadow-lg for the card,
  bg-muted + text-muted-foreground for the initials avatar, text-muted-foreground for the meta
  line, accent + accent-foreground for control hover, ring for every focus ring. No hex, rgb()
  or oklch() anywhere; the only colour arriving from outside is a consumer-hosted avatar.
- Layout: one row — avatar, headline plus meta line, then the bell and × buttons. The root is
  w-full max-w-sm in normal flow; placement is the consumer's, e.g.
  className="fixed bottom-4 left-4 z-50". The avatar falls back to codepoint-safe initials, and
  falls back again on a load error, so a dead URL never leaves a broken image box.
- Animation: entrance and exit are declared together on the card, the exit delayed by
  max(260, dwell - 200) ms, so one declaration covers the whole life of a notice and there is
  no second timer to keep in sync with the first. animation-play-state is the single pause
  switch for the decoration and is flipped by the same render that banks the schedule. Under
  prefers-reduced-motion the inline animation is dropped entirely: notices swap instantly and
  every feature still works.
- ARIA: the root is role="region" with aria-label. The announcement is one composed sentence —
  "Riley subscribed to Pro. Berlin, DE. 2 minutes ago." — in a permanent sr-only
  role="status" aria-live="polite" aria-atomic="true" node, because a live region that mounts
  together with its content is not reliably announced, and wrapping the card itself would read
  the button names out with every notice. It empties between notices, which is what lets the
  same sentence be announced a second time instead of being swallowed as a no-change. The
  avatar is aria-hidden (the name is already in the text) and both controls are icon-only with
  aria-label.
- data-state on the root ("waiting" | "running" | "paused" | "idle" | "muted") and on the card,
  for consumer styling and for tests.

Customization levers
- Cadence: interval / visibleFor / initialDelay / loop / limit are the whole rhythm. A quiet
  marketing page wants interval 20000 with limit 3; a checkout can afford interval 6000 with
  visibleFor 3000. The 1200 ms gap floor and the reading-floor constants (1200 + 55 per
  character, clamped to 2600..12000) are the two numbers to retune for another language or a
  denser layout.
- Content: location, stamp and avatar are each optional and nothing shifts when they are
  absent. To add a product thumbnail or a verified mark, extend SocialProofEntry and render it
  beside the headline — and leave it out of the announced sentence unless it is worth reading
  aloud.
- Placement and skin: the root className owns corner, width and z-index; swap bg-popover for
  bg-card, or drop shadow-lg for a flatter surface. Rounding follows --radius.
- Motion: retune the two keyframes and the 260 / 200 ms durations; a fade with no travel is a
  one-line change. Never make the exit longer than the shortest possible dwell.
- Honesty, which is the actual product decision here: feed the queue from a real source
  (recent signups, orders, reviews), reduce names to a first name or "Someone", and drop the
  location rather than invent one. Fabricated urgency is what gave this pattern its
  reputation, and it is checkable by anyone who reads their own account page. If there is
  nothing to show, show nothing — an empty entries array renders nothing at all.

Concepts

  • Frequency cap — the promise is not "one entry per tick" but dwell + gap = max(dwell + 1200ms, interval). Time can only be added to a notice, never taken off the cadence, so handing the widget a thousand entries changes nothing about how often a reader is interrupted.
  • Reading-time floor — a notice stays up for at least 1200ms + 55ms per character, clamped to 2.6–12 s. Counting characters rather than words is what keeps a Chinese line from being treated as one word and yanked away half-read.
  • Banked remainder — every pause source stores deadline − performance.now() and every resume continues from it. Without banking, hovering to read a notice three times would hand out three fresh dwells and the cadence would quietly become a lie.
  • Quiet ending — an exhausted queue, a spent limit and an empty entries array all resolve to the same honest state: nothing on screen, nothing invented to fill it. The next entry is derived on every render, so a live feed can revive the widget just by appending.
  • Deliberate successor — the card can only vanish under a reader on × or Escape, and only then; the frame after it goes, focus is handed to the muted line or to the region itself rather than dropped on the document body. Both pause flags are re-read from the DOM in that same frame, because a removed node fires no focusout and a collapsed card fires no pointerleave — and a stale flag on either would freeze the rotation for good.
  • Announcement out of band — the composed sentence lives in a permanent screen-reader-only status node rather than on the card, because a live region that mounts at the same moment as its content is not reliably announced, and because a reader should hear the event, not the names of the two buttons next to it.

On This Page