npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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/core for non-React validation, graph, path, authoring, and patch helpers
  • @moritzbrantner/storytelling/schema for storyDocumentJsonSchema
  • useStoryRuntime(...), StoryPlayer, StoryControls, StoryScroller, StoryScrollTimeline, StoryProgress, and StoryMinimap
  • createStoryRendererRegistry(...), getStoryRendererKey(...), and getStoryStageProps(...)
  • @moritzbrantner/storytelling/media for media-oriented stage helpers
  • @moritzbrantner/storytelling/workflow for workflow-editor document conversion
  • @moritzbrantner/storytelling/timeline for timeline-editor document conversion
  • @moritzbrantner/storytelling/remotion for frame-synced compositions
  • @moritzbrantner/storytelling/three for 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:

  • none for direct scene switching.
  • fade for a crossfade.
  • slide for the incoming scene sliding over the current scene.
  • push for the incoming scene pushing the current scene away.
  • wipe for a directional clipped reveal.
  • zoom for a scale-and-fade handoff.
  • blur for 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 dev

The 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 verify

The 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:smoke

perf: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.