diff --git a/renderer/src/remotion/Confetti.jsx b/renderer/src/remotion/Confetti.jsx
new file mode 100644
index 0000000..1017fc9
--- /dev/null
+++ b/renderer/src/remotion/Confetti.jsx
@@ -0,0 +1,64 @@
+import {AbsoluteFill, interpolate, random, useCurrentFrame, useVideoConfig} from 'remotion';
+import {createConfettiParticles, getConfettiParticleState} from './confetti-layout.js';
+
+/**
+ * Winner-celebration confetti burst. Renders nothing before `startFrame`, then
+ * fires a deterministic paper-strip burst from the wheel center through the end
+ * of the composition.
+ *
+ * @param {object} props
+ * @param {number} props.startFrame frame the burst begins on (winner reveal)
+ * @param {string[]} props.colors palette (theme slice colors)
+ * @param {number|string} props.seed reproducibility seed (e.g. winner index)
+ * @param {number} [props.count] particle count
+ */
+export const Confetti = ({startFrame, colors, seed, count = 70}) => {
+ const frame = useCurrentFrame();
+ const {durationInFrames, fps, height, width} = useVideoConfig();
+
+ if (frame < startFrame) {
+ return null;
+ }
+
+ const size = Math.min(width, height);
+ const lifetimeFrames = Math.max(1, durationInFrames - 1 - startFrame);
+ const localFrame = frame - startFrame;
+ const seconds = localFrame / fps;
+ const progress = localFrame / lifetimeFrames;
+ const opacity = interpolate(progress, [0, 0.06, 0.7, 1], [0, 1, 1, 0], {
+ extrapolateLeft: 'clamp',
+ extrapolateRight: 'clamp',
+ });
+
+ const particles = createConfettiParticles({
+ colors,
+ count,
+ random: (key) => random(`${seed}-${key}`),
+ size,
+ });
+
+ return (
+
+ {particles.map((particle, index) => {
+ const state = getConfettiParticleState(particle, seconds);
+
+ return (
+
+ );
+ })}
+
+ );
+};
diff --git a/renderer/src/remotion/WheelComposition.jsx b/renderer/src/remotion/WheelComposition.jsx
index 58fd3fa..3550e87 100644
--- a/renderer/src/remotion/WheelComposition.jsx
+++ b/renderer/src/remotion/WheelComposition.jsx
@@ -11,6 +11,7 @@ import {
} from './wheel-layout.js';
import {getRadialLabelLayout} from './wheel-label-layout.js';
import {getTheme} from './themes.js';
+import {Confetti} from './Confetti.jsx';
const baseFont =
'Quicksand, Inter, "Noto Sans", "Noto Sans Vietnamese", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
@@ -181,6 +182,8 @@ export const WheelComposition = (props) => {
}}
/>
+
+
);
};
diff --git a/renderer/src/remotion/confetti-layout.js b/renderer/src/remotion/confetti-layout.js
new file mode 100644
index 0000000..dbf2565
--- /dev/null
+++ b/renderer/src/remotion/confetti-layout.js
@@ -0,0 +1,85 @@
+/**
+ * Deterministic confetti field for the winner celebration.
+ *
+ * Remotion renders every GIF frame independently in headless Chromium, so the
+ * animation must be a pure function of the frame number. These helpers turn a
+ * seeded random source into stable particle descriptors and evaluate their
+ * ballistic path at an arbitrary time, keeping the visual identical on every
+ * render of the same seed.
+ */
+
+/** Canvas size the physics constants below are tuned for. */
+export const CONFETTI_REFERENCE_SIZE = 512;
+
+/**
+ * @param {number} t normalized value in [0, 1]
+ * @param {number} min
+ * @param {number} max
+ * @returns {number}
+ */
+const lerp = (t, min, max) => min + t * (max - min);
+
+/**
+ * @typedef {object} ConfettiParticle
+ * @property {string} color
+ * @property {number} gravity px/s^2 downward acceleration
+ * @property {number} height px
+ * @property {number} originX px
+ * @property {number} originY px
+ * @property {number} rotationSpeed deg/s
+ * @property {number} startRotation deg
+ * @property {number} vx px/s
+ * @property {number} vy px/s (negative points up on screen)
+ * @property {number} width px
+ */
+
+/**
+ * Builds the confetti particles bursting outward and upward from the wheel
+ * center. Physics scale with `size` relative to {@link CONFETTI_REFERENCE_SIZE}.
+ *
+ * @param {object} params
+ * @param {number} params.count number of particles
+ * @param {number} params.size canvas edge length in px
+ * @param {string[]} params.colors palette to cycle through
+ * @param {(seed: string) => number} params.random deterministic [0, 1) source
+ * @returns {ConfettiParticle[]}
+ */
+export const createConfettiParticles = ({count, size, colors, random}) => {
+ const scale = size / CONFETTI_REFERENCE_SIZE;
+ const center = size / 2;
+ const palette = colors.length > 0 ? colors : ['#f97316'];
+
+ return Array.from({length: count}, (_, index) => {
+ const rand = (/** @type {string} */ key) => random(`${index}-${key}`);
+ // Fan upward: -160deg..-20deg in screen coords (0deg right, -90deg up).
+ const angleRadians = (lerp(rand('angle'), -160, -20) * Math.PI) / 180;
+ const speed = lerp(rand('speed'), 320, 820) * scale;
+ const width = lerp(rand('width'), 6, 12) * scale;
+
+ return {
+ color: palette[Math.floor(rand('color') * palette.length)] ?? palette[0] ?? '#f97316',
+ gravity: 1500 * scale,
+ height: width * lerp(rand('ratio'), 0.4, 1),
+ originX: center + lerp(rand('originX'), -0.06, 0.06) * size,
+ originY: center + lerp(rand('originY'), -0.04, 0.02) * size,
+ rotationSpeed: lerp(rand('spin'), -540, 540),
+ startRotation: lerp(rand('rotation'), 0, 360),
+ vx: Math.cos(angleRadians) * speed,
+ vy: Math.sin(angleRadians) * speed,
+ width,
+ };
+ });
+};
+
+/**
+ * Evaluates a particle's position and rotation at `seconds` after the burst.
+ *
+ * @param {ConfettiParticle} particle
+ * @param {number} seconds elapsed since the burst started
+ * @returns {{rotation: number, x: number, y: number}}
+ */
+export const getConfettiParticleState = (particle, seconds) => ({
+ rotation: particle.startRotation + particle.rotationSpeed * seconds,
+ x: particle.originX + particle.vx * seconds,
+ y: particle.originY + particle.vy * seconds + 0.5 * particle.gravity * seconds * seconds,
+});
diff --git a/renderer/test/confetti-layout.test.js b/renderer/test/confetti-layout.test.js
new file mode 100644
index 0000000..b5ef192
--- /dev/null
+++ b/renderer/test/confetti-layout.test.js
@@ -0,0 +1,116 @@
+import {describe, expect, test} from 'vitest';
+import {
+ CONFETTI_REFERENCE_SIZE,
+ createConfettiParticles,
+ getConfettiParticleState,
+} from '../src/remotion/confetti-layout.js';
+
+// Cyclic pseudo-random source: stable, spans (0, 1), and keys map to values.
+const makeRandom = () => {
+ const seen = new Map();
+ let next = 0;
+ return (/** @type {string} */ seed) => {
+ if (!seen.has(seed)) {
+ seen.set(seed, (next++ % 10) / 10 + 0.05);
+ }
+ return seen.get(seed);
+ };
+};
+
+describe('confetti layout', () => {
+ test('creates the requested particle count', () => {
+ const particles = createConfettiParticles({
+ colors: ['#f97316', '#14b8a6'],
+ count: 24,
+ random: makeRandom(),
+ size: 512,
+ });
+
+ expect(particles).toHaveLength(24);
+ });
+
+ test('is deterministic for the same seed source', () => {
+ const params = {colors: ['#f97316', '#14b8a6'], count: 8, size: 512};
+ const first = createConfettiParticles({...params, random: makeRandom()});
+ const second = createConfettiParticles({...params, random: makeRandom()});
+
+ expect(second).toEqual(first);
+ });
+
+ test('draws colors only from the provided palette', () => {
+ const palette = ['#f97316', '#14b8a6', '#facc15'];
+ const particles = createConfettiParticles({
+ colors: palette,
+ count: 40,
+ random: makeRandom(),
+ size: 512,
+ });
+
+ for (const particle of particles) {
+ expect(palette).toContain(particle.color);
+ }
+ });
+
+ test('falls back to a default color when palette is empty', () => {
+ const particles = createConfettiParticles({
+ colors: [],
+ count: 4,
+ random: makeRandom(),
+ size: 512,
+ });
+
+ for (const particle of particles) {
+ expect(typeof particle.color).toBe('string');
+ expect(particle.color.length).toBeGreaterThan(0);
+ }
+ });
+
+ test('scales physics with canvas size', () => {
+ const [small] = createConfettiParticles({colors: ['#000'], count: 1, random: makeRandom(), size: 256});
+ const [large] = createConfettiParticles({colors: ['#000'], count: 1, random: makeRandom(), size: 1024});
+
+ if (!small || !large) {
+ throw new Error('expected one particle per call');
+ }
+
+ expect(large.gravity).toBeCloseTo(small.gravity * 4);
+ expect(large.width).toBeCloseTo(small.width * 4);
+ });
+
+ test('reference size keeps gravity at the tuned constant', () => {
+ const [particle] = createConfettiParticles({
+ colors: ['#000'],
+ count: 1,
+ random: makeRandom(),
+ size: CONFETTI_REFERENCE_SIZE,
+ });
+
+ expect(particle?.gravity).toBe(1500);
+ });
+
+ test('particle starts at its origin and accelerates downward', () => {
+ const particle = {
+ color: '#000',
+ gravity: 1500,
+ height: 8,
+ originX: 100,
+ originY: 200,
+ rotationSpeed: 90,
+ startRotation: 10,
+ vx: 50,
+ vy: -300,
+ width: 8,
+ };
+
+ expect(getConfettiParticleState(particle, 0)).toEqual({
+ rotation: 10,
+ x: 100,
+ y: 200,
+ });
+
+ const later = getConfettiParticleState(particle, 1);
+ expect(later.x).toBe(150);
+ expect(later.y).toBe(650); // 200 - 300 + 0.5 * 1500
+ expect(later.rotation).toBe(100);
+ });
+});