@moritzbrantner/storytelling
v0.5.0
Published
Serializable story documents, branching React playback, and Remotion or Three adapters.
Downloads
251
Readme
@moritzbrantner/storytelling
Serializable story documents, branching React playback, and Remotion or Three-friendly rendering helpers.
Main APIs
defineStory(story)/validateStory(story)validateStoryDocument(story)/assertStoryDocument(story)resolveStoryPath(story, options)/buildStoryTimeline(story, options)compileStory(story)/enumerateStoryPaths(story, options)analyzeStory(story, options)/applyStoryPatch(story, patch, options)serializeStorySnapshot(...)/parseStorySnapshot(...)@moritzbrantner/storytelling/corefor non-React validation, graph, path, authoring, and patch helpers@moritzbrantner/storytelling/schemaforstoryDocumentJsonSchemauseStoryRuntime(...),StoryPlayer,StoryControls,StoryScroller,StoryScrollTimeline,StoryProgress, andStoryMinimapcreateStoryRendererRegistry(...),getStoryRendererKey(...), andgetStoryStageProps(...)@moritzbrantner/storytelling/mediafor media-oriented stage helpers@moritzbrantner/storytelling/workflowfor workflow-editor document conversion@moritzbrantner/storytelling/timelinefor timeline-editor document conversion@moritzbrantner/storytelling/remotionfor frame-synced compositions@moritzbrantner/storytelling/threefor Three.js stage rendering
See docs/API.md for the compact public API reference.
Story schema
Stories are serializable documents. Nodes can be linear with next, branching
with choices, nested with children, or terminal when no continuation is
present. Node ids are globally unique across top-level and nested nodes.
import { defineStory } from "@moritzbrantner/storytelling";
export const story = defineStory({
id: "signal",
title: "Signal",
openingNodeId: "wake",
nodes: [
{
id: "wake",
title: "Wake the observatory",
content: [{ type: "paragraph", text: "A signal reaches the tower." }],
choices: [
{ id: "answer", label: "Answer", target: "answer-node" },
{ id: "trace", label: "Trace", target: "trace-node" },
],
},
{
id: "answer-node",
title: "The pilot responds",
next: "ending",
},
{
id: "trace-node",
title: "The harbor appears",
stage: { renderer: "map" },
},
{
id: "ending",
title: "Contact",
},
],
});validateStory() uses strict published-document validation by default and
rejects blank fields, invalid ids, duplicate node ids, duplicate choice ids,
missing targets, missing opening nodes, invalid content blocks, unsafe numeric
durations, nodes with both next and choices, function-bearing document
fields, and cycles. resolveStoryPath() returns the current path for a
StorySnapshot, one choose action, or a static routeChoiceIds route, while
buildStoryTimeline() converts that path into frame ranges for video-oriented
renderers.
Nested nodes play as a depth-first flattened sequence. A parent scene renders
first, then its children render in order, and the final descendant falls through
to the parent next target. Generic StoryScrollScene[] arrays remain flat.
export const nestedStory = defineStory({
id: "nested-report",
title: "Nested Report",
openingNodeId: "chapter",
nodes: [
{
id: "chapter",
title: "Chapter",
next: "ending",
children: [
{ id: "chapter-scene-a", title: "Scene A" },
{ id: "chapter-scene-b", title: "Scene B" },
],
},
{ id: "ending", title: "Ending" },
],
});
resolveStoryPath(nestedStory, { autoAdvanceLinearNodes: true }).nodes.map((node) => node.id);
// ["chapter", "chapter-scene-a", "chapter-scene-b", "ending"]Use validateStoryDocument() when an editor should show all diagnostics instead
of throwing on the first invalid field.
import { validateStoryDocument } from "@moritzbrantner/storytelling";
const issues = validateStoryDocument(story);Pass { mode: "compat" } only when a legacy importer needs lenient validation.
New story documents should satisfy the default strict contract.
Use compileStory() and enumerateStoryPaths() when an authoring UI needs graph
metadata, branch lists, endings, or all selectable routes through a document.
Authoring toolkit
Use analyzeStoryDraft() when an editor needs validation errors, authoring
warnings, reachability, and metrics without throwing on draft documents. Use
applyStoryPatch() for immutable story edits while an editor keeps temporary
draft state. Patch operations throw for missing nodes or choices by default; pass
{ onMissing: "ignore" } for legacy no-op behavior. Use rename-node to change
a node id so opening-node, next, and choice-target references are updated
together.
import { analyzeStoryDraft, applyStoryPatch } from "@moritzbrantner/storytelling";
const report = analyzeStoryDraft(story);
const draft = applyStoryPatch(story, {
type: "add-choice",
nodeId: "wake",
choice: { id: "wait", label: "Wait", target: "ending" },
});
const renamed = applyStoryPatch(story, {
type: "rename-node",
nodeId: "ending",
nextNodeId: "finale",
});Server-side tools can import the same serializable core helpers without loading React components or adapter code:
import {
analyzeStory,
applyStoryPatch,
defineStory,
resolveStoryPath,
validateStoryDocument,
} from "@moritzbrantner/storytelling/core";
import { storyDocumentJsonSchema } from "@moritzbrantner/storytelling/schema";Pass includeFixes: true to analyzeStory() to receive deterministic safe
patch suggestions for fixable draft issues such as unreachable nodes, empty
reachable nodes, disabled-only empty branches, and blank strict-mode fields.
Linear stories
Use next when a story should move through one fixed sequence without multiple
paths. The player renders each next link as a single continue action.
import { defineStory } from "@moritzbrantner/storytelling";
export const linearStory = defineStory({
id: "bridge-report",
title: "Bridge Report",
openingNodeId: "briefing",
labels: {
continue: "Continue",
completedBranch: "The report is complete.",
},
nodes: [
{
id: "briefing",
title: "Brief the desk",
content: [{ type: "paragraph", text: "The editor assigns the morning report." }],
next: "crossing",
},
{
id: "crossing",
title: "Ride the first train",
content: [{ type: "paragraph", text: "The repaired bridge carries commuters again." }],
next: "publish",
},
{
id: "publish",
title: "Publish at noon",
content: [{ type: "paragraph", text: "The sequence ends with one clear update." }],
},
],
});React playback
Use StoryPlayer for focused choice-driven playback, or StoryScroller when
the reader should scroll through scenes that receive normalized numeric progress
and Motion values for scroll-reactive effects.
import { StoryScroller } from "@moritzbrantner/storytelling";
import { motion, useTransform, type MotionValue } from "motion/react";
function OpeningMotionScene({
value,
scrollProgress,
}: {
value: number;
scrollProgress: MotionValue<number>;
}) {
const opacity = useTransform(scrollProgress, [0, 0.75, 1], [1, 1, 0]);
const y = useTransform(scrollProgress, [0, 1], [0, -48]);
return (
<motion.div style={{ opacity, y }}>
<OpeningScene scrollValue={value} />
</motion.div>
);
}
export function StoryExperience() {
return (
<StoryScroller
transition={{ type: "slide", scrollUnits: 20, direction: "up" }}
scrollInputScale={0.5}
autoplay={{ unitsPerSecond: 18 }}
scenes={[
{
id: "opening",
title: "Opening",
scrollUnits: 200,
transitionToNext: { type: "none" },
render: ({ value, scrollProgress }) => (
<OpeningMotionScene value={value} scrollProgress={scrollProgress} />
),
},
]}
/>
);
}StoryScroller changes scenes directly by default. Add
transition={{ type: "fade", scrollUnits: 20 }} or another scroll-driven
transition to animate between every scene. Use transitionToNext on an
individual scene to override that boundary. Each scene body takes 100
scroll units by default; set scrollUnits on a scene or story node to make
that scene shorter or longer. For example, scrollUnits: 200 makes a scene
take twice as much scroll distance, while scrollUnits: 50 makes it take half
as much.
Transition units are timeline scroll units added between scene bodies, not
pixels, frames, or seconds. A scene with the default 100 body units and a
20 unit slide transition holds its final frame at 100, transitions from
100 to 120, and starts the next scene at 120.
Supported scroller transitions are:
nonefor direct scene switching.fadefor a crossfade.slidefor the incoming scene sliding over the current scene.pushfor the incoming scene pushing the current scene away.wipefor a directional clipped reveal.zoomfor a scale-and-fade handoff.blurfor a blurred crossfade.
Directional transitions accept direction: "up" | "down" | "left" | "right".
zoom accepts fromScale and toScale; blur accepts maxBlur. Animated
transitions are disabled for users who prefer reduced motion.
<StoryScroller
transition={{ type: "push", scrollUnits: 24, direction: "left" }}
scenes={[
{
id: "overview",
title: "Overview",
transitionToNext: { type: "wipe", scrollUnits: 18, direction: "right" },
render: OverviewScene,
},
{
id: "details",
title: "Details",
render: DetailsScene,
},
]}
/>Use scrollInputScale to tune wheel and vertical arrow-key input. 1 is the
default: tapping Arrow Down or Arrow Up for less than 300ms scrolls by 10
scene units on key release, while holding past that threshold continues
smoothly at 20 scene units per second. Wheel deltas are left unchanged.
Values below 1 slow scrolling down; values above 1 speed it up. Arrow Right
and Arrow Left move directly to the next or previous scene.
Use autoplay when the scroller should advance itself. Passing true uses the
default pace of 20 scene units per second; pass
autoplay={{ unitsPerSecond: 18 }} to set a custom pace. Autoplay is disabled
for users who prefer reduced motion.
Autoscroll examples
Custom scene arrays can autoscroll without a story document. This is useful for guided editorial, report, or kiosk-style experiences where each scene owns its own visual treatment.
<StoryScroller
ariaLabel="Guided report"
scenes={reportScenes}
transition={{ type: "fade", scrollUnits: 16 }}
autoplay={{ unitsPerSecond: 12 }}
scrollInputScale={0.5}
/>Story-backed scrollers can autoscroll too. Linear stories work especially well
because StoryScroller resolves the full next chain into scrollable scenes.
<StoryScroller
story={linearStory}
registry={storyRegistry}
transition={{ type: "fade", scrollUnits: 18 }}
autoplay
/>Use the object form when the page needs a play/pause control or a preset menu.
const [running, setRunning] = useState(true);
<StoryScroller
scenes={tourScenes}
autoplay={{ enabled: running, unitsPerSecond: 24 }}
onActiveIndexChange={setActiveSceneIndex}
onSceneProgressChange={setSceneProgress}
/>;Both StoryPlayer and story-backed StoryScroller support controlled runtime
state with snapshot, defaultSnapshot, and onSnapshotChange. Use
serializeStorySnapshot() and parseStorySnapshot() to put the current runtime
state in a URL or share token. resolveStoryPath() also reports consumedChoiceIds,
unconsumedChoiceIds, and stoppedReason so editors can distinguish endings,
awaiting choices, invalid choices, stopAt, and max-step limits. The headless
useStoryPathState() hook accepts stopAt/defaultStopAt, which lets custom
controls step backward through auto-advanced linear nodes.
Story-backed scrollers let users pick again by default when they scroll back to
an answered branch scene. Pass allowBranchReselection={false} to lock the
selected path instead.
Composable UI
Use useStoryRuntime() when the default StoryPlayer layout is too opinionated
but you still want the same path state, labels, actions, and StoryRenderProps.
import { StoryStageFrame, useStoryRuntime } from "@moritzbrantner/storytelling";
function CustomPlayer({ story }) {
const runtime = useStoryRuntime(story);
return (
<main>
<StoryStageFrame {...runtime.renderProps} />
{runtime.choices.map((choice) => (
<button key={choice.id} type="button" onClick={() => runtime.choose(choice.id)}>
{choice.label}
</button>
))}
</main>
);
}StoryPlayer also accepts grouped slots for targeted customization and
grouped modules for turning optional UI pieces off. The older direct
renderControls/renderStage style still works and takes precedence over the
matching grouped slot.
<StoryPlayer
story={story}
layout="stacked"
modules={{ controls: false, progress: false, trail: false }}
slots={{
actions: (props) => <Toolbar canGoBack={props.canGoBack} restart={props.restart} />,
}}
/>StoryScroller can also be composed from named parts. Use the callable
component for the default scroller, or use Root, Canvas, Stage,
Overlays, Menu, and Minimap when the page needs to place navigation or
overlays outside the canvas.
<StoryScroller story={story} modules={{ minimap: true }} />
<StoryScroller.Root story={story} registry={storyRegistry}>
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_20rem]">
<StoryScroller.Canvas>
<StoryScroller.Stage />
<StoryScroller.Overlays />
</StoryScroller.Canvas>
<aside className="grid gap-4">
<StoryScroller.Menu />
<StoryScroller.Minimap collapsible />
</aside>
</div>
</StoryScroller.Root>For headless layouts, useStoryScrollerController, useStoryScroller, and
useStoryScrollerScene expose the same state and navigation actions used by the
compound parts. Existing renderScene, renderChoicePanel, renderMinimap,
slots, and modules props remain supported on the callable component.
StoryContent can render custom serializable blocks by type.
<StoryContent
content={node.content}
renderers={{
chart: ({ block }) => <Chart data={(block as { data: number[] }).data} />,
}}
/>Use StoryScrollTimeline directly for scroll-driven scenes that do not need a
story document.
<StoryScrollTimeline
scenes={[
{
id: "intro",
title: "Intro",
render: ({ progress }) => <IntroScene progress={progress} />,
},
]}
/>Example website
Run the local example app from the repository root:
bun devThe app shows StoryPlayer, StoryScroller, custom web stage renderers,
branching and linear story presets, and the minimap/state helpers against the
local source files.
Renderer registry
Renderer registries let the same story document target web, Remotion, and Three renderers without putting renderer-specific components into the document.
import { createStoryRendererRegistry, StoryStageFrame } from "@moritzbrantner/storytelling";
const registry = createStoryRendererRegistry({
web: {
map(props) {
return <div>{props.node.title}</div>;
},
},
});Remotion
The Remotion entrypoint stays behind a subpath so base React consumers do not
need to load Remotion code. A Remotion project should own its root registration
and spread the serializable metadata returned by getStoryCompositionProps()
into <Composition>.
import { Composition, registerRoot } from "remotion";
import {
StoryRemotionComposition,
getStoryCompositionProps,
} from "@moritzbrantner/storytelling/remotion";
import { story } from "./story";
const composition = getStoryCompositionProps(story, {
id: "story-video",
routeChoiceIds: ["answer"],
fps: 30,
width: 1920,
height: 1080,
});
function RemotionRoot() {
return <Composition {...composition} component={StoryRemotionComposition} />;
}
registerRoot(RemotionRoot);Only pass JSON-serializable story data, route choice ids, and layout values through
Remotion defaultProps or render inputProps. Do not put a custom renderer
registry in defaultProps: registries contain React components/functions, and
Remotion does not preserve functions or classes during rendering. Import custom
registries inside the Remotion bundle and pass them from the component layer.
See example/remotion for a minimal root, composition module,
and optional renderer script.
Three
The Three entrypoint follows the same pattern and expects three and
@react-three/fiber as peer dependencies.
import { StoryCanvasStage } from "@moritzbrantner/storytelling/three";
import { useStoryRuntime } from "@moritzbrantner/storytelling";
export function ThreeStory() {
const runtime = useStoryRuntime(story);
return <StoryCanvasStage {...runtime.renderProps} />;
}Media
The media entrypoint exposes reusable stage helpers for subtitle, audio, and video stories without keeping the older JSX story-node model in the root API.
import { createVideoStoryScene } from "@moritzbrantner/storytelling/media";
const registry = createStoryRendererRegistry({
web: {
interview: createVideoStoryScene({ src: "/interview.mp4", title: "Interview" }),
},
});Workflow And Timeline Adapters
The workflow and timeline entrypoints are pure conversion helpers. They return
plain objects shaped for @moritzbrantner/workflow-editor and
@moritzbrantner/timeline-editor, but they do not import those packages.
import { storyToTimelineEditorDocument } from "@moritzbrantner/storytelling/timeline";
import { storyToWorkflowDocument } from "@moritzbrantner/storytelling/workflow";
const workflowDocument = storyToWorkflowDocument(story);
const timelineDocument = storyToTimelineEditorDocument(story, { routeChoiceIds: ["answer"] });Use storyToWorkflowDocument(story, { allowInvalid: true, includeDiagnostics: true })
when a workflow editor needs to display a draft story that does not yet pass
validation. Timeline and Remotion adapters require finite positive fps values;
when timeline timing data contains multiple items for one node, the last item
wins.
Adapter decision
The React package keeps ./remotion and ./three as subpath exports for now.
Do not split them into separate packages until the base story schema and renderer
registry stabilize and a downstream consumer needs independent adapter release
cadence.
Standalone verification
This repository publishes @moritzbrantner/storytelling as a standalone package
while keeping ./remotion and ./three as subpath exports.
bun run verifyThe release gate covers formatting, Oxlint diagnostics, forbidden import checks,
type checking, unit tests, build output, package export smoke tests, temporary
consumer install smoke coverage, and package dry-run contents. After publishing,
run bun run test:published to verify npm metadata, the latest dist-tag, and
clean-project installability from the public registry.
The repository also includes focused quality gates for library correctness and performance:
bun run test:property
bun run test:coverage
bun run test:types
bun run size
bun run perf:smokeperf:smoke runs deterministic core and React benchmarks against built dist
output and checks bench/budgets.smoke.json. Use bun run perf for the full
benchmark matrix and bun run perf:compare to compare bench/results/latest.json
with the checked-in baseline.
