Year in review videos, in React.
react-wrapped turns a year of numbers into a short video that plays in the page. You describe the year as a list of scenes. The library brings the stage, the animation, the transitions between scenes, a player with a timeline you can edit, and a button that saves the video as a file. It started as the engine behind Hype Stack's Wrapped, where it plays real developer years. Use it whole as one component, or take single packages: only the charts, only the player, only the share cards.
pnpm add @react-wrapped/kit gsapLoad the stylesheet once, anywhere in your app:
import "@react-wrapped/kit/styles.css";You need React 19 and GSAP 3.13 or newer. The stylesheet brings everything the components use and keeps to its own wr-
classes, so there is no Tailwind to set up and no reset to load.
A Wrapped is a subject, a seed and a list of scenes. Each scene is a function you call with its content and a key.
import { useState } from "react";
import { bigNumberScene, chartScene, DEFAULT_WRAPPED_CONFIG, summaryScene, WrappedView } from "@react-wrapped/kit";
import "@react-wrapped/kit/styles.css";
const subject = { handle: "ada", name: "Ada", avatarUrl: null, caption: "Orbit Wrapped", period: "2026" };
const weeks = [4, 9, 6, 14, 22, 31, 18, 12, 9, 15, 7, 11];
const scenes = [
bigNumberScene(
{ eyebrow: "This year", value: 1687, title: ["commits."], lede: "**4.6** a day.", counter: "climb", variant: 0 },
{ key: "total", label: "Total" },
),
chartScene(
{
eyebrow: "Your year",
title: ["Then came", "*launch week.*"],
lede: "The busiest stretch, lit.",
chart: { type: "spark", values: weeks, band: { from: 4, to: 6, label: "Launch week" } },
},
{ key: "launch", label: "Launch week" },
),
summaryScene(
{ handle: "ada", avatarUrl: null, caption: "Orbit Wrapped", stats: [{ label: "Commits", value: "1,687" }] },
{ key: "card", label: "Share card" },
),
];
export function MyWrapped() {
const [config, setConfig] = useState(DEFAULT_WRAPPED_CONFIG);
return <WrappedView subject={subject} seed="ada-2026" scenes={scenes} config={config} onConfigChange={setConfig} />;
}That renders the player, the timeline and the settings panel. A few things worth knowing:
- A chart is data.
chart: { type: "spark", values }is the whole chart. The scene draws it and plays its entrance. - Text takes two marks.
*accent*turns words the palette's accent colour,**bold**makes them bold. - The seed is the video's luck. It picks the transition after each scene, so the same seed always plays the same video.
configis everything a viewer can change: palette, format, speed, scene order, rewritten texts. Keep it in state, or in the URL so the link is the video.shareandopeningare optional.shareadds the download button and the cards to keep.openingadds the year scan that finds hidden scenes.
The full example plays a whole made-up year with all of it switched on.
Eight packages, released together under one version number. Two packages with the same version always fit each other.
| Package | What is in it | Version | Downloads |
|---|---|---|---|
| @react-wrapped/kit | Everything below in one install, plus WrappedView, the whole page |
||
| @react-wrapped/core | The stage, the motion kit, palettes, formats and transitions | ||
| @react-wrapped/charts | 21 charts and their entrances | ||
| @react-wrapped/scenes | 11 ready-made scenes and the pixel art set | ||
| @react-wrapped/player | The player, the timeline, the scene editor and the settings panel | ||
| @react-wrapped/scan | The year scan that finds hidden scenes | ||
| @react-wrapped/graphics | The share card, insight cards and the gallery under the video | ||
| @react-wrapped/marks | The marks pressed into the bottom right corner of the stage, as data |
Start with @react-wrapped/kit. Reach for the single packages when you only want a part, for example charts on a stage
with no player around them:
pnpm add @react-wrapped/core @react-wrapped/charts gsapEvery piece below plays live on the designs page, in any palette and format.
A chart is a plain object with a type and its numbers. Each one plays in the order it is read: the structure first,
the numbers next, the point last. All charts:
Spark 路 Bars 路 Funnel 路 Pulse 路 Rings 路 Donut 路 Days 路 Dial 路 Meter 路 Pixel area 路 Wheel 路 Day clock 路 River 路 Dot share 路 Then and now 路 Bubbles 路 Month up close 路 Radar 路 Where you stand 路 Mirror 路 Week by hour
How one scene hands over to the next. Fourteen cover the frame and clear onto the next scene, five move the scene itself. The seed picks one per scene, or you do. All transitions:
Layers 路 Slash 路 Arrow 路 Wave 路 Drip 路 Bloom 路 Pulse 路 Blink 路 Spin 路 Fan 路 Doors 路 Brush 路 Scan 路 Flash 路 Iris 路 Wipe 路 Lift 路 Drop 路 Zoom
| Part | What you get | Where |
|---|---|---|
| 11 ready-made scenes | Opening, big number, big day, bars, chart, ranked list, split, fact, persona, trophy case and the share card | Designs |
| Your own scenes | defineScene({ render, animate }) with the same motion kit the built-in scenes use |
Recipes |
| Player | Play, pause, replay, full screen, and a timeline where a viewer hides, reorders and rewrites scenes | Full example |
| Year scan | A sweep over the year grid that finds hidden scenes and adds them to the video | Full example |
| Share cards | The year on one card, plus an insight card per finding, drawn in the browser and ready to download | Full example |
| Three formats | 9:16, 1:1 and 16:9. Scenes are laid out once and fitted to each frame | Formats |
| Ten palettes | Each in dark and light, plus a palette built from your own accent colour. All of it is CSS variables | Theming |
| Video file | The download button draws every frame at full size in the browser and saves the result. No server, no screen capture | Full example |
defineScene takes a render and an animate. The animation brings the scene in and holds it. The player adds the way
out, so a custom scene works with every transition.
import { gsap } from "gsap";
import { defineScene, hold, Lines, linesIn, riseIn } from "@react-wrapped/core";
const quoteScene = defineScene<{ title: readonly string[]; quote: string }>({
align: "center",
render: ({ title, quote }) => (
<>
<Lines lines={title} />
<p className="wr-lede wr-rise">{quote}</p>
</>
),
animate: ({ root }) => {
const tl = gsap.timeline();
linesIn(tl, root, { at: 0 });
riseIn(tl, root.querySelectorAll(".wr-rise"), { at: 0.5 });
return hold(tl, 1.5);
},
});
const scene = quoteScene(
{ title: ["Commit message", "*of the year.*"], quote: "fix: it works now, do not ask" },
{ key: "quote", label: "Quote" },
);Query inside root, never outside it, and build everything on the timeline you return. That is what lets the player
scrub the scene and the export draw it frame by frame.
The palette and the background mode live in the config. Start from the defaults and change what you need.
import { useState } from "react";
import { DEFAULT_WRAPPED_CONFIG, type WrappedConfig } from "@react-wrapped/core";
const [config, setConfig] = useState<WrappedConfig>({ ...DEFAULT_WRAPPED_CONFIG, palette: "grape", mode: "light" });cuts maps a scene key to the transition that takes it out. Scenes you leave out keep the one the seed picked.
const config: WrappedConfig = { ...DEFAULT_WRAPPED_CONFIG, cuts: { total: "drip", launch: "iris" } };defineTransition puts yours next to the nineteen built-in ones, in the config and in the scene editor's list. A cover
is three layers and three states; coverCut plays them the way every built-in cover plays.
import { coverCut, defineTransition } from "@react-wrapped/core";
// Once, where your app starts, before a player is on the page.
defineTransition({
id: "curtain",
label: "Curtain",
cover: (
<>
<i />
<i />
<i />
</>
),
play: (tl, { root, group }) => {
if (group)
coverCut(
tl,
{ root, group },
{ hidden: { yPercent: -101 }, covering: { yPercent: 0 }, cleared: { yPercent: 101 } },
);
},
});Every word the library shows on its own is a label. Wrap your page in WrappedProvider and replace the ones you want. A
sentence with a number in it is a function, so a language with three plural forms can have all three.
import { WrappedProvider } from "@react-wrapped/core";
<WrappedProvider
locale="pl-PL"
labels={{
player: { play: "Odtw贸rz", pause: "Pauza" },
scan: { start: "Skanuj m贸j rok", findAll: "Znajd藕 wszystkie" },
}}
>
<MyWrapped />
</WrappedProvider>;The video's palette belongs to the viewer. The controls around it read a few variables of their own, which you can set
on any element above them. Dark values apply under a .dark class.
body {
--wr-ui-primary: #7c3aed;
--wr-ui-radius: 0.5rem;
--wr-font: "Space Grotesk", sans-serif;
}Every part of the controls is a slot with a name. It carries the name as data-wr-slot, and takes a class for it
through classNames, on one component or on WrappedProvider for the whole page.
<WrappedPlayer
{...video}
classNames={{
"player-play": "my-play-button",
"timeline-segment-bar": "my-bar",
}}
/>With Tailwind, import the stylesheet into a layer before utilities and your utilities win over the library's classes:
@import "tailwindcss";
@import "@react-wrapped/kit/styles.css" layer(components);The styling guide lists every slot.
react-wrapped is cut from Hype Stack, the full-stack template I build. Here is what else I maintain.
pnpm install
pnpm build
pnpm typecheck
pnpm lint
pnpm testNode 24, pnpm 12, Nx, Vite 8, tsgo, oxlint and oxfmt. DESIGN.md explains how the packages fit together and the rules a change has to keep. Commits follow conventional commits, because the next version number is worked out from them.
GSAP is a peer dependency. You install it yourself and it comes under its own license, which is free to use, commercial projects included, but is not MIT. Read the GSAP standard license before you ship.