Skip to main content

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.

Loading globe…

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.

  • center and zoom interpolate between steps, so the camera glides rather than jumps.
  • markers, counter, preset, title, and other options apply at the step boundary through setOptions.
  • The container must be tall enough to scroll through. 400vh for four beats is a sane start.
  • The canvas is position: sticky inside 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:

  1. Where you started. One point, zoomed in. Small numbers are fine here: they are the setup.
  2. The first expansion. Pull out one level. Something changed.
  3. The hard part. A quarter that did not go to plan, and what you did. Skipping this is why most recaps read like adverts.
  4. 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 preventDefault on 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