Coin Toss
Coins thrown up through the frame, turning far enough to show their edge.
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.
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.