Year in review
Wrapped-style recaps work because they are paced. One number, held long enough to land, then the next. A single dashboard showing all twelve months at once is the same data and nobody finishes it.
story() binds the camera to an element's scroll progress, so the globe becomes the visual track of
a page the reader controls.
The code
"use client";
import { useEffect, useRef } from "react";
import { createGlobe } from "canvas-globe";
export function YearInReview() {
const host = useRef(null);
const canvas = useRef(null);
useEffect(() => {
const globe = createGlobe(canvas.current, {
preset: "aurora",
autoRotate: false,
interactive: false,
zoomable: false,
});
globe.story(host.current, [
{
at: 0,
center: [72.58, 23.03],
zoom: 2.4,
markers: januaryCities,
counter: { value: 214, label: "customers" },
},
{
at: 0.33,
center: [20, 35],
zoom: 1.4,
markers: aprilCities,
counter: { value: 1180, label: "customers" },
},
{
at: 0.66,
center: [-60, 20],
zoom: 1.3,
markers: augustCities,
counter: { value: 2640, label: "customers" },
},
{
at: 1,
center: [100, 10],
zoom: 1,
markers: decemberCities,
counter: { value: 4218, label: "customers worldwide" },
title: { text: "68 countries", subtitle: "2024 in review" },
},
]);
return () => globe.destroy();
}, []);
return (
<section ref={host} style={{ minHeight: "400vh", position: "relative" }}>
<div style={{ position: "sticky", top: 0, height: "100vh" }}>
<canvas ref={canvas} style={{ width: "100%", height: "100%" }} />
</div>
<Beat>January: one office, 214 customers.</Beat>
<Beat>April: Europe opened up.</Beat>
<Beat>August: the Americas.</Beat>
<Beat>December: 68 countries.</Beat>
</section>
);
}
How story works
Each step needs an at between 0 and 1: its position in the container's scroll progress.
centerandzoominterpolate between steps, so the camera glides rather than jumps.markers,counter,preset,title, and other options apply at the step boundary throughsetOptions.- The container must be tall enough to scroll through.
400vhfor four beats is a sane start. - The canvas is
position: stickyinside it, so it stays put while the text scrolls past.
globe.story(element, steps, { onStep: (step, i) => highlight(i) });
globe.stopStory();
onStep is where you sync anything outside the canvas: highlighting the active paragraph, updating
a progress dot, firing an analytics event per beat.
Writing the beats
The structure that works, in four to six beats:
- Where you started. One point, zoomed in. Small numbers are fine here: they are the setup.
- The first expansion. Pull out one level. Something changed.
- The hard part. A quarter that did not go to plan, and what you did. Skipping this is why most recaps read like adverts.
- Where you ended. Fully zoomed out, the headline number, the title baked in.
Each beat is one sentence and one number. If a beat needs a paragraph, it is two beats.
Do not hijack the scroll
Scrollytelling gets a bad name from pages that steal the scrollbar. Rules:
- Never call
preventDefaulton wheel events. The reader's scroll is the reader's. - Keep the whole story under six screens. Past that, people leave mid-narrative and you have taught them your site is exhausting.
- Make it readable stopped. Freeze-frame any beat: the text and the number should still make sense without the transition.
- Honour reduced motion. The library disables its own animation automatically; make sure your beat transitions are not fighting it.
const calm = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (calm) {
globe.setOptions({ center: { lon: 100, lat: 10 } }); // final view, no story
} else {
globe.story(host, steps);
}
The version without scroll
Not every recap wants a long page. tour() flies between points on a timer: better for a modal, an
email-linked share page, or a screen in an office:
globe.tour(
[
{ lon: 72.58, lat: 23.03 },
{ lon: 20, lat: 35 },
{ lon: -60, lat: 20 },
{ lon: 100, lat: 10 },
],
{ dwell: 2800, zoom: 1.6, loop: true, onStep: (p, i) => setCaption(CAPTIONS[i]) },
);
zoom is set once for the whole tour rather than per point: a recap that zooms in and out on every
beat is nauseating anyway.
Record it and the recap becomes a video you can post:
const { promise } = globe.record({ duration: 12000, filename: "2024.webm" });
globe.tour(points, { dwell: 2800, loop: false });
await promise;
See Exporting.
Per-customer recaps
The version people actually share is the one about them. Same story, their data:
globe.setOptions({
markers: user.locations,
title: { text: `${user.name} shipped from 14 countries`, subtitle: "2024 with Acme" },
watermark: { image: "/logo.svg", text: "acme.com" },
});
const blob = await globe.exportBlob({ preset: "story" });
Generate it on the client, hand it to navigator.share, and you never store a personal graphic on
your servers, which reduces hosting work and simplifies privacy review. See
Social share card generator.
Next
- Motion:
flyTo,tour,storyand momentum - Launch announcement: a single beat, made cinematic
- Waitlist milestone: one number, celebrated