← All particles

Coin Toss

Coins thrown up through the frame, turning far enough to show their edge.

celebrationplayfulenergetic18 particles · light · canvas-2d · automatic · finite
Variant

The canvas in this preview is the file shown here. The surrounding demo shell only provides context and is not part of the copied code.

285 lines · react only
import { useEffect, useRef } from "react";

/**
 * Vibary · Coin Toss
 *
 * Coins thrown up through the frame, tumbling, falling back out.
 *
 * The technique: at edge-on the coin shows its rim, not nothing. A disc
 * animated by scaling its width to |cos θ| disappears into a hairline
 * twice per turn and reads as a flat sticker being squashed. A real coin
 * has thickness, so the silhouette never gets narrower than the rim —
 * and the face ellipse slides across that rim and swaps sides as it
 * turns past edge-on, which is what makes the flip legible rather than
 * merely fast.
 *
 * The throw is specified by where it should peak, not by a velocity:
 * launch speed is derived from the box height, so the arc stays in frame
 * whatever size the container ends up.
 *
 * Self-contained: one canvas, no dependencies at all.
 * Works with zero props; tune via `count`, `faceColor`, `variant`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type CoinTossProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Coins in the toss. */
  count?: number;
  /** The lit face of the coin. */
  faceColor?: string;
  /** The rim and the shaded face — darker than `faceColor`. */
  edgeColor?: string;
  /** Fires once the last coin has left the frame. */
  onComplete?: () => void;
};

type VariantConfig = {
  /** How far up the box the arc peaks, as a fraction of its height. */
  rise: number;
  /** Sideways speed spread in px per second. */
  spread: number;
  /** Turns per second about the coin's own axis. */
  spin: number;
  /** Coin radius in px. */
  size: number;
  /** Seconds over which the coins leave. */
  stagger: number;
};

const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // A short toss that stays low and turns slowly enough to follow.
  subtle: { rise: 0.5, spread: 70, spin: 1.05, size: 11, stagger: 0.4 },
  // Clears most of the frame and flips about twice on the way. Default.
  default: { rise: 0.68, spread: 120, spin: 1.8, size: 13, stagger: 0.3 },
  // Thrown higher and wider, tumbling fast.
  playful: { rise: 0.8, spread: 180, spin: 2.6, size: 15, stagger: 0.22 },
};

const GRAVITY = 1150;

type Coin = {
  x: number;
  y: number;
  vx: number;
  vy: number;
  /** Rotation about the coin's own diameter — the flip. */
  flip: number;
  flipSpeed: number;
  /** Rotation in the plane of the screen, so they aren't all upright. */
  tilt: number;
  tiltSpeed: number;
  scale: number;
  /** Seconds before this one leaves the hand. */
  delay: number;
  live: boolean;
};

