Skip to content

Confetti

Celebration bursts of paper, ribbons and sparkles on the GPU that arc up, flutter down and can settle on a floor; bursts can follow cues.

Loading
00:00:00

Loading the live preview

Props
theme

Paper colours. Default: the theme's accents.

px/s².

A floor that pieces land on: true for the frame's bottom, or a y.

Fine streamers among the paper.

A flash of glints at each burst.

Usage, as set above

Install

Copies the source into your project. It's yours from then on: edit it like any other file.

npx framefields add confetti

Writes one file (confetti/index.ts) into your components folder. Then:

import { Confetti } from "./components/confetti/index.js";
Confetti({ bursts: [{ at: 30, origin: [960, 900] }], floor: true })

Props

Plus the props every component takes: at, duration, hold,enter, exit, x, y, width, height,theme, seed and customize.

PropTypeDefault
burstsrequiredArray<{ at, origin, count, direction, spread, power }>
palettestring[]
Paper colours. Default: the theme's accents.
gravitynumber
px/s².
floorboolean | numberfalse
A floor that pieces land on: true for the frame's bottom, or a y.
ribbonsbooleantrue
Fine streamers among the paper.
sparklesbooleantrue
A flash of glints at each burst.

Cues

Frames other layers can key off, on the returned node: node.cues.bursts[0].frame. Change the props and everything keyed to a cue moves with it.

bursts

Parts

The layers customize receives by name, to restyle or animate one piece without forking the component.

paper-<o>-<n>ribbons-<o>sparkles-<o>

Source

What framefields add confetti writes for confetti/index.ts.

