Python · scrollytelling · one HTML file

Data stories that ship as a single file.

Turn DataFrames into NYT-style scrollytelling essays and slide decks — scaffolded, branded, vetted, and bundled into one self-contained HTML file you can email, host, or present. Pure Python in. No JavaScript required.

Install

pip install dstory                     # core: schema + brand + scaffold + bundle
pip install "dstory[vet]"              # adds Playwright for browser-driven vetting
pip install "dstory[prep]"             # adds pandas for data-prep helpers
pip install "dstory[all]"              # everything

# After installing the [vet] extra, install Chromium:
playwright install chromium

Quickstart: notebook, zero JavaScript

One cell, DataFrame to shippable HTML. kind: "vizzu" scenes are fully declarative — frames animate between any chart configuration, and the space is much bigger than bar→line: bars curl into pies, scatters pack into treemaps, lines wrap into radars. Watch the morph showcase (13 frames, zero JavaScript), then grab configs from the chart reference.

import pandas as pd
from dstory import build
from dstory.prep import to_long

df = pd.DataFrame({
    "month":   ["Jan", "Feb", "Mar", "Apr"],
    "Signups": [1000, 1500, 2900, 3400],
    "Churn":   [200, 240, 250, 260],
})

result = build({
    "meta": {"title": "Q1 Growth", "theme": "scientific-bright"},
    "scenes": [{
        "id": "scene-growth", "kind": "vizzu", "dataset": "metrics",
        "alt": "Signups more than tripled while churn stayed flat.",
        "series": [
            {"name": "month", "type": "dimension"},
            {"name": "variable", "type": "dimension"},
            {"name": "value", "type": "measure"},
        ],
        "frames": [
            {"headline": "Signups took off in March",
             "config": {"channels": {"x": "month", "y": "value", "color": "variable"},
                        "geometry": "rectangle"}},
            {"headline": "While churn barely moved",
             "config": {"channels": {"x": "month", "y": "value", "color": "variable"},
                        "geometry": "line"}},
        ],
    }],
    "datasets": {"metrics": to_long(df, id_vars="month")},
}, "q1-growth", vet=True)

result.html    # → q1-growth/dist/story.html — open it, scroll, the chart morphs
result.passed  # → True (the 5-dimension vet report)

Quickstart: CLI

# 1. Scaffold a project from one of the 8 bundled themes
dstory init my-story --theme editorial-noir --audience general-public

# 2. Add scenes: from the cookbook, a blank stub, or by hand
dstory scene my-story scene-trend --from line-reveal

# 3. Preview while authoring
dstory preview my-story

# 4. Bundle to a single self-contained HTML file (--vendor: works offline)
dstory bundle my-story --vendor

# 5. Vet before delivery
dstory vet my-story/dist/story.html --data my-story/data.json

The cookbook

When you want the bespoke-D3 look without writing D3 from scratch: 10 complete, themed, responsive scene renderers. A recipe is source code dropped into your project — edit it freely, it's yours. Each documents its dataset columns and scene.config options in the file header. All are theme-token driven and respect prefers-reduced-motion. There is no BarChart() API and never will be: recipes are starting points, not abstractions.

→ All 10 recipes, rendered

dstory recipes                                        # list them
dstory scene my-story scene-growth --from line-reveal # copy one in, wired and ready

Scene kinds

A story is a sequence of scenes in data.json; each declares its kind:

KindWhat it is
simpleSingle chart-driven moment — headline, commentary, chart mount, source line.
scrollySticky chart + stepping text; the chart morphs on each step (your D3 logic via onStep).
vizzuAnimated data pivots declared as frames[] — no JS needed. 40+ shapes, any-to-any morphs: live showcase · chart reference.
bleedFull-viewport cinematic moment.
pinnedCentral object pinned for one scroll-section, transformed by scroll progress (onProgress).
customChrome-free; the renderer owns the entire <section>. Kinetic typography, embedded interactives, anything.

Per scene: width (narrow / default / wide / full), chrome: false to drop the auto-generated headline/commentary elements, and alt for the chart's screen-reader description.

Brand & themes

A brand is a TOML file defining color tokens, typography, motion personality, atmosphere, and chart palette. The 8 bundled presets are themselves brand TOMLs — extend one without forking anything:

# acme-brand.toml
[brand]
name    = "Acme Corp"
extends = "minimal-light"

[colors]
accent   = "#FF6B35"

[typography]
display      = "'Fraunces', Georgia, serif"
google_fonts = ["Fraunces:opsz,wght@9..144,400;9..144,700"]
dstory init acme-story --theme ./acme-brand.toml

