Skip to content
react-wrapped

Build the page from parts

WrappedView is a layout and about a hundred lines of wiring around five components that are all exported. When you want a different page (the settings in a drawer, the scan on its own route, no gallery) take the parts and do the wiring yourself. This guide goes through that wiring with a page that does everything WrappedView does and imports nothing from the kit.

The parts

PartPackageIts job on the page
`WrappedPlayer`@react-wrapped/playerPlays the scenes that are on. Its timeline edits the cut.
`WrappedConfigurator`@react-wrapped/playerChanges what applies to the whole video.
`SceneEditor`@react-wrapped/playerChanges one scene's texts and its transition.
`YearScan`@react-wrapped/scanFinds the hidden scenes.
`GraphicsGallery`@react-wrapped/graphicsHands out the pictures.
arrangeScenes, editsFor, luckyCut, resolvePalette@react-wrapped/coreThe small functions between them.
bash
pnpm add @react-wrapped/core @react-wrapped/player @react-wrapped/scan @react-wrapped/graphics @react-wrapped/scenes gsap
tsx
import "@react-wrapped/core/styles.css";
import "@react-wrapped/player/styles.css";
import "@react-wrapped/scan/styles.css";
import "@react-wrapped/graphics/styles.css";

One config, one list of opened drops

The page owns two pieces of state. The config is everything a viewer can change. The list of opened drops is what the scan has found. Every part reads from those two and reports back to them, and nothing else is shared.

From your scenes to the ones that play

Four steps turn the scenes your director returns into the array the player gets. Each is memoized on what it reads, so a change to one scene's text does not rebuild the others.

tsx
// The drops opened so far. The director is asked again each time one is opened: that is how a find joins the video.
const { opened, open } = useOpenedDrops(null);
const cut = useMemo(() => directSample(sample.year, sample.traits, opened), [sample, opened]);
const scenes = useMemo(() => withDayScene(cut.scenes, sample.hours), [cut.scenes, sample.hours]);
// The viewer's order. A scene they never placed (a new find) goes after the scene the director put before it.
const arranged = useMemo(() => arrangeScenes(scenes, config.order), [scenes, config.order]);
// The viewer's rewritten texts, scene by scene. A scene nobody edited stays the same object.
const ordered = useMemo(
  () =>
    arranged.map((scene) => {
      const edits = editsFor(config.edits, scene.key);
      return Object.keys(edits).length > 0 ? scene.withEdits(edits) : scene;
    }),
  [arranged, config.edits],
);
// What plays. The timeline still gets every scene, so one that is off can be switched back on.
const playing = useMemo(
  () => ordered.filter((scene) => !config.hidden.includes(scene.key)),
  [ordered, config.hidden],
);
  • arrangeScenes applies config.order and keeps a newly found scene in its story slot.
  • editsFor picks one scene's edits out of the flat config.edits, and scene.withEdits applies them.
  • The player gets playing. The timeline, through editing.scenes, gets ordered, so a hidden scene stays on the row.

Editing from the timeline

editing is three callbacks. Two of them write to the config. The third opens the scene editor by remembering a key.

tsx
const [editingKey, setEditingKey] = useState<string | null>(null);
const keys = ordered.map((scene) => scene.key);
const editing = {
  scenes: ordered,
  hidden: config.hidden,
  onToggle: (key: string, on: boolean) =>
    setConfig({
      ...config,
      hidden: on ? config.hidden.filter((hidden) => hidden !== key) : [...config.hidden, key],
    }),
  onMove: (key: string, by: -1 | 1) => {
    const from = keys.indexOf(key);
    const to = from + by;
    if (from === -1 || to < 0 || to >= keys.length) return;
    const order = [...keys];
    [order[from], order[to]] = [order[to], order[from]];
    setConfig({ ...config, order });
  },
  onEdit: setEditingKey,
};

A transition per scene

The scene editor needs to know the way out of the scene it edits: the transition it plays now, the one the seed picked, and the scene that comes next. luckyCut(seed, key) is the seed's pick. Saving keeps a pick only when it differs from that, so the config stays short.

tsx
// The way out of the scene being edited. The last scene playing has none, and neither has one switched off.
const editingAt = playing.findIndex((scene) => scene.key === editingKey);
const editingNext = editingAt === -1 ? undefined : playing[editingAt + 1];
const lucky = editingKey ? luckyCut(cut.seed, editingKey) : null;
const editingCut: SceneCut | null =
  editingKey && editingNext && lucky
    ? { style: config.cuts[editingKey] ?? lucky, original: lucky, next: editingNext }
    : null;
