Shared prize wheel
Spin the wheel in one window and watch it spin and land on the same page in the other. Each slice links to a docs page.
- Keep this page open in your normal window.
- Copy the private-window link, then paste it into a private or incognito window.
- Interact in either window and watch the other one update.
Copy the code
Both versions use the same shared data and behavior. The live playground runs the Vanilla HTML version.
Save this as index.html, or open it in the playground to
test and change it.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Shared prize wheel</title>
<style>
:root {
color: #1c1c1c;
background: #f4efe5;
font-family: ui-sans-serif, system-ui, sans-serif;
}
* { box-sizing: border-box; }
body { display: grid; min-height: 100vh; margin: 0; padding: 1.5rem; place-items: center; }
main { display: grid; gap: 0.75rem; justify-items: center; }
.wheel {
position: relative;
width: 232px;
height: 232px;
border: 2px solid #1c1c1c;
border-radius: 50%;
background: repeating-conic-gradient(#f3cf58 0 60deg, #f4efe5 60deg 120deg);
}
/* Each label sits on its slice's center line, reading outward. */
.wheel span {
position: absolute;
top: 50%;
left: 50%;
transform: rotate(var(--angle)) translate(28px, -50%);
transform-origin: 0 0;
font: 600 0.7rem ui-monospace, monospace;
}
/* The pointer at 12 o'clock. */
.pointer {
width: 0;
margin: 0 auto -6px;
border: 10px solid transparent;
border-top: 16px solid #1c1c1c;
}
</style>
</head>
<body>
<main id="prize-wheel"></main>
<script type="module">
import { playhtml, html, nothing } from "https://unpkg.com/playhtml@latest";
const PAGES = [
{ label: "Toggle a lamp", href: "https://playhtml.fun/docs/capabilities/#can-toggle" },
{ label: "Drag a hat", href: "https://playhtml.fun/docs/capabilities/#can-move" },
{ label: "See cursors", href: "https://playhtml.fun/docs/advanced/cursors/" },
{ label: "Summon rain", href: "https://playhtml.fun/docs/data/events/" },
{ label: "Shape data", href: "https://playhtml.fun/docs/advanced/data-best-practices/" },
{ label: "Write React", href: "https://playhtml.fun/docs/using-react/" },
];
const SLICE = 360 / PAGES.length;
const SPIN_MS = 3600;
// The wheel's angle at time `now`: six turns that ease out and stop
// with the target slice under the pointer.
function wheelAngle(spin, now) {
if (!spin) return 0;
const t = Math.min(1, Math.max(0, (now - spin.startedAt) / SPIN_MS));
const eased = 1 - (1 - t) ** 3;
return (6 * 360 - (spin.target + 0.5) * SLICE) * eased;
}
// Each label points along the center line of its slice.
function labelAngle(i) {
return (i + 0.5) * SLICE - 90;
}
function newSpin() {
const target = Math.floor(Math.random() * PAGES.length);
return { startedAt: Date.now(), target };
}
playhtml.register("prize-wheel", {
defaultData: { spin: null },
view: ({ data, setData }) => {
const { spin } = data;
const spinning = spin && Date.now() - spin.startedAt < SPIN_MS;
const landed = spin && !spinning ? PAGES[spin.target] : null;
const angle = wheelAngle(spin, Date.now());
return html`
<div class="pointer"></div>
<div class="wheel" style="transform: rotate(${angle}deg)">
${PAGES.map(
(page, i) => html`
<span style="--angle: ${labelAngle(i)}deg">${page.label}</span>
`,
)}
</div>
<button
?disabled=${spinning}
@click=${() => setData({ spin: newSpin() })}
>
${spinning ? "Spinning…" : "Spin"}
</button>
${landed
? html`<a href=${landed.href}>${landed.label} →</a>`
: nothing}
`;
},
// The data only changes when someone spins, so re-render every frame
// while the wheel turns. Return the cleanup to stop the loop.
onMount: ({ getData, requestUpdate }) => {
let frame;
const tick = () => {
const { spin } = getData();
const turning = spin && Date.now() - spin.startedAt < SPIN_MS + 1000;
if (turning) requestUpdate();
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
},
});
await playhtml.init({ developmentMode: true });
</script>
</body>
</html>
Start with a React + TypeScript project, install the packages below,
then replace src/App.tsx with the component.
npm install playhtml @playhtml/react import { useEffect, useState, type CSSProperties } from "react";
import { PlayProvider, withSharedState } from "@playhtml/react";
type Spin = { startedAt: number; target: number };
const PAGES = [
{ label: "Toggle a lamp", href: "https://playhtml.fun/docs/capabilities/#can-toggle" },
{ label: "Drag a hat", href: "https://playhtml.fun/docs/capabilities/#can-move" },
{ label: "See cursors", href: "https://playhtml.fun/docs/advanced/cursors/" },
{ label: "Summon rain", href: "https://playhtml.fun/docs/data/events/" },
{ label: "Shape data", href: "https://playhtml.fun/docs/advanced/data-best-practices/" },
{ label: "Write React", href: "https://playhtml.fun/docs/using-react/" },
];
const SLICE = 360 / PAGES.length;
const SPIN_MS = 3600;
// The wheel's angle at time `now`: six turns that ease out and stop
// with the target slice under the pointer.
function wheelAngle(spin: Spin | null, now: number) {
if (!spin) return 0;
const t = Math.min(1, Math.max(0, (now - spin.startedAt) / SPIN_MS));
const eased = 1 - (1 - t) ** 3;
return (6 * 360 - (spin.target + 0.5) * SLICE) * eased;
}
// Each label points along the center line of its slice.
function labelAngle(i: number) {
return (i + 0.5) * SLICE - 90;
}
function newSpin(): Spin {
const target = Math.floor(Math.random() * PAGES.length);
return { startedAt: Date.now(), target };
}
const PrizeWheel = withSharedState(
{ id: "prize-wheel", defaultData: { spin: null as Spin | null } },
({ data, setData }) => {
const { spin } = data;
const [now, setNow] = useState(Date.now);
// The data only changes when someone spins, so re-render every frame
// while the wheel turns.
useEffect(() => {
if (!spin) return;
let frame: number;
const tick = () => {
setNow(Date.now());
if (Date.now() - spin.startedAt < SPIN_MS) {
frame = requestAnimationFrame(tick);
}
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [spin]);
const spinning = spin !== null && now - spin.startedAt < SPIN_MS;
const landed = spin && !spinning ? PAGES[spin.target] : null;
return (
<>
<div className="pointer" />
<div
className="wheel"
style={{ transform: `rotate(${wheelAngle(spin, now)}deg)` }}
>
{PAGES.map((page, i) => (
<span
key={page.label}
style={{ "--angle": `${labelAngle(i)}deg` } as CSSProperties}
>
{page.label}
</span>
))}
</div>
<button
disabled={spinning}
onClick={() => setData({ spin: newSpin() })}
>
{spinning ? "Spinning…" : "Spin"}
</button>
{landed && <a href={landed.href}>{landed.label} →</a>}
</>
);
},
);
export default function App() {
return (
<PlayProvider initOptions={{ developmentMode: true }}>
<main>
<PrizeWheel />
</main>
<style>{`
:root { color: #1c1c1c; background: #f4efe5; font-family: system-ui, sans-serif; }
* { box-sizing: border-box; }
body { margin: 0; }
#root { display: grid; min-height: 100vh; padding: 1.5rem; place-items: center; }
main { display: grid; gap: 0.75rem; justify-items: center; }
.wheel {
position: relative;
width: 232px;
height: 232px;
border: 2px solid #1c1c1c;
border-radius: 50%;
background: repeating-conic-gradient(#f3cf58 0 60deg, #f4efe5 60deg 120deg);
}
/* Each label sits on its slice's center line, reading outward. */
.wheel span {
position: absolute;
top: 50%;
left: 50%;
transform: rotate(var(--angle)) translate(28px, -50%);
transform-origin: 0 0;
font: 600 0.7rem ui-monospace, monospace;
}
/* The pointer at 12 o'clock. */
.pointer {
width: 0;
margin: 0 auto -6px;
border: 10px solid transparent;
border-top: 16px solid #1c1c1c;
}
`}</style>
</PlayProvider>
);
} Share the spin, not the angle
Section titled “Share the spin, not the angle”Shared data holds only when the spin started and which slice it lands on:
setData({ spin: { startedAt: Date.now(), target } });
Every browser computes the wheel’s angle from that and the current time, so the animation runs locally at full frame rate and still lands in the same place for everyone. Nothing is written while the wheel turns.
Re-render on a clock
Section titled “Re-render on a clock”The data only changes when someone spins, so the wheel needs its own clock. The
Vanilla HTML version calls requestUpdate() from a requestAnimationFrame loop
in onMount, which re-runs view without touching data. The React version
does the same with useState and a useEffect loop.
See Custom elements for the view API.