Code — how this page is built

Under the hood

The actual source of the components on this page, read straight from the repo. Copy it, read it, or open it on GitHub.


Canvas wheel: segments + labels drawn per option. Picks the winner first, then animates rotation with a cubic ease-out so the chosen segment stops under the pointer.

'use client';

import * as React from 'react';

import { Win98Button } from '@/components/ui/win-98-button';

const ACCENTS = ['#c2453a', '#6f7d4a', '#2f6db0', '#b8772e', '#b65a86', '#4c8c7a', '#9b5cc0', '#c9a23f'];
const SIZE = 420;

export function SpinnerGame() {
  const canvasRef = React.useRef<HTMLCanvasElement>(null);
  const [text, setText] = React.useState('Yes\nNo\nMaybe\nAsk again\nDefinitely\nNo way');
  const [angle, setAngle] = React.useState(0);
  const [spinning, setSpinning] = React.useState(false);
  const [winner, setWinner] = React.useState<string | null>(null);

  const options = React.useMemo(
    () => text.split('\n').map((s) => s.trim()).filter(Boolean),
    [text],
  );

  const draw = React.useCallback(
    (rot: number) => {
      const c = canvasRef.current;
      if (!c) return;
      const ctx = c.getContext('2d');
      if (!ctx || options.length === 0) return;
      const n = options.length;
      const seg = (Math.PI * 2) / n;
      const r = SIZE / 2;
      ctx.clearRect(0, 0, SIZE, SIZE);
      ctx.save();
      ctx.translate(r, r);
      ctx.rotate(rot);
      for (let i = 0; i < n; i++) {
        ctx.beginPath();
        ctx.moveTo(0, 0);
        ctx.arc(0, 0, r - 4, i * seg, (i + 1) * seg);
        ctx.closePath();
        ctx.fillStyle = ACCENTS[i % ACCENTS.length];
        ctx.fill();
        ctx.save();
        ctx.rotate(i * seg + seg / 2);
        ctx.textAlign = 'right';
        ctx.fillStyle = '#fff';
        ctx.font = '600 14px "Helvetica Neue", Arial, sans-serif';
        ctx.fillText(options[i].slice(0, 16), r - 16, 5);
        ctx.restore();
      }
      ctx.restore();
    },
    [options],
  );

  React.useEffect(() => {
    draw(angle);
  }, [draw, angle]);

  const spin = () => {
    if (spinning || options.length === 0) return;
    setSpinning(true);
    setWinner(null);
    const n = options.length;
    const seg = (Math.PI * 2) / n;
    const winIdx = Math.floor(Math.random() * n);
    // Land the chosen segment's middle under the pointer (top, -90°).
    const target =
      Math.PI * 2 * (5 + Math.floor(Math.random() * 3)) -
      (winIdx * seg + seg / 2) -
      Math.PI / 2;
    const start = angle;
    const dur = 3800;
    const t0 = performance.now();
    const tick = (now: number) => {
      const p = Math.min(1, (now - t0) / dur);
      const eased = 1 - Math.pow(1 - p, 3);
      setAngle(start + (target - start) * eased);
      if (p < 1) requestAnimationFrame(tick);
      else {
        setWinner(options[winIdx]);
        setSpinning(false);
      }
    };
    requestAnimationFrame(tick);
  };

  return (
    <div className="flex w-full max-w-md flex-col items-center gap-6">
      <div className="relative">
        <canvas ref={canvasRef} width={SIZE} height={SIZE} className="rounded-full" />
        <div className="absolute -top-1 left-1/2 -translate-x-1/2 text-2xl leading-none text-foreground">
          ▾
        </div>
      </div>

      <div className="h-6 font-mono text-sm tracking-wide">
        {winner ? <span className="font-bold">→ {winner}</span> : spinning ? '…' : ''}
      </div>

      <Win98Button onClick={spin} disabled={spinning} className="h-10 min-w-32 px-7 text-sm">
        Spin
      </Win98Button>

      <textarea
        value={text}
        onChange={(e) => setText(e.target.value)}
        rows={4}
        spellCheck={false}
        aria-label="options, one per line"
        className="w-full resize-none border border-foreground/25 bg-transparent p-3 text-center font-mono text-xs leading-relaxed outline-none"
      />
      <div className="font-mono text-[11px] uppercase tracking-[0.12em] opacity-45">
        one option per line
      </div>
    </div>
  );
}
↖ kaspirius