const saveScene = (values: Record<string, string>, style: WrappedTransition | null) => {
  if (!editingKey) return;
  const others = Object.entries(config.edits).filter(([name]) => !name.startsWith(`${editingKey}.`));
  const mine = Object.entries(values).map(([field, value]): [string, string] => [`${editingKey}.${field}`, value]);
  const cuts = Object.fromEntries(Object.entries(config.cuts).filter(([key]) => key !== editingKey));
  // Only a transition that differs from the seed's own pick is kept.
  if (style && style !== lucky) cuts[editingKey] = style;
  setConfig({ ...config, edits: Object.fromEntries([...others, ...mine]), cuts });
};

The scan, first or under the video

YearScan adds scenes by calling onOpen, which here is the open of useOpenedDrops: the list changes, the director runs again, and the new scene is in cut.scenes. Give a drop's scene the key drop- followed by the drop's id: the found card flies to the timeline segment with that key. For the scan first flow, useScanGate says when the scan is the page: until one run has come to rest with something found, and never again after that. Render the scan alone while gated is true, and hand onRunChange to YearScan.

tsx
const { gated, onRunChange } = useScanGate({ enabled: gate, found: opened.length, id: cut.seed });
// The video takes the scan's place at the top, so that is where the page should be looking.
const rootRef = useRef<HTMLDivElement>(null);
const wasGated = useRef(gated);
useLayoutEffect(() => {
  if (wasGated.current && !gated) rootRef.current?.scrollIntoView({ block: "start" });
  wasGated.current = gated;
}, [gated]);

The graphics

The gallery takes a list of graphics and the palette. The list is memoized on the palette, because each tile draws its image again when its image function changes.

tsx
const { palette: paletteId, accent, mode } = config;
const palette = useMemo(() => resolvePalette({ palette: paletteId, accent, mode }), [paletteId, accent, mode]);
const insights = useMemo(() => sampleInsights(sample.year, sample.traits), [sample]);
const graphics = useMemo((): WrappedGraphic[] => {
  const hidden = new Set(cut.pending.map((drop) => drop.id));
  return [
    {
      id: "share-card",
      title: "Share card",
      line: "The year on one card, sized for a post.",
      image: () => renderShareCard(cut.card, palette),
      fileName: `${sample.year.handle}-wrapped`,
      wide: true,
    },
    ...insights.map((card) => ({
      id: `insight-${card.id}`,
      title: card.name,
      line: card.insight,
      image: () => renderInsightCard(card, palette),
      fileName: `${sample.year.handle}-${card.id}`,
      wide: true,
      // A card that belongs to a drop stays locked until the scan has found that drop.
      locked: card.traitId ? hidden.has(card.traitId) : false,
    })),
  ];
}, [cut.card, cut.pending, insights, palette, sample.year.handle]);

The layout is yours

The parts bring no page layout. The example lays them out with inline styles on the chrome tokens, so its panels follow the same light and dark as the parts inside them. Two things to know. Give the player's box a height when its edges should line up with a column beside it; with no height it takes its size from its width. And the scan, while it is the page, wants a box about as tall as the video will be, so the page does not jump when the video arrives.

The whole file

The sample year comes from @react-wrapped/sample, a package inside the repository that is not published: it stands in for your own data and director. The example also adds one scene of its own with chartScene, to show a page mixing its scenes into a director's.

from-parts.tsx
import { type CSSProperties, useLayoutEffect, useMemo, useRef, useState } from "react";

import { hourName, type SceneChart } from "@react-wrapped/charts";
import {
  arrangeScenes,
  DEFAULT_WRAPPED_CONFIG,
  editsFor,
  luckyCut,
  resolvePalette,
  type SceneInstance,
  type WrappedConfig,
  type WrappedTransition,
} from "@react-wrapped/core";
import { GraphicsGallery, renderInsightCard, renderShareCard, type WrappedGraphic } from "@react-wrapped/graphics";
import {
  type SceneCut,
  SceneEditor,
  ShareActions,
  WrappedConfigurator,
  WrappedPlayer,
  type WrappedPlayerHandle,
} from "@react-wrapped/player";
import { directSample, formatShort, hoursOfDay, sampleInsights, sampleTraits, sampleYear } from "@react-wrapped/sample";
import { useOpenedDrops, useScanGate, YearScan } from "@react-wrapped/scan";
import { chartScene } from "@react-wrapped/scenes";

/*
 * The same page as the full Wrapped example, put together by hand: no `WrappedView`, and nothing imported from
 * `@react-wrapped/kit`. Every piece comes from the package that owns it. The layout is the page's own, written as
 * inline styles on the library's chrome tokens, so it follows the same light and dark as the parts inside it.
 */

