Swish UI
All componentsFeedback and forms Free

Hearts stream

A heart button whose taps release hearts that sway as they rise and fade; holding it streams them.

It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.

Demo.tsx

"use client";
import { useEffect, useRef, useState, type MouseEvent, type PointerEvent } from "react";
import { useInView, usePrefersReducedMotion } from "@/lib/motion";
import styles from "./demo.module.css";

const HEART = "M12 21s-7.5-4.6-9.6-9.2C.9 8.4 3 4.5 6.7 4.5c2.1 0 3.6 1.1 5.3 3 1.7-1.9 3.2-3 5.3-3 3.7 0 5.8 3.9 4.3 7.3C19.5 16.4 12 21 12 21z";
const TONES = ["accent", "text", "soft", "accent", "ink"];
const HOLD_AFTER = 260; // ms before a press becomes a stream
const STREAM_EVERY = 110;
const AMBIENT_EVERY = 1500; // hearts from other viewers until you join in
const fmt = (n: number) => n.toLocaleString("en-US");

export default function Demo() {
  const root = useRef<HTMLDivElement>(null);
  const layer = useRef<HTMLDivElement>(null);
  const holdTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
  const streamTimer = useRef<ReturnType<typeof setInterval>>(undefined);
  const inView = useInView(root);
  const reduced = usePrefersReducedMotion();
  const [count, setCount] = useState(1284);
  const [holding, setHolding] = useState(false);
  const [joined, setJoined] = useState(false);

  // Each heart is a plain element that removes itself when its rise ends; no React state per heart.
  const spawn = (small = false) => {
    setCount((c) => c + 1);
    const box = layer.current;
    if (!box || reduced) return;
    const el = document.createElement("span");
    el.className = styles.heart;
    const size = small ? 14 + Math.random() * 6 : 20 + Math.random() * 12;
    el.dataset.tone = TONES[Math.floor(Math.random() * TONES.length)];
    el.style.setProperty("--size", `${size}px`);
    el.style.setProperty("--x", `${(Math.random() - 0.5) * 110}px`);
    el.style.setProperty("--sway", `${(Math.random() < 0.5 ? -1 : 1) * (8 + Math.random() * 16)}px`);
    el.style.setProperty("--rise", `${box.clientHeight - size}px`);
    el.style.setProperty("--dur", `${1.9 + Math.random() * 1.1}s`);
    el.style.setProperty("--swing", `${0.7 + Math.random() * 0.5}s`);
    el.innerHTML = `<span class="${styles.sway}"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="${HEART}"/></svg></span>`;
    el.addEventListener("animationend", (e) => { if (e.target === el) el.remove(); });
    box.append(el);
  };

  // Until the visitor joins in, other viewers send the odd heart so the demo is never still.
  useEffect(() => {
    if (reduced || joined || !inView) return;
    const timer = setInterval(() => spawn(true), AMBIENT_EVERY);
    return () => clearInterval(timer);
  }, [reduced, joined, inView]);

  const stop = () => {
    clearTimeout(holdTimer.current);
    clearInterval(streamTimer.current);
    setHolding(false);
  };
  useEffect(() => () => { clearTimeout(holdTimer.current); clearInterval(streamTimer.current); }, []);

  const down = (e: PointerEvent<HTMLButtonElement>) => {
    if (e.button !== 0) return;
    setJoined(true);
    spawn();
    holdTimer.current = setTimeout(() => {
      setHolding(true);
      streamTimer.current = setInterval(() => spawn(), STREAM_EVERY);
    }, HOLD_AFTER);
  };
  // Pointer presses spawn on pointerdown; a click with no pointer detail is the keyboard.
  const click = (e: MouseEvent<HTMLButtonElement>) => {
    if (e.detail !== 0) return;
    setJoined(true);
    spawn();
  };

  return (
    <div ref={root} className={styles.root}>
      <div className={styles.column}>
        <div ref={layer} className={styles.layer} aria-hidden="true">
          {reduced && [0, 1, 2].map((i) => (
            <span key={i} className={styles.still} data-i={i}>
              <svg viewBox="0 0 24 24"><path d={HEART} /></svg>
            </span>
          ))}
        </div>
        <button
          type="button"
          className={styles.button}
          data-holding={holding || undefined}
          aria-label={`Send a heart, ${fmt(count)} so far`}
          onPointerDown={down}
          onPointerUp={stop}
          onPointerLeave={stop}
          onPointerCancel={stop}
          onClick={click}
          onContextMenu={(e) => e.preventDefault()}
        >
          <svg className={styles.icon} viewBox="0 0 24 24" aria-hidden="true"><path d={HEART} /></svg>
          <span className={styles.count}>{fmt(count)}</span>
        </button>
        <p className={styles.hint}>Tap to like, hold to stream</p>
      </div>
    </div>
  );
}

demo.module.css

