8-Ball
A glossy pool-ball mascot, as any ball in the rack: it bounces, turns round to show its number, follows your pointer, and does a trick on every tap.
experimental
Try it
How it works
- Its face is stuck to the ball's surface and its number sticker to the back: each eye, the smile, and the sticker sit at a point on the sphere and are drawn squashed toward the centre as they turn away, so they slide across the curve and go round.
- Every ball in the set is a design: solids 1 to 8, stripes 9 to 15 (the band follows the ball's curve, seen from a little above), and the cue ball. Light balls get dark eyes. Eyes are round by default, or leaning pills.
- On its own it hovers and hops with squash and stretch, glances about, and every so often turns round to show its number. Each tap is the next trick: a big boing, a spin, a squish, a roll (with a clack), and a wink, each with a synthesized blip.
- EightBall is exported on its own: pass ball (1 to 15, or "cue") and eyes ("round" or "pill"). Reduced motion skips the bouncing, rolling, and turning; the expressions still change.
Install
Copy it, with its game rules, into a shadcn/ui project with the shadcn CLI, or open it in v0 to remix it. Or copy the source below.
bunx shadcn@latest add https://streamui.org/r/eight-ball-lab.jsonSource
src/components/demos/EightBallLab.tsx
"use client";
import { useEffect, useId, useRef, useState, type PointerEvent, type ReactNode } from "react";
import { cn } from "@streamuilib/react";
import { blip } from "@/lib/games/arcade-sound";
/** A pool ball by its number (1 to 15), or the cue ball. */
export type PoolBall = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | "cue";
export type EightBallProps = {
/** Which pool ball it is: solids 1 to 8, stripes 9 to 15, or the cue ball. */
ball?: PoolBall;
/** Its eyes: round (the default) or leaning pills. */
eyes?: "round" | "pill";
/** Width of the ball, in px. */
size?: number;
/** Accessible name. */
label?: string;
};
/** The classic set's colours: 1 to 7 (and 9 to 15 as stripes of the same), then the 8. */
const POOL: Record<number, { color: string; light?: boolean }> = {
1: { color: "oklch(0.85 0.17 90)", light: true },
2: { color: "oklch(0.47 0.18 260)" },
3: { color: "oklch(0.56 0.21 28)" },
4: { color: "oklch(0.43 0.15 305)" },
5: { color: "oklch(0.7 0.18 50)" },
6: { color: "oklch(0.5 0.13 155)" },
7: { color: "oklch(0.38 0.12 20)" },
8: { color: "oklch(0.17 0.006 270)" },
};
const CUE = "oklch(0.97 0.012 95)";
const WHITE = "oklch(0.99 0 0)";
const INK = "var(--ink)";
/** A ball's paint: its base, its stripe (for 9 to 15), and whether its face needs dark features. */
function paint(ball: PoolBall) {
if (ball === "cue") return { base: CUE, stripe: null, light: true };
const solid = POOL[ball > 8 ? ball - 8 : ball];
return ball > 8 ? { base: WHITE, stripe: solid.color, light: !!solid.light } : { base: solid.color, stripe: null, light: !!solid.light };
}
type Action = "boing" | "spin" | "squish" | "roll" | "wink";
/** The tricks, in turn: what it does, what's announced, how long it lasts. */
const ACTIONS: { id: Action; say: string; ms: number }[] = [
{ id: "boing", say: "Boing! A big happy bounce.", ms: 1100 },
{ id: "spin", say: "It spins all the way around.", ms: 1000 },
{ id: "squish", say: "Squish!", ms: 900 },
{ id: "roll", say: "It rolls away and back.", ms: 1500 },
{ id: "wink", say: "It winks.", ms: 1000 },
];
/** Its voice: short synthesized blips, all above 300 Hz so small speakers play them. */
const SOUNDS: Record<Action, Parameters<typeof blip>[0]> = {
boing: [
{ f: 320, to: 760, dur: 0.16, type: "sine", gain: 0.07 },
{ f: 760, to: 420, at: 0.17, dur: 0.2, type: "sine", gain: 0.05 },
],
spin: [{ f: 450, to: 1400, dur: 0.4, type: "triangle", gain: 0.05 }],
squish: [
{ f: 560, to: 340, dur: 0.12, type: "square", gain: 0.03 },
{ f: 340, to: 620, at: 0.13, dur: 0.12, type: "square", gain: 0.025 },
],
// Two balls clacking, then a roll.
roll: [
{ f: 2400, to: 1800, dur: 0.03, type: "triangle", gain: 0.06 },
...Array.from({ length: 7 }, (_, i) => ({ f: 380 + (i % 3) * 40, at: 0.08 + i * 0.09, dur: 0.05, type: "triangle" as const, gain: 0.03 })),
],
wink: [
{ f: 1200, dur: 0.07, type: "sine", gain: 0.05 },
{ f: 1600, at: 0.08, dur: 0.12, type: "sine", gain: 0.045 },
],
};
const R = 80;
const BASE = { yaw: 24, pitch: -16 };
/** How far above the ball we look from, for the stripe's curve. */
const ELEVATION = 14;
/** A stripe ball's band reaches this far above and below the equator, in degrees. */
const BAND = 38;
/**
* Where a point on the ball lands, seen from a little above (the same view
* as the stripe): `yaw` around it (0 faces you) and `lat` up from its
* equator, in degrees. `k` is how squarely it faces you (1 head-on, 0 at the
* edge).
*/
function onSphere(yaw: number, lat: number) {
const t = (yaw * Math.PI) / 180;
const l = (lat * Math.PI) / 180;
const e = (ELEVATION * Math.PI) / 180;
const x = R * Math.cos(l) * Math.sin(t);
const y0 = -R * Math.sin(l);
const z0 = R * Math.cos(l) * Math.cos(t);
const y = y0 * Math.cos(e) + z0 * Math.sin(e);
const k = (-y0 * Math.sin(e) + z0 * Math.cos(e)) / R;
return { x, y, k, angle: (Math.atan2(y, x) * 180) / Math.PI };
}
/**
* A flat decal stuck to the ball at (yaw, lat): squashed toward the
* centre as it turns away, faded at the edge, and hidden on the back.
*/
function Decal({ yaw, lat, children }: { yaw: number; lat: number; children: ReactNode }) {
const s = onSphere(yaw, lat);
if (s.k <= 0.02) return null;
return (
<g transform={`translate(${s.x} ${s.y}) rotate(${s.angle}) scale(${s.k} 1) rotate(${-s.angle})`} opacity={Math.min(1, s.k * 4)}>
{children}
</g>
);
}
/**
* A stripe ball's band, between latitudes ±`lat`, seen from a little above:
* the front arcs of its two edge circles, joined well outside the ball and
* clipped to it.
*/
function bandPath(lat = BAND) {
const e = (ELEVATION * Math.PI) / 180;
const l = (lat * Math.PI) / 180;
const rx = R * Math.cos(l);
const ry = R * Math.cos(l) * Math.sin(e);
const top = -R * Math.sin(l) * Math.cos(e);
const bottom = R * Math.sin(l) * Math.cos(e);
const far = R * 2;
return `M${-far} ${top} L${-rx} ${top} A${rx} ${ry} 0 0 0 ${rx} ${top} L${far} ${top} L${far} ${bottom} L${rx} ${bottom} A${rx} ${ry} 0 0 1 ${-rx} ${bottom} L${-far} ${bottom} Z`;
}
/**
* 8-Ball: a glossy pool-ball mascot in faux 3D, as any ball in the set. Its
* face is stuck to the surface (and its number sticker to the back), so as it
* turns the eyes slide across the curve and go round, and the number comes
* into view. It hovers and bounces with squash and stretch, glances about,
* turns all the way round now and then, and looks toward the pointer. Each
* tap is the next trick: a big boing, a spin, a squish, a roll, and a wink.
* One labelled button.
*/
export function EightBall({ ball = 8, eyes = "round", size = 180, label }: EightBallProps) {
const [yaw, setYaw] = useState(BASE.yaw);
const [pitch, setPitch] = useState(BASE.pitch);
const [action, setAction] = useState<{ id: Action; n: number } | null>(null);
const [blink, setBlink] = useState(false);
const [hop, setHop] = useState(0);
const statusId = useId();
const gid = useId().replace(/:/g, "");
const current = useRef(BASE.yaw);
const frame = useRef(0);
const busy = useRef(false);
const hovering = useRef(false);
const still = useRef(false);
const count = useRef(0);
const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
/** Turn the face to `deg` over `ms`, easing in and out, from wherever it is now. */
const turnTo = (deg: number, ms = 600) => {
cancelAnimationFrame(frame.current);
if (still.current) {
current.current = ((deg + 540) % 360) - 180;
setYaw(current.current);
return;
}
const from = current.current;
const t0 = performance.now();
const step = (now: number) => {
const k = Math.min(1, (now - t0) / ms);
const e = k < 0.5 ? 4 * k * k * k : 1 - (-2 * k + 2) ** 3 / 2;
current.current = from + (deg - from) * e;
setYaw(current.current);
if (k < 1) frame.current = requestAnimationFrame(step);
else {
current.current = ((((deg + 180) % 360) + 360) % 360) - 180;
setYaw(current.current);
}
};
frame.current = requestAnimationFrame(step);
};
const later = (ms: number, fn: () => void) => timers.current.push(setTimeout(fn, ms));
useEffect(() => {
still.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const all = timers.current;
const blinker = setInterval(() => {
setBlink(true);
all.push(setTimeout(() => setBlink(false), 130));
}, 3400);
// Idle: glance about, hop now and then, and every so often turn round to show its number.
const looks = [BASE.yaw - 40, BASE.yaw, BASE.yaw + 30, BASE.yaw];
let tick = 0;
const idle = setInterval(() => {
if (busy.current || hovering.current) return;
tick += 1;
if (tick % 5 === 0) {
busy.current = true;
turnTo(180 + BASE.yaw, 1000);
all.push(setTimeout(() => turnTo(360 + BASE.yaw, 1000), 1900));
all.push(setTimeout(() => (busy.current = false), 3000));
} else {
turnTo(looks[tick % looks.length], 800);
if (tick % 2 === 0) setHop((h) => h + 1);
}
}, 2400);
return () => {
clearInterval(blinker);
clearInterval(idle);
cancelAnimationFrame(frame.current);
all.forEach(clearTimeout);
};
}, []);
const perform = () => {
const next = ACTIONS[count.current % ACTIONS.length];
count.current += 1;
const n = count.current;
busy.current = true;
blip(SOUNDS[next.id]);
setAction({ id: next.id, n });
if (next.id === "spin") turnTo(current.current + 360, 900);
else turnTo(BASE.yaw, 250);
later(next.ms, () => {
busy.current = false;
setAction((a) => (a && a.n === n ? null : a));
});
};
const onMove = (e: PointerEvent<HTMLButtonElement>) => {
hovering.current = true;
if (still.current) return;
const r = e.currentTarget.getBoundingClientRect();
const dx = (e.clientX - r.left) / r.width - 0.5;
const dy = (e.clientY - r.top) / r.height - 0.5;
setPitch(Math.max(-38, Math.min(26, dy * 70 - 6)));
if (!busy.current) turnTo(Math.max(-1, Math.min(1, dx * 2)) * 50, 200);
};
const id = action?.id;
const { stripe, base, light } = paint(ball);
// On a stripe ball the face sits a little lower, to stay on the band.
const lift = stripe ? -12 : 0;
const eyeLat = -pitch + 8 + lift;
const smileLat = -pitch - 16 + lift;
/** Dark features on light paint, white on dark: by what's under that spot. */
const ink = (lat: number) => (stripe && Math.abs(lat) > BAND - 6 ? INK : light ? INK : WHITE);
const face = ink(eyeLat);
const happy = id === "boing" || id === "spin";
const name = label ?? (ball === "cue" ? "Cue ball" : `${ball}-Ball`);
/** One eye: round or a leaning pill, with arcs and chevrons for moods. */
const eye = (side: -1 | 1) => {
if (happy) return <path d="M-9 4 Q0 -10 9 4" fill="none" stroke={face} strokeWidth="6" strokeLinecap="round" />;
if (id === "squish") return <path d={side < 0 ? "M-7 -9 L6 0 L-7 9" : "M7 -9 L-6 0 L7 9"} fill="none" stroke={face} strokeWidth="6" strokeLinecap="round" strokeLinejoin="round" />;
if (id === "wink" && side > 0) return <path d="M-9 0 Q0 7 9 0" fill="none" stroke={face} strokeWidth="6" strokeLinecap="round" />;
if (eyes === "pill") return <rect x="-6.5" y="-16" width="13" height="32" rx="6.5" fill={face} transform={`rotate(-28) scale(1 ${blink ? 0.15 : 1})`} />;
return <ellipse rx="10" ry="15" fill={face} transform={`scale(1 ${blink ? 0.12 : 1})`} />;
};
const smile = id === "squish" ? "M-10 0 Q-5 6 0 0 Q5 6 10 0" : happy || id === "wink" ? "M-14 -3 Q0 15 14 -3" : "M-11 0 Q0 9 11 0";
return (
<button
type="button"
aria-label={`${name}. Tap for a trick.`}
aria-describedby={statusId}
onClick={perform}
onPointerMove={onMove}
onPointerLeave={() => {
hovering.current = false;
setPitch(BASE.pitch);
if (!busy.current) turnTo(BASE.yaw, 500);
}}
className="relative grid cursor-pointer place-items-center rounded-full focus-visible:outline-3 focus-visible:outline-offset-4 focus-visible:outline-blue"
style={{ width: size * 1.45, height: size * 1.45 }}
>
<style href="eight-ball-critter" precedence="default">{`
@keyframes eightball-hover { 0%, 100% { translate: 0 0; } 50% { translate: 0 -4%; } }
@keyframes eightball-hop { 0%, 100% { translate: 0 0; scale: 1 1; } 14% { translate: 0 0; scale: 1.1 0.9; } 45% { translate: 0 -16%; scale: 0.95 1.06; } 76% { translate: 0 0; scale: 1.12 0.88; } 88% { scale: 0.97 1.03; } }
@keyframes eightball-boing { 0%, 100% { translate: 0 0; scale: 1 1; } 10% { translate: 0 0; scale: 1.18 0.8; } 40% { translate: 0 -34%; scale: 0.88 1.14; } 70% { translate: 0 0; scale: 1.22 0.78; } 82% { translate: 0 -6%; scale: 0.96 1.04; } 92% { translate: 0 0; scale: 1.04 0.97; } }
@keyframes eightball-squish { 0%, 100% { scale: 1 1; } 20% { scale: 1.28 0.7; } 40% { scale: 0.88 1.14; } 58% { scale: 1.12 0.9; } 76% { scale: 0.97 1.03; } }
@keyframes eightball-roll-x { 0%, 100% { translate: 0 0; } 40% { translate: 26% 0; } 70% { translate: -10% 0; } }
@keyframes eightball-roll-face { 0%, 100% { rotate: 0deg; } 40% { rotate: 150deg; } 70% { rotate: -58deg; } }
@keyframes eightball-shadow { 0%, 14%, 76%, 100% { scale: 1; opacity: 1; } 45% { scale: 0.7; opacity: 0.55; } }
@keyframes eightball-shadow-big { 0%, 10%, 70%, 100% { scale: 1; opacity: 1; } 40% { scale: 0.5; opacity: 0.4; } }
.eightball-hover { animation: eightball-hover 2.4s ease-in-out infinite; }
.eightball-hop { animation: eightball-hop 0.7s cubic-bezier(.3,0,.3,1); transform-origin: 50% 100%; }
.eightball-boing { animation: eightball-boing 1.05s cubic-bezier(.3,0,.3,1); transform-origin: 50% 100%; }
.eightball-squish { animation: eightball-squish 0.85s ease-out; transform-origin: 50% 100%; }
.eightball-roll-x { animation: eightball-roll-x 1.4s cubic-bezier(.45,0,.35,1); }
/* view-box's origin is user space (0, 0): the ball's centre. */
.eightball-roll-face { animation: eightball-roll-face 1.4s cubic-bezier(.45,0,.35,1); transform-box: view-box; transform-origin: 0 0; }
.eightball-shadow-hop { animation: eightball-shadow 0.7s cubic-bezier(.3,0,.3,1); }
.eightball-shadow-boing { animation: eightball-shadow-big 1.05s cubic-bezier(.3,0,.3,1); }
@media (prefers-reduced-motion: reduce) {
.eightball-hover, .eightball-hop, .eightball-boing, .eightball-squish, .eightball-roll-x, .eightball-roll-face, .eightball-shadow-hop, .eightball-shadow-boing { animation: none; }
}
`}</style>
<div className={cn("relative", id === "roll" && "eightball-roll-x")} key={`x-${id === "roll" ? action?.n : 0}`} style={{ width: size }}>
<span
aria-hidden
key={`shadow-${action?.n ?? 0}-${hop}`}
className={cn(
"absolute -bottom-[2%] left-1/2 block h-[10%] w-[62%] -translate-x-1/2 rounded-[50%] bg-ink/22 blur-[3px]",
id === "boing" ? "eightball-shadow-boing" : !id && hop ? "eightball-shadow-hop" : "",
)}
/>
<div className="eightball-hover">
<div
key={`b-${action?.n ?? 0}-${hop}`}
className={cn(id === "boing" ? "eightball-boing" : id === "squish" ? "eightball-squish" : !id && hop ? "eightball-hop" : "")}
>
<svg aria-hidden viewBox="-100 -100 200 200" overflow="visible" className="block w-full">
<defs>
<clipPath id={`${gid}-clip`}>
<circle r={R} />
</clipPath>
{/* Light from the top left, a darker edge: works over any paint. */}
<radialGradient id={`${gid}-light`} cx="36%" cy="30%" r="80%">
<stop offset="0%" stopColor="white" stopOpacity="0.42" />
<stop offset="28%" stopColor="white" stopOpacity="0.06" />
<stop offset="62%" stopColor="black" stopOpacity="0" />
<stop offset="100%" stopColor="black" stopOpacity="0.42" />
</radialGradient>
</defs>
{/* The ball: its paint, its stripe, its face and number, then light and gloss over all of it. */}
<g clipPath={`url(#${gid}-clip)`}>
<circle r={R} fill={base} />
{/* Everything painted on the ball; a roll turns it all around the middle. */}
<g className={cn(id === "roll" && "eightball-roll-face")} key={`f-${id === "roll" ? action?.n : 0}`}>
{stripe && <path d={bandPath()} fill={stripe} />}
{ball !== "cue" && (
<Decal yaw={yaw + 180} lat={4}>
<circle r="31" fill={WHITE} stroke={INK} strokeOpacity="0.15" strokeWidth="1.5" />
<text y="1.5" textAnchor="middle" dominantBaseline="central" fontFamily="var(--font-display), sans-serif" fontSize={ball > 9 ? 30 : 38} fill={INK}>
{ball}
</text>
</Decal>
)}
<Decal yaw={yaw - 18} lat={eyeLat}>
{eye(-1)}
</Decal>
<Decal yaw={yaw + 18} lat={eyeLat}>
{eye(1)}
</Decal>
<Decal yaw={yaw} lat={smileLat}>
<path d={smile} fill="none" stroke={ink(smileLat)} strokeWidth="5.5" strokeLinecap="round" strokeLinejoin="round" />
</Decal>
</g>
<circle r={R} fill={`url(#${gid}-light)`} />
<ellipse cx="-30" cy="-46" rx="20" ry="11" transform="rotate(-32 -30 -46)" fill="white" opacity="0.28" />
</g>
<circle r={R} fill="none" stroke={INK} strokeWidth="3" />
</svg>
</div>
</div>
</div>
<span id={statusId} aria-live="polite" className="sr-only">
{action ? ACTIONS.find((a) => a.id === action.id)?.say : ""}
</span>
</button>
);
}
const SET: PoolBall[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, "cue"];
/**
* The lab: the 8-Ball, with the whole rack to pick from and the eye style.
* `preview` (the Labs index card) shows just the 8; the pickers live on the
* lab's own page.
*/
export function EightBallLab({ preview = false }: { preview?: boolean }) {
const [ball, setBall] = useState<PoolBall>(8);
const [eyes, setEyes] = useState<"round" | "pill">("round");
return (
<div className="flex w-full flex-col items-center gap-4 text-ink">
<EightBall ball={ball} eyes={eyes} />
{!preview && (
<>
<div role="group" aria-label="Ball" className="flex max-w-sm flex-wrap justify-center gap-2">
{SET.map((b) => {
const p = paint(b);
return (
<button
key={b}
type="button"
aria-label={b === "cue" ? "Cue ball" : `${b} ball`}
aria-pressed={ball === b}
onClick={() => setBall(b)}
className={cn(
"relative grid size-9 place-items-center overflow-hidden rounded-full border-2 border-ink font-mono text-[10px] font-bold focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-blue",
ball === b && "ring-2 ring-ink ring-offset-2",
)}
style={{ background: p.stripe ? `linear-gradient(${WHITE} 26%, ${p.stripe} 26% 74%, ${WHITE} 74%)` : p.base }}
>
{b !== "cue" && <span className="grid size-4.5 place-items-center rounded-full bg-paper-raised text-ink">{b}</span>}
</button>
);
})}
</div>
<div role="group" aria-label="Eyes" className="flex gap-2">
{(["round", "pill"] as const).map((e) => (
<button
key={e}
type="button"
aria-pressed={eyes === e}
onClick={() => setEyes(e)}
className={cn(
"border-2 border-ink px-3 py-1 font-mono text-xs font-bold uppercase shadow-brutal-sm focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-blue",
eyes === e ? "bg-lime text-on-bright" : "bg-paper-raised",
)}
>
{e} eyes
</button>
))}
</div>
</>
)}
</div>
);
}