Show 211 lines
confetti/index.ts
/**
* Confetti: celebration bursts of paper, ribbons and sparkles that arc up,
* flutter down and, with a floor, settle. Simulated on the GPU (one particle
* system per palette colour and origin), deterministic per seed; bursts can
* come straight from another component's cues.
*
* Coordinates are the frame's: the root covers the video (default
* the composition `canvas`, or the given width / height) from 0, 0.
*/
import type { LayoutNode } from "framefields";
import {
type Animatable,
bindComponent,
defineComponent,
type ParticleOptions,
resolveTarget,
targetPoint,
targetSchema,
type Vec2,
z,
} from "framefields/components";
export interface ConfettiCues {
[name: string]: ReadonlyArray<{ frame: number }>;
bursts: ReadonlyArray<{ frame: number; origin: Vec2 }>;
}
export const confetti = /* @__PURE__ */ defineComponent({
name: "Confetti",
props: () => ({
bursts: z
.array(
z
.object({
at: z.number().int().min(0),
/** Where it bursts from: a point, rect, node or text. */
origin: targetSchema(),
/** Paper pieces in this burst. */
count: z.number().int().positive().default(140),
/** Launch direction, degrees (−90 = up). */
direction: z.number().default(-90),
/** Cone width, degrees. */
spread: z.number().min(0).max(360).default(70),
/** Launch strength multiplier. */
power: z.number().positive().default(1),
})
.strict(),
)
.min(1),
/** Paper colours. Default: the theme's accents. */
palette: z.array(z.string()).min(1).optional(),
/** px/s². */
gravity: z.number().min(0).optional(),
/** A floor that pieces land on: `true` for the frame's bottom, or a y. */
floor: z.union([z.boolean(), z.number()]).default(false),
/** Fine streamers among the paper. */
ribbons: z.boolean().default(true),
/** A flash of glints at each burst. */
sparkles: z.boolean().default(true),
}),
defaults: { enter: false, exit: false },
build(ctx, props) {
const { host, theme, fps } = ctx;
const s = theme.scale;
const W = ctx.requested.width ?? ctx.canvas.width;
const H = ctx.requested.height ?? ctx.canvas.height;
const c = theme.color;
const palette = props.palette ?? [
c.accent,
c.accent2,
c.highlight,
c.positive,
c.negative,
];
const gravity = props.gravity ?? 1500 * s;
const floor =
props.floor === false
? undefined
: props.floor === true
? H - 6 * s
: props.floor;
const lifetime: [number, number] =
floor === undefined ? [2.6, 4] : [5, 6.5];
// Bursts that share an origin share systems.
const origins = new Map<
string,
{ point: Vec2; bursts: Array<(typeof props.bursts)[number]> }
>();
const cues: Array<{ frame: number; origin: Vec2 }> = [];
for (const b of props.bursts) {
const point = targetPoint(
resolveTarget(b.origin, { measure: host.measure, fps }),
b.at,
);
const key = `${point[0].toFixed(1)},${point[1].toFixed(1)},${b.direction},${b.spread},${b.power}`;
const entry = origins.get(key) ?? { point, bursts: [] };
entry.bursts.push(b);
origins.set(key, entry);
cues.push({ frame: b.at, origin: point });
}
const parts: Record<string, Animatable<LayoutNode>> = {};
const children: LayoutNode[] = [];
let n = 0;
const system = (
name: string,
o: Omit<ParticleOptions, "x" | "y" | "width" | "height" | "id" | "seed">,
) => {
const node = host.particles({
id: ctx.partId(name),
x: 0,
y: 0,
width: W,
height: H,
seed: ctx.seed * 101 + n++,
...o,
});
parts[name] = node;
children.push(node);
};
[...origins.values()].forEach(({ point, bursts }, o) => {
const first = bursts[0] as (typeof bursts)[number];
const emitter = (share: number) => ({
shape: "point" as const,
x: point[0],
y: point[1],
rate: 0,
bursts: bursts.map((b) => ({
frame: b.at,
count: Math.max(1, Math.round(b.count * share)),
})),
});
const total = Math.max(...bursts.map((b) => b.count));
const launch = {
direction: first.direction,
spread: first.spread,
speed: [1100 * s * first.power, 2100 * s * first.power] as [
number,
number,
],
gravity,
drag: 1.1,
// Paper wanders as it falls: curl noise reads as flutter.
noise: { strength: 260 * s, scale: 0.004 / s, speed: 1.4 },
lifetime,
...(floor !== undefined && { floor, bounce: 0.08 }),
};
palette.forEach((color, k) => {
system(`paper-${o}-${k}`, {
...launch,
max: Math.ceil(total / palette.length) * bursts.length + 8,
emitter: emitter(1 / palette.length),
shape: "square",
size: [13 * s, 11 * s],
sizeJitter: 0.45,
fade: [0.01, floor === undefined ? 0.25 : 0.12],
color,
});
});
if (props.ribbons)
system(`ribbons-${o}`, {
...launch,
max: Math.ceil(total * 0.2) * bursts.length + 8,
emitter: emitter(0.2),
shape: "spark",
stretch: 0.03,
size: [5 * s, 4 * s],
sizeJitter: 0.3,
fade: [0.01, 0.3],
color: palette.slice(0, 3),
});
if (props.sparkles)
system(`sparkles-${o}`, {
max: 80 * bursts.length,
emitter: emitter(0.35),
direction: first.direction,
spread: Math.min(360, first.spread * 1.6),
speed: [300 * s, 900 * s],
gravity: gravity * 0.15,
drag: 2.4,
lifetime: [0.35, 0.8],
shape: "soft",
size: [14 * s, 0],
sizeJitter: 0.5,
fade: [0, 0.5],
blend: "add",
color: ["#ffffff", c.highlight],
});
});
const last = Math.max(...props.bursts.map((b) => b.at));
return {
children,
width: W,
height: H,
duration: last + Math.ceil(lifetime[1] * fps),
origin: [0, 0],
cues: { bursts: cues } as ConfettiCues,
parts,
};
},
});
/**
* Celebration bursts of paper, ribbons and sparkles on the GPU that arc up,
* flutter down and can settle on a floor; bursts can follow cues.
*/
export const Confetti = /* @__PURE__ */ bindComponent(confetti);