# 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.

> Live preview: see the scene playing at https://bettertyped.github.io/react-wrapped/docs/guides/custom-scene/
>
> 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`

```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](https://bettertyped.github.io/react-wrapped/docs/scenes/): 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

| Key | Type | What 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) => ReactNode` | What the scene draws, inside the frame's padding. |
| `animate` | `(context: SceneContext, props: P) => gsap.core.Timeline` | Builds the timeline that brings the scene in and holds it. Called once per video, not per play. |

### What animate is handed

| Key | Type | What it is |
| --- | --- | --- |
| `root` | `HTMLElement` | The 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. |
| `rng` | `Rng` | A 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](https://bettertyped.github.io/react-wrapped/docs/transitions/) 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`.

| Helper | What 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_OUT` | The ease arrivals use: `expo.out`. |
| `Lines` | A 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](https://bettertyped.github.io/react-wrapped/docs/theming/), 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.

| Key | Type | What it does |
| --- | --- | --- |
| `name` | `string` | The key the edit is stored under. Usually the prop's name. |
| `label` | `string` | What the editor calls the field. |
| `multiline?` | `boolean` | Edited in a box of several lines. |
| `read` | `(props: P) => string` | The text as the editor shows it. |
| `write` | `(props: P, value: string) => P` | The 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](https://bettertyped.github.io/react-wrapped/docs/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](https://bettertyped.github.io/react-wrapped/docs/scenes/chart/) does.

## Related

- [Scenes](https://bettertyped.github.io/react-wrapped/docs/scenes/)
- [Custom transition](https://bettertyped.github.io/react-wrapped/docs/guides/custom-transition/)
- [SceneEditor](https://bettertyped.github.io/react-wrapped/docs/player/scene-editor/)
- [Theming](https://bettertyped.github.io/react-wrapped/docs/theming/)
- [Formats](https://bettertyped.github.io/react-wrapped/docs/formats/)

Source: https://bettertyped.github.io/react-wrapped/docs/guides/custom-scene/