const PAGE: CSSProperties = { display: "grid", gap: 12, minWidth: 0 };
// The player and the column beside it: side by side where there is room, stacked on a phone.
const TOP: CSSProperties = { display: "flex", flexWrap: "wrap", gap: 12 };
const PANEL: CSSProperties = {
  minWidth: 0,
  padding: 20,
  borderRadius: 28,
  background: "var(--wr-ui-card)",
  color: "var(--wr-ui-fg)",
  boxShadow: "inset 0 0 0 1px var(--wr-ui-border)",
};
// The player fits its frame into the room it is given, so its panel has a height.
const PLAYER_PANEL: CSSProperties = { ...PANEL, flex: "999 1 420px", display: "grid", height: "min(780px, 92vh)" };
const SIDE: CSSProperties = { flex: "1 1 320px", display: "grid", gap: 12, alignContent: "start", minWidth: 0 };
// As the page, the scan takes the box the video is about to take.
const GATE_BOX: CSSProperties = { display: "grid", height: "min(780px, 92vh)", minHeight: 420 };
/** A scene this page adds to the director's own: the hours of the day on a clock, right after the week. */
function withDayScene(scenes: readonly SceneInstance[], hours: readonly number[]): SceneInstance[] {
  const chart: SceneChart = { type: "clock", hours };
  const day = chartScene(
    {
      eyebrow: "Your day",
      title: ["Your best hour is", `*${hourName(hours.indexOf(Math.max(...hours)))}.*`],
      chart,
      lede: "Commits by hour of the day. The lit hour is your busiest.",
    },
    { key: "day", label: "Your day" },
  );
  const after = scenes.findIndex((scene) => scene.key === "week");
  return [...scenes.slice(0, after + 1), day, ...scenes.slice(after + 1)];
}

