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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/social-proof-toast.jsonPrompt
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 + 55msper 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
limitand 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.
Progress Toast
A toast bound to one long job — determinate or indeterminate progress, a live percentage in its accessible name, cancel while it runs, and terminal states that stay long enough to read with a retry on failure.
Exit Intent
A leave-detector and its surface — a pointer crossing the top edge or a fast scroll-up opens a dismissable payload once per session, and never over a field somebody is typing into.