npm package: @matthesketh/react-storyteller
Description: React 19 scroll-driven storytelling. Three layouts, step callbacks, smooth progress tracking, zero dependencies.
Author: Matt Hesketh
Latest version: 1.2.1

[![npm version](https://img.shields.io/npm/v/@matthesketh/react-storyteller)](https://www.npmjs.com/package/@matthesketh/react-storyteller) [![npm downloads](https://img.shields.io/npm/dm/@matthesketh/react-storyteller)](https://www.npmjs.com/package/@matthesketh/react-storyteller) [![license](https://img.shields.io/npm/l/@matthesketh/react-storyteller)](https://www.npmjs.com/package/@matthesketh/react-storyteller) [![TypeScript](https://img.shields.io/badge/TypeScript-native-3178C6)](https://www.typescriptlang.org/) [![React](https://img.shields.io/badge/React-19-61DAFB)](https://react.dev/) [![zero deps](https://img.shields.io/badge/dependencies-0-brightgreen)](https://www.npmjs.com/package/@matthesketh/react-storyteller) [![demo](https://img.shields.io/badge/demo-live-f59e0b)](https://react-storyteller.hesketh.pro)

# @matthesketh/react-storyteller

Scroll-driven storytelling for React 19. Three layout modes, step callbacks, smooth progress tracking -- zero runtime dependencies.

[Live Demo](https://react-storyteller.hesketh.pro) &nbsp;|&nbsp; [npm](https://www.npmjs.com/package/@matthesketh/react-storyteller)

## Features

- Three layouts: `side-by-side`, `overlay`, and `stacked`
- Step callbacks: `onEnter` and `onExit` per step or globally
- Progress tracking: smooth 0–1 value within the active step
- Zero dependencies: no runtime deps beyond React itself
- TypeScript-first: full type definitions included
- React 19 native: built against React 19 with no compatibility shims
- Minimal CSS: opt-in stylesheet, fully overridable

## Install

```bash
npm install @matthesketh/react-storyteller
```

## Quick Start

```tsx
import { Storyteller } from '@matthesketh/react-storyteller';
import '@matthesketh/react-storyteller/styles';

const steps = [
  { content: <p>Step one: introduce the subject.</p> },
  { content: <p>Step two: show the change.</p> },
  { content: <p>Step three: reveal the outcome.</p> },
];

export function MyStory() {
  return (
    <Storyteller
      steps={steps}
      graphic={({ stepIndex }) => (
        <div className="graphic">
          <strong>Step {stepIndex + 1}</strong>
        </div>
      )}
    />
  );
}
```

## API

### StorytellerProps

| Prop | Type | Default | Description |
|---|---|---|---|
| `steps` | `StorytellerStep[]` | — | Array of step definitions |
| `graphic` | `(props: GraphicRenderProps) => ReactNode` | — | Render prop for the sticky graphic panel |
| `layout` | `'side-by-side' \| 'overlay' \| 'stacked'` | `'side-by-side'` | Layout mode |
| `progress` | `boolean` | `false` | Enable per-step scroll progress tracking |
| `snap` | `boolean \| 'proximity' \| 'mandatory'` | `false` | Enable CSS scroll-snap on steps |
| `hashSync` | `boolean \| string` | `false` | Sync active step to URL hash (`#step-1`) |
| `offset` | `number` | `0.5` | Viewport trigger point (0 = top, 1 = bottom) |
| `onStepEnter` | `(context: StepContext) => void` | — | Fires when any step enters the trigger point |
| `onStepExit` | `(context: StepContext) => void` | — | Fires when any step exits the trigger point |
| `className` | `string` | — | Class added to the outer container element |
| `graphicClassName` | `string` | — | Class added to the graphic panel element |
| `stepClassName` | `string` | — | Class added to every step element |
| `activeStepClassName` | `string` | `'is-active'` | Class added to the currently active step element |

### StorytellerStep

| Property | Type | Required | Description |
|---|---|---|---|
| `content` | `ReactNode` | Yes | The content rendered inside the step |
| `onEnter` | `(context: StepContext) => void` | No | Fires when this step enters the trigger point |
| `onExit` | `(context: StepContext) => void` | No | Fires when this step exits the trigger point |
| `className` | `string` | No | Additional class applied to this step's element |

### GraphicRenderProps

| Property | Type | Description |
|---|---|---|
| `stepIndex` | `number` | Index of the currently active step (`-1` before any step is active) |
| `progress` | `number` | Scroll progress within the active step (0–1); requires `progress` prop |
| `direction` | `ScrollDirection` | Last scroll direction: `'up'` or `'down'` |

### StepContext

| Property | Type | Description |
|---|---|---|
| `index` | `number` | Index of the step that triggered the callback |
| `direction` | `ScrollDirection` | Scroll direction at the time of the event |
| `progress` | `number` | Scroll progress at the time of the event |

## Layouts

### side-by-side (default)

The graphic is sticky on one side while steps scroll past on the other. Best for data visualisations and annotated diagrams.

```tsx
<Storyteller
  layout="side-by-side"
  steps={steps}
  graphic={({ stepIndex }) => <Chart activeIndex={stepIndex} />}
/>
```

### overlay

The graphic fills the full viewport and the steps scroll over it. Best for full-bleed imagery or video backgrounds.

```tsx
<Storyteller
  layout="overlay"
  steps={steps}
  graphic={({ stepIndex }) => <FullBleedImage src={images[stepIndex]} />}
/>
```

### stacked

The graphic sits above the steps in normal document flow — no sticky positioning. Best for mobile-first layouts or simpler stories where a fixed panel is not needed.

```tsx
<Storyteller
  layout="stacked"
  steps={steps}
  graphic={({ stepIndex }) => <Summary index={stepIndex} />}
/>
```

## Progress Tracking

Enable the `progress` prop to receive a continuous 0–1 value representing how far the user has scrolled through the active step. Use it to drive animations tied directly to scroll position.

```tsx
<Storyteller
  steps={steps}
  progress
  graphic={({ stepIndex, progress }) => (
    <div
      style={{
        opacity: progress,
        transform: `scale(${0.8 + progress * 0.2})`,
      }}
    >
      <Chart activeIndex={stepIndex} />
    </div>
  )}
/>
```

`progress` is `0` at the start of the step and `1` at the end. It is only calculated for the currently active step.

## Snap Scrolling

Enable the `snap` prop to make steps snap into position as the user scrolls, similar to Apple's product pages. Each step locks into the viewport centre, ensuring clean transitions between steps.

```tsx
<Storyteller
  steps={steps}
  snap
  graphic={({ stepIndex }) => <Chart activeIndex={stepIndex} />}
/>
```

`snap` accepts three values:

- `true` or `'proximity'` -- snaps when the user stops scrolling near a step boundary (recommended)
- `'mandatory'` -- always snaps to the nearest step, even mid-scroll

Under the hood this sets `scroll-snap-type` on `<html>` and `scroll-snap-align: center` on each step. The style is cleaned up when the component unmounts.

## URL Hash Sync

Enable the `hashSync` prop to sync the active step to the URL hash. Users can share deep links to specific points in a story, and the page will scroll to the correct step on load.

```tsx
<Storyteller
  steps={steps}
  hashSync
  graphic={({ stepIndex }) => <Chart activeIndex={stepIndex} />}
/>
```

As the user scrolls, the URL updates to `#step-1`, `#step-2`, etc. On page load, if the URL already contains a matching hash, the page scrolls to that step automatically.

Pass a string to customise the prefix:

```tsx
<Storyteller hashSync="scene-" ... />
// URL becomes #scene-1, #scene-2, etc.
```

Uses `history.replaceState` so it doesn't pollute browser history.

## Tailwind CSS

If you use Tailwind, you can replace the base stylesheet with the Tailwind plugin. It registers the same structural classes as Tailwind components and utilities.

```js
// tailwind.config.js
import { storytellerPlugin } from '@matthesketh/react-storyteller/tailwind';

export default {
  plugins: [storytellerPlugin()],
};
```

Then remove the CSS import -- the plugin provides all `rst-*` classes via Tailwind's layer system. You can extend them with Tailwind utilities as normal:

```tsx
<Storyteller
  className="max-w-7xl mx-auto"
  stepClassName="px-8 py-12"
  graphicClassName="rounded-xl overflow-hidden"
  steps={steps}
  graphic={graphic}
/>
```

The plugin includes:

- **Components**: `.rst-side-by-side`, `.rst-overlay`, `.rst-stacked`, `.rst-graphic--sticky`, `.rst-step`, `.rst-steps`, `.rst-sr-only`
- **Utilities**: `.rst-step--snap`, `.rst-snap-proximity`, `.rst-snap-mandatory`

## Framer Motion

The `graphic` render prop works naturally with framer-motion. Wrap your graphic content in `motion.div` and use `stepIndex` as the animation key:

```tsx
import { motion, AnimatePresence } from 'framer-motion';

<Storyteller
  steps={steps}
  graphic={({ stepIndex }) => (
    <AnimatePresence mode="wait">
      <motion.div
        key={stepIndex}
        initial={{ opacity: 0, y: 20 }}
        animate={{ opacity: 1, y: 0 }}
        exit={{ opacity: 0, y: -20 }}
        transition={{ duration: 0.4 }}
      >
        <Chart step={stepIndex} />
      </motion.div>
    </AnimatePresence>
  )}
/>
```

For scroll-linked animations, combine `progress` with `motion.div` style props:

```tsx
<Storyteller
  progress
  steps={steps}
  graphic={({ stepIndex, progress }) => (
    <motion.div
      animate={{ scale: 0.8 + progress * 0.2, opacity: 0.5 + progress * 0.5 }}
      transition={{ type: 'tween', duration: 0 }}
    >
      <Chart step={stepIndex} />
    </motion.div>
  )}
/>
```

No additional dependencies are required from this package -- framer-motion is optional and only needed if you choose to use it.

## Styles

Import the bundled stylesheet once at your app entry point:

```ts
import '@matthesketh/react-storyteller/styles';
```

The stylesheet provides:

- Layout structure for all three modes (`rst-side-by-side`, `rst-overlay`, `rst-stacked`)
- Sticky positioning for the graphic panel in `side-by-side` and `overlay` modes
- Sensible defaults for step spacing

To override styles, target the BEM-style class names directly in your own CSS:

```css
.rst-side-by-side {
  gap: 4rem;
}

.rst-graphic {
  background: #f5f5f5;
}

.rst-step.is-active {
  font-weight: bold;
}
```

## Comparison

| Feature | react-storyteller | react-scrollama | @bsmnt/scrollytelling |
|---|---|---|---|
| React 19 | Yes | No | No |
| TypeScript | Native | Separate types | Partial |
| Built-in layouts | Yes (3) | No | No |
| Snap scrolling | Yes | No | No |
| URL hash sync | Yes | No | No |
| Tailwind plugin | Yes | No | No |
| Progress tracking | Yes | Yes | Yes |
| Runtime deps | None | d3-selection | GSAP |
| Config-driven steps | Yes | No | No |

## License

MIT