Skip to content
react-wrapped

Custom scene

A scene is a render and a timeline. defineScene takes both and gives back a scene function like the eleven built-in ones: it works with every transition, shows up on the timeline, opens in the scene editor and exports frame by frame. This guide builds one, a goal with a bar that fills, and lists the rules a scene has to keep.

Your goal for the year

0%

200 active days.So close.

182 of 200 days

Eighteen short. There is always next year.

The scene this guide builds: a percentage, a bar and a count, all on one timeline.

Installation

bash
pnpm add @react-wrapped/core gsap

The whole scene

This file is the scene in the preview. render draws it with the stage's own type classes, and animate brings it in with the motion kit and one tween of its own.

goal-scene.tsx
import { gsap } from "gsap";
import type { CSSProperties } from "react";

import {
  countUp,
  defineScene,
  EASE_OUT,
  hold,
  Lines,
  linesField,
  linesIn,
  rich,
  riseIn,
  setText,
  textField,
} from "@react-wrapped/core";

// Sizes are in the frame's own pixels: the stage is 1080 wide and is scaled as a whole to fit the page.
const TRACK: CSSProperties = {
  width: "100%",
  height: 28,
  margin: "56px 0 20px",
  overflow: "hidden",
  borderRadius: 14,
  background: "var(--wr-l1)",
};
const FILL: CSSProperties = { display: "block", height: "100%", borderRadius: 14, background: "var(--wr-l4)" };
const COUNT: CSSProperties = { marginBottom: 44, fontSize: 36, fontWeight: 600, color: "var(--wr-muted)" };

const percentOf = (reached: number, goal: number) => Math.round((reached / Math.max(1, goal)) * 100);

/** A goal and how far the year got: the percentage counts up while a bar fills to the same mark. */
export const goalScene = defineScene<{
  eyebrow: string;
  title: readonly string[];
  reached: number;
  goal: number;
  /** What is counted: "days", "books". */
  unit: string;
  lede: string;
}>({
  // The texts a viewer may rewrite from the scene editor. The numbers stay yours.
  fields: [textField("eyebrow", "Line above"), linesField("title", "Headline"), textField("lede", "Line under it")],
  render: ({ eyebrow, title, reached, goal, unit, lede }) => (
    <>
      <p className="wr-eyebrow">{rich(eyebrow)}</p>
      {/* `countUp` reads where to stop from `data-count` and what to put after the number from `data-suffix`. */}
      <div className="wr-mega" data-count={percentOf(reached, goal)} data-suffix="%">
        0%
      </div>
      <Lines lines={title} />
      <div style={TRACK}>
        <i data-goal-fill style={{ ...FILL, width: `${Math.min(100, percentOf(reached, goal))}%` }} />
      </div>
      {/* Rendered finished: a still of the scene (the timeline's hover preview) shows the markup as it stands. */}
      <p data-goal-count style={COUNT}>{`${reached} of ${goal} ${unit}`}</p>
      <p className="wr-lede">{rich(lede)}</p>
    </>
  ),
  animate: ({ root }, { reached, goal, unit }) => {
    const tl = gsap.timeline();
    // Query inside `root` only: every scene of the video is mounted on the same stage.
    const count = root.querySelector("[data-goal-count]");
    const done = { value: 0 };

    riseIn(tl, root.querySelectorAll(".wr-eyebrow, .wr-mega"), { at: 0 });
    countUp(tl, root.querySelector(".wr-mega"), { at: 0.2, duration: 1.6 });
    linesIn(tl, root, { at: 0.35 });
    tl.from(
      root.querySelector("[data-goal-fill]"),
      { scaleX: 0, transformOrigin: "left center", duration: 1.6, ease: EASE_OUT },
      0.2,
    );
    riseIn(tl, count, { at: 0.2 });
    // A tween on a plain object, so the count is on the timeline too and seeks with everything else.
    tl.to(
      done,
      {
        value: reached,
        duration: 1.6,
        ease: "power3.out",
        onUpdate: () => {
          if (count) setText(count, `${Math.round(done.value)} of ${goal} ${unit}`);
        },
      },
      0.2,
    );
    riseIn(tl, root.querySelector(".wr-lede"), { at: 1.2 });
    // The scene ends on a pause for reading. It never takes itself out: the transition does that.
    hold(tl, 1.4);
    return tl;
  },
});

Place it

What defineScene returns is called like any ready-made scene: the props, then the key and the label.

tsx
import { goalScene } from "./goal-scene";

export const scene = goalScene(
  {
    eyebrow: "Your goal for the year",
    title: ["200 active days.", "*So close.*"],
    reached: 182,
    goal: 200,
    unit: "days",
    lede: "Eighteen short. There is always next year.",
  },
  { key: "goal", label: "Goal" },
);