→ All 8 presets — same story, eight personalities

Scroll vs. slides

By default the story plays as a scroll-driven essay. Set meta.mode: "slides" to flip the same scenes into a click/keyboard deck — arrow keys, position indicator, and #slide-N deep links so you can share "open at slide 12". Pick scroll for published essays and mobile; slides for live talks and exec briefings.

Reaching every reader

FieldEffect
meta.lang / dirSets <html lang dir> — screen-reader voice, hyphenation, RTL layout.
meta.descriptionMeta description + Open Graph/Twitter tags, so shared links unfurl properly.
meta.share_imageog:image / twitter:image for the link card (absolute URL).
meta.hero_imageFull-bleed hero photo with a theme-aware scrim; local files inlined at bundle time.
scene.altOne sentence: what the chart shows and the takeaway → role="img" + aria-label. The vetter flags chart scenes without it.

The template also ships a skip-to-content link, a reading progress bar, prefers-reduced-motion support throughout, and a print stylesheet — browser print yields a clean PDF handout. The vetter is audience-aware too: it scores the story's prose (Flesch reading ease) against meta.audience and notes when the writing is harder than that audience should have to work for.

Vetting

dstory vet grades the bundled HTML on five dimensions. A story is "ready to ship" only when all five pass:

1. RendersHeadless browser at 1440/768/480 — no console errors, charts paint, slides navigate.
2. Data fidelityEvery numeric claim cross-checked against the data (catches "tripled" vs value=2.4).
3. EditorialNo slop phrases or placeholders, sources present, headlines are insights, readability vs. audience.
4. VisualFull-page screenshots saved at all viewports for review.
5. A11y & ethicsColor contrast, alt text, PII leaks, dual-axis warnings.

Claims & data fidelity

Claims are first-class: each pairs prose with the number it asserts, and the vetter blocks the build when they drift apart. compute_claim() derives the phrase from the value using the same vocabulary the vetter checks, so a claim built this way cannot fail vetting:

from dstory.prep import compute_ratio, compute_claim

ratio = compute_ratio(after=2900, before=1000)   # 2.9
claim = compute_claim(
    id="c1",
    text_template="Signups {phrase} in Q1",    # → "Signups tripled in Q1"
    value=ratio,
)

Offline bundles

dstory bundle --vendor downloads and inlines the CDN runtime libraries (d3, scrollama, Motion), so the single HTML file works with no network at all — on a plane, behind a proxy, in ten years. Adds ~300 KB. Vizzu scenes are the one exception (the library streams WebAssembly at runtime); stories without them become fully self-contained, and the bundler tells you either way.

CLI reference

dstory initScaffold a project — --theme (preset or TOML path), --audience, --mode, --title.
dstory sceneAdd a scene script — --from <recipe> (cookbook) or --kind (blank stub). Wires index.html automatically.
dstory recipesList the cookbook recipes.
dstory previewLocal dev server (unbundled projects fetch data.json, which file:// blocks).
dstory bundleProject → dist/story.html — --vendor for offline, --no-validate to skip schema checks.
dstory vet5-dimension report — --no-browser for static checks only.
dstory themesList the 8 brand presets.

Python API

build(story, path, …)One call: scaffold + data.json + scenes + bundle (+ vet). Returns BuildResult with .html, .passed.
Story / Scene / Meta / ClaimPydantic models for data.json — authoring bugs surface at build time.
Brand.from_preset(), .from_toml(), .extend(accent="#…"), .css().
init / write_scene / wire_scenesScaffolding primitives (what build() composes).
bundle(slug, vendor=…)Inline CSS/JS/images/data → one file, with self-containment warnings.
vet(html, data=…)Typed 5-dimension Report.
recipe_js / list_recipes / recipe_demoThe cookbook, programmatically.
dstory.prepto_records / to_long (DataFrame → datasets), compute_claim, fmt_compact / fmt_pct.

How is this different from…?

Streamlit / DashNo server. The output is one HTML file that works from an email attachment. Narrative-first, not dashboard-first.
ipyvizzu-storydstory's vizzu scenes cover the same ground, then add 5 other scene kinds, a brand system, claim-vs-data vetting, and a11y/editorial gates.
QuartoQuarto renders documents; dstory builds bespoke graphics-led stories with scroll-driven chart morphs, and vets the output in a real browser.
Flourish / DatawrapperCode-first and version-controlled; your data never leaves your machine; no per-seat licensing.