.root {
  container-type: size; position: relative; display: grid; place-items: end center; width: 100%; height: 100%; padding: 0 16px 22px;
  box-sizing: border-box; overflow: hidden; background: radial-gradient(120% 80% at 50% 100%, var(--accent-pale), var(--surface) 70%);
}
.column { position: relative; display: flex; flex-direction: column; align-items: center; gap: 10px; }
/* Hearts start at the button's centre and rise through this column. */
.layer { position: absolute; left: 50%; bottom: 52px; width: 240px; height: min(380px, calc(100cqh - 90px)); translate: -50% 0; pointer-events: none; }

.button {
  position: relative; z-index: 1; display: inline-flex; align-items: center; gap: 8px; min-height: 48px; padding: 0 20px 0 16px;
  border-radius: 999px; border: 1px solid var(--line); background: var(--white); color: var(--ink); cursor: pointer;
  font: inherit; font-size: 15px; font-weight: 700; touch-action: none; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none;
  box-shadow: 0 10px 24px -16px color-mix(in srgb, var(--ink) 60%, transparent);
  transition: transform .18s var(--out), border-color .2s;
}
.button:hover { border-color: var(--accent-soft); }
.button:active { transform: scale(.94); }
.button:focus-visible { outline: 2px solid var(--accent-text); outline-offset: 3px; }
.button[data-holding] { border-color: var(--accent); animation: pulse .32s var(--out) infinite alternate; }
.button[data-holding]::after {
  content: ""; position: absolute; inset: -1px; border-radius: inherit; border: 2px solid var(--accent); pointer-events: none;
  animation: ring .7s var(--out) infinite;
}
@keyframes pulse { from { transform: scale(.96); } to { transform: scale(1.06); } }
@keyframes ring { from { opacity: .8; scale: 1; } to { opacity: 0; scale: 1.35 1.6; } }
.icon { width: 22px; height: 22px; fill: var(--accent); }
.button:active .icon, .button[data-holding] .icon { fill: var(--accent-text); }
.count { min-width: 5ch; text-align: left; font-variant-numeric: tabular-nums; }
.hint { margin: 0; font-size: 12px; color: var(--muted); }

/* One heart: the outer span rises and fades, the inner span sways side to side on its own clock. */
.heart {
  position: absolute; left: 50%; bottom: 0; width: var(--size); height: var(--size); margin-left: calc(var(--size) / -2);
  animation: rise var(--dur) cubic-bezier(.3, .6, .4, 1) forwards;
}
.sway { display: block; width: 100%; height: 100%; animation: sway var(--swing) ease-in-out infinite alternate; }
.heart svg, .still svg { display: block; width: 100%; height: 100%; }
.heart[data-tone="accent"] svg { fill: var(--accent); }
.heart[data-tone="text"] svg { fill: var(--accent-text); }
.heart[data-tone="soft"] svg { fill: var(--accent-soft); }
.heart[data-tone="ink"] svg { fill: var(--ink); }
@keyframes rise {
  0% { opacity: 0; transform: translate(0, 0) scale(.3); }
  12% { opacity: 1; transform: translate(calc(var(--x) * .3), calc(var(--rise) * -.08)) scale(1.1); }
  22% { transform: translate(calc(var(--x) * .5), calc(var(--rise) * -.18)) scale(1); }
  70% { opacity: .9; }
  100% { opacity: 0; transform: translate(var(--x), calc(var(--rise) * -1)) scale(.85); }
}
@keyframes sway {
  from { transform: translateX(calc(var(--sway) * -1)) rotate(-8deg); }
  to { transform: translateX(var(--sway)) rotate(8deg); }
}

/* Reduced motion: three still hearts above the button stand in for the stream. */
.still { position: absolute; left: 50%; width: 22px; height: 22px; fill: var(--accent); }
.still[data-i="0"] { bottom: 18%; translate: -150% 0; opacity: .9; }
.still[data-i="1"] { bottom: 40%; translate: 20% 0; opacity: .6; width: 18px; height: 18px; fill: var(--accent-text); }
.still[data-i="2"] { bottom: 62%; translate: -80% 0; opacity: .35; width: 16px; height: 16px; }

@container (max-height: 300px) {
  .root { padding-bottom: 14px; }
  .column { gap: 6px; }
  .button { min-height: 44px; }
  .layer { bottom: 44px; height: calc(100cqh - 70px); }
}
@media (prefers-reduced-motion: reduce) {
  .button[data-holding], .button[data-holding]::after { animation: none; }
  .button { transition: none; }
}

Use it for

  • Live streams, launches and events where reactions pile up
  • Appreciation buttons where tapping again should feel good
  • Social features that show a crowd reacting in real time

Avoid

  • Binary like or unlike toggles, where a count of one per person matters
  • Dense feeds, where many buttons streaming at once get noisy

How it works

Each heart is a plain span appended to a layer above the button, with random size, drift, sway and duration set as CSS variables, and it removes itself on animationend. The outer span rises and fades while an inner span sways on its own alternating keyframes, so the path curves without any frame loop. A press spawns one heart and, held past 260ms, starts an interval that streams them while the button pulses.

Accessibility

The button is a real button whose name includes the running count; Enter and Space send a heart each and Enter repeats when held. Ambient hearts stop off screen. Reduced motion shows three still hearts and only updates the count.

Found on