export default function CoinToss({
  variant = "default",
  count = 18,
  faceColor = "#E9CB84",
  edgeColor = "#A9812F",
  onComplete,
}: CoinTossProps) {
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  // The parent's callback is read through a ref, assigned in an effect
  // rather than during render, so an inline arrow can't restart the toss.
  const completeRef = useRef(onComplete);
  useEffect(() => {
    completeRef.current = onComplete;
  });

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const context = canvas.getContext("2d");
    if (!context) return;

    const config = VARIANTS[variant];
    const reduced =
      typeof window !== "undefined" &&
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    let width = 0;
    let height = 0;

    const resize = () => {
      const rect = canvas.getBoundingClientRect();
      const ratio = Math.min(window.devicePixelRatio || 1, 2);
      width = rect.width;
      height = rect.height;
      canvas.width = Math.max(1, Math.floor(width * ratio));
      canvas.height = Math.max(1, Math.floor(height * ratio));
      context.setTransform(ratio, 0, 0, ratio, 0, 0);
    };
    resize();

    const random = (min: number, max: number) => min + Math.random() * (max - min);

    const build = (): Coin[] => {
      // Solve for the launch speed that peaks at the wanted height:
      // v = sqrt(2 g h). Specifying the apex instead of the velocity is
      // what keeps the arc inside the box at any container size. The
      // per-coin variation is capped on the high side for the same
      // reason — the luckiest coin has to stay in frame too.
      const apex = Math.sqrt(2 * GRAVITY * height * config.rise);
      return Array.from({ length: count }, (_, index) => ({
        // Launched across most of the width rather than from one spot:
        // a narrow band plus a short flight is a clump, not a toss.
        x: width * random(0.16, 0.84),
        y: height + config.size * 2,
        vx: random(-config.spread, config.spread),
        vy: -apex * random(0.86, 1.06),
        flip: random(0, Math.PI * 2),
        flipSpeed: config.spin * Math.PI * 2 * random(0.75, 1.35) * (Math.random() < 0.5 ? -1 : 1),
        tilt: random(-0.5, 0.5),
        tiltSpeed: random(-0.7, 0.7),
        scale: random(0.82, 1.15),
        delay: (index / count) * config.stagger * random(0.6, 1.4),
        live: true,
      }));
    };

    let coins = build();

    const drawCoin = (coin: Coin) => {
      const radius = config.size * coin.scale;
      // Thickness in px. Small, but never zero — this is the whole point.
      const rim = radius * 0.17;
      const face = Math.cos(coin.flip);
      const turn = Math.sin(coin.flip);

      context.save();
      context.translate(coin.x, coin.y);
      context.rotate(coin.tilt);

      // Silhouette: the face's projected width plus the rim showing at
      // the turn. At edge-on this is all that is left, and it is a solid
      // bar of metal rather than a vanishing line.
      const silhouette = radius * Math.abs(face) + rim * Math.abs(turn);
      context.fillStyle = edgeColor;
      context.beginPath();
      context.ellipse(0, 0, silhouette, radius, 0, 0, Math.PI * 2);
      context.fill();

      // The lit face rides on top of the rim and crosses to the other
      // side as the coin turns past edge-on.
      const faceWidth = radius * Math.abs(face);
      if (faceWidth > 0.4) {
        context.fillStyle = faceColor;
        context.globalAlpha = face > 0 ? 1 : 0.72;
        context.beginPath();
        context.ellipse(-rim * turn, 0, faceWidth, radius, 0, 0, Math.PI * 2);
        context.fill();

        // One inner ring, only when the face is open enough to read it.
        if (faceWidth > radius * 0.45) {
          context.globalAlpha = (face > 0 ? 0.4 : 0.22) * Math.abs(face);
          context.strokeStyle = edgeColor;
          context.lineWidth = 1.1;
          context.beginPath();
          context.ellipse(-rim * turn, 0, faceWidth * 0.66, radius * 0.66, 0, 0, Math.PI * 2);
          context.stroke();
        }
        context.globalAlpha = 1;
      }

      context.restore();
    };

    // Reduced motion: the coins laid out along the arc they would fly,
    // each at a different point in its turn — including one edge-on, so
    // the thing that makes it a coin is the thing you see.
    if (reduced) {
      const still = () => {
        context.clearRect(0, 0, width, height);
        const shown = Math.min(count, 7);
        for (let index = 0; index < shown; index++) {
          const t = shown === 1 ? 0.5 : index / (shown - 1);
          coins[index].x = width * (0.16 + t * 0.68);
          coins[index].y = height * (0.74 - Math.sin(t * Math.PI) * 0.46);
          coins[index].flip = (index / shown) * Math.PI;
          coins[index].tilt = (t - 0.5) * 0.7;
          drawCoin(coins[index]);
        }
      };
      still();
      const onResizeStill = () => {
        resize();
        coins = build();
        still();
      };
      window.addEventListener("resize", onResizeStill);
      return () => window.removeEventListener("resize", onResizeStill);
    }

    let frame = 0;
    let elapsed = 0;
    let last = performance.now();
    let announced = false;

    const tick = (now: number) => {
      const delta = Math.min((now - last) / 1000, 0.05);
      last = now;
      elapsed += delta;
      context.clearRect(0, 0, width, height);

      let live = 0;
      for (const coin of coins) {
        if (!coin.live || elapsed < coin.delay) {
          if (coin.live) live++;
          continue;
        }
        coin.vy += GRAVITY * delta;
        coin.x += coin.vx * delta;
        coin.y += coin.vy * delta;
        coin.flip += coin.flipSpeed * delta;
        coin.tilt += coin.tiltSpeed * delta;

        if (coin.y - config.size * 2 > height && coin.vy > 0) {
          coin.live = false;
          continue;
        }
        live++;
        drawCoin(coin);
      }

      if (live === 0) {
        context.clearRect(0, 0, width, height);
        if (!announced) {
          announced = true;
          completeRef.current?.();
        }
        return;
      }
      frame = requestAnimationFrame(tick);
    };

    frame = requestAnimationFrame(tick);

    const onResize = () => {
      const previous = height;
      resize();
      // Follow the bottom edge so coins in flight keep their arc rather
      // than jumping to a new one.
      const shift = height - previous;
      for (const coin of coins) coin.y += shift;
    };
    window.addEventListener("resize", onResize);

    return () => {
      cancelAnimationFrame(frame);
      window.removeEventListener("resize", onResize);
    };
  }, [variant, count, faceColor, edgeColor]);

  return (
    <canvas
      ref={canvasRef}
      aria-hidden
      style={{ width: "100%", height: "100%", display: "block" }}
    />
  );
}

About this effect

A reward moment with weight to it — points credited, cashback earned, a bonus unlocked. At edge-on the coin shows its rim rather than nothing: a disc animated by scaling its width to the cosine of the turn vanishes into a hairline twice per revolution and reads as a squashed sticker, so here the silhouette never gets narrower than the coin's thickness and the lit face slides across that rim and swaps sides as it turns past edge-on. The throw is specified by where it should peak rather than by a velocity — launch speed is solved from the container height — so the arc stays in frame at any size.

Points creditedCashback earnedBonus unlockedReward reveal

Related effects