export function FromParts({ gate = false }: { gate?: boolean }) {
  const [config, setConfig] = useState<WrappedConfig>({ ...DEFAULT_WRAPPED_CONFIG, format: "story" });
  const sample = useMemo(() => {
    const year = sampleYear();
    return { year, traits: sampleTraits(year), hours: hoursOfDay(year) };
  }, []);

  /* ---------- the scenes ---------- */

  // The drops opened so far. The director is asked again each time one is opened: that is how a find joins the video.
  const { opened, open } = useOpenedDrops(null);
  const cut = useMemo(() => directSample(sample.year, sample.traits, opened), [sample, opened]);
  const scenes = useMemo(() => withDayScene(cut.scenes, sample.hours), [cut.scenes, sample.hours]);
  // The viewer's order. A scene they never placed (a new find) goes after the scene the director put before it.
  const arranged = useMemo(() => arrangeScenes(scenes, config.order), [scenes, config.order]);
  // The viewer's rewritten texts, scene by scene. A scene nobody edited stays the same object.
  const ordered = useMemo(
    () =>
      arranged.map((scene) => {
        const edits = editsFor(config.edits, scene.key);
        return Object.keys(edits).length > 0 ? scene.withEdits(edits) : scene;
      }),
    [arranged, config.edits],
  );
  // What plays. The timeline still gets every scene, so one that is off can be switched back on.
  const playing = useMemo(
    () => ordered.filter((scene) => !config.hidden.includes(scene.key)),
    [ordered, config.hidden],
  );

  /* ---------- editing from the timeline ---------- */

  const [editingKey, setEditingKey] = useState<string | null>(null);
  const keys = ordered.map((scene) => scene.key);
  const editing = {
    scenes: ordered,
    hidden: config.hidden,
    onToggle: (key: string, on: boolean) =>
      setConfig({
        ...config,
        hidden: on ? config.hidden.filter((hidden) => hidden !== key) : [...config.hidden, key],
      }),
    onMove: (key: string, by: -1 | 1) => {
      const from = keys.indexOf(key);
      const to = from + by;
      if (from === -1 || to < 0 || to >= keys.length) return;
      const order = [...keys];
      [order[from], order[to]] = [order[to], order[from]];
      setConfig({ ...config, order });
    },
    onEdit: setEditingKey,
  };

  // The way out of the scene being edited. The last scene playing has none, and neither has one switched off.
  const editingAt = playing.findIndex((scene) => scene.key === editingKey);
  const editingNext = editingAt === -1 ? undefined : playing[editingAt + 1];
  const lucky = editingKey ? luckyCut(cut.seed, editingKey) : null;
  const editingCut: SceneCut | null =
    editingKey && editingNext && lucky
      ? { style: config.cuts[editingKey] ?? lucky, original: lucky, next: editingNext }
      : null;
  const saveScene = (values: Record<string, string>, style: WrappedTransition | null) => {
    if (!editingKey) return;
    const others = Object.entries(config.edits).filter(([name]) => !name.startsWith(`${editingKey}.`));
    const mine = Object.entries(values).map(([field, value]): [string, string] => [`${editingKey}.${field}`, value]);
    const cuts = Object.fromEntries(Object.entries(config.cuts).filter(([key]) => key !== editingKey));
    // Only a transition that differs from the seed's own pick is kept.
    if (style && style !== lucky) cuts[editingKey] = style;
    setConfig({ ...config, edits: Object.fromEntries([...others, ...mine]), cuts });
  };

  /* ---------- the scan first ---------- */

  // With `gate`, the scan is the page until one run has come to rest with something found.
  const { gated, onRunChange } = useScanGate({ enabled: gate, found: opened.length, id: cut.seed });
  // The video takes the scan's place at the top, so that is where the page should be looking.
  const rootRef = useRef<HTMLDivElement>(null);
  const wasGated = useRef(gated);
  useLayoutEffect(() => {
    if (wasGated.current && !gated) rootRef.current?.scrollIntoView({ block: "start" });
    wasGated.current = gated;
  }, [gated]);

  /* ---------- the graphics to keep ---------- */

  const { palette: paletteId, accent, mode } = config;
  const palette = useMemo(() => resolvePalette({ palette: paletteId, accent, mode }), [paletteId, accent, mode]);
  const insights = useMemo(() => sampleInsights(sample.year, sample.traits), [sample]);
  const graphics = useMemo((): WrappedGraphic[] => {
    const hidden = new Set(cut.pending.map((drop) => drop.id));
    return [
      {
        id: "share-card",
        title: "Share card",
        line: "The year on one card, sized for a post.",
        image: () => renderShareCard(cut.card, palette),
        fileName: `${sample.year.handle}-wrapped`,
        wide: true,
      },
      ...insights.map((card) => ({
        id: `insight-${card.id}`,
        title: card.name,
        line: card.insight,
        image: () => renderInsightCard(card, palette),
        fileName: `${sample.year.handle}-${card.id}`,
        wide: true,
        // A card that belongs to a drop stays locked until the scan has found that drop.
        locked: card.traitId ? hidden.has(card.traitId) : false,
      })),
    ];
  }, [cut.card, cut.pending, insights, palette, sample.year.handle]);

  // The player's handle: the share row borrows the stage from it to make the video file.
  const player = useRef<WrappedPlayerHandle>(null);
  // While the file is being made the stage is being filmed frame by frame: nothing else may move it.
  const [rendering, setRendering] = useState(false);

  return (
    <div ref={rootRef} style={PAGE}>
      {/* While the scan is the page there is no video. The scan below keeps its place in the tree either way. */}
      {gated ? null : (
        <div style={TOP}>
          <div style={PLAYER_PANEL}>
            <WrappedPlayer
              ref={player}
              subject={cut.subject}
              seed={cut.seed}
              scenes={playing}
              config={config}
              editing={editing}
            />
          </div>
          <div style={SIDE}>
            <div style={PANEL}>
              <ShareActions
                player={player}
                config={config}
                fileName={`${sample.year.handle}-wrapped.mp4`}
                onRendering={setRendering}
              />
            </div>
            <div style={PANEL} inert={rendering}>
              <WrappedConfigurator config={config} onChange={setConfig} />
            </div>
          </div>
        </div>
      )}
      <div style={gated ? GATE_BOX : undefined}>
        <YearScan
          calendar={cut.calendar}
          palette={palette}
          drops={cut.drops}
          found={opened}
          onOpen={open}
          describeDay={(day) => formatShort(sample.year.days[day].date)}
          gate={gated}
          onRunChange={onRunChange}
        />
      </div>
      {gated ? null : <GraphicsGallery graphics={graphics} palette={palette} />}
      <SceneEditor
        scene={ordered.find((scene) => scene.key === editingKey) ?? null}
        original={arranged.find((scene) => scene.key === editingKey) ?? null}
        cut={editingCut}
        config={config}
        onSave={saveScene}
        onClose={() => setEditingKey(null)}
      />
    </div>
  );
}

What WrappedView adds on top

  • The loading state in the same layout. Render `WrappedPlayerLoading` where the player will be.
  • A viewer mode: with editable off there is no scan, no settings and no editing, only the video and what you pass as viewer.
  • The settings column sliding open instead of appearing, and the video rising into the place the scan held.

Last updated . Read this page as Markdown.