defineScene

KeyTypeWhat it does
align?"start" | "center"Where the content sits: against the left edge, or centred. Left when left out.
fields?readonly SceneField<P>[]The texts a viewer may rewrite in the scene editor.
render(props: P) => ReactNodeWhat the scene draws, inside the frame's padding.
animate(context: SceneContext, props: P) => gsap.core.TimelineBuilds the timeline that brings the scene in and holds it. Called once per video, not per play.

What animate is handed

KeyTypeWhat it is
rootHTMLElementThe scene's own element. Query inside it and nowhere else.
format"story" | "square" | "landscape"The frame the video plays in, for a scene that moves differently in a wide one.
rngRngA seeded random source: next(), int(max), pick(items), fork(label). The same link always draws the same values.

The rules

  • A scene brings itself in and never takes itself out. End on a hold and return. The way out is a transition the player adds, which is why your scene works with all of them.
  • Only add tweens. The player seeks this timeline, plays it backwards and jumps into its middle. A timer, a delayedCall or a callback that changes something outside the timeline will not follow it there.
  • Everything is on the timeline. A number that counts is a tween on a plain object with an onUpdate, as in the example. Nothing moves by CSS animation or transition: the exported video sets each frame's moment on the timeline and draws it, and CSS motion would stand still.
  • Text is set through `setText`. It edits the text node in place. Assigning textContent on every frame of a counter inserts a new node each time, and a page with :has() rules restyles itself on every insertion.
  • Query inside `root`. All scenes of a video are mounted on one stage at once, so a selector run on the document finds other scenes' elements too.
  • Render the finished picture. A still of the scene, such as the timeline's hover card, shows the markup as rendered, with every data-count set to its final number. Start values belong in from tweens, not in the markup.
  • Props are plain data. The player compares JSON.stringify(props) to decide whether a scene changed and has to be built again. A function or an element in the props is invisible to that check.

The motion kit

These are the helpers the built-in scenes are made of. Use them and your scene moves like the rest of the video. All of them come from @react-wrapped/core.

HelperWhat it does
linesIn(tl, root, { at, stagger? })Every heading line under root rises out of its own mask, 0.09 seconds apart.
riseIn(tl, targets, { at, stagger? })Supporting text and objects come up out of a blur, 0.9 seconds each.
countUp(tl, element, { at, duration?, style? })Counts an element up to its data-count and puts its data-suffix after the number. style is climb or slot.
hold(tl, seconds)A pause for reading. Every scene ends on one.
hasTargets(targets)Whether a selector found anything. Guard a tween on a part the scene does not always render, and GSAP has nothing empty to warn about.
setText(element, text)Changes an element's text without swapping its text node.
EASE_OUTThe ease arrivals use: expo.out.
LinesA heading as lines, each in a mask, ready for linesIn. Takes lines and an optional className.
headingLines(text)Splits a one-line heading into two balanced lines at a word. Text with its own line breaks keeps them.
rich(text)Turns the two text marks into elements: single stars for the accent colour, double stars for bold.
textField(name, label)A field for a prop that is one string.
linesField(name, label)A field for a prop that is an array of lines, edited one line per row.

The type classes are plain CSS in the core stylesheet: wr-eyebrow for the small line on top, wr-mega for a giant number, wr-title for the headline (it is what Lines uses) and wr-lede for the line at the bottom. For anything else, style it yourself with the stage's variables, as the bar in the example does with --wr-l1 and --wr-l4.

Fields

fields lists what a viewer may rewrite. textField and linesField cover a string and an array of lines. For anything else write the field yourself: how to read the text out of the props, and how to put an edited text back.

KeyTypeWhat it does
namestringThe key the edit is stored under. Usually the prop's name.
labelstringWhat the editor calls the field.
multiline?booleanEdited in a box of several lines.
read(props: P) => stringThe text as the editor shows it.
write(props: P, value: string) => PThe props with the viewer's text put back in.

Sizes and time

  • The frame's short side is always 1080 pixels and the whole stage is scaled to the page. Size things in those pixels: a 128 pixel headline, a 44 pixel line.
  • A scene's content sits inside the frame's padding, in a column 920 pixels wide in the tall and square formats.
  • defineScene fits a scene that is too tall by scaling it as a whole, down to 60 percent, before animate runs. Test yours in all three formats.
  • After your hold, the scene is kept up for as long as its text takes to read, when that is longer.
  • For a chart inside a scene, render Chart and call animateChart(tl, root, at), both from @react-wrapped/charts. That is all the chart scene does.

Last updated . Read this page as Markdown.