dstory
Turn DataFrames into NYT-style scrollytelling and slide-deck data stories — one self-contained HTML file you can email, host, or present. Pure Python in, no JavaScript required.
📖 Documentation · ▶ Live demo: Ctrl-Alt-Reinvent — Microsoft, 1975–2025 — a 40-scene data essay built with dstory · ▶ Cookbook gallery — the chart recipes, rendered · ▶ Theme showcase — same story, eight personalities · ▶ Morph showcase — bars curl into pies, live, zero JS
dstory is the deterministic infrastructure for data stories: a one-call builder, a single-file bundler, a 5-dimension vetter (renders / data fidelity / editorial integrity / visual / a11y), a brand system for your colors and typography, and pydantic schemas that surface authoring bugs at build time instead of delivery time.
Who is this for?
- Python data scientists (no JS): animated chart sequences via declarative Vizzu scenes, plus a cookbook of finished D3 charts you copy and own. DataFrame → story.html in one notebook cell.
- Authors comfortable with D3: full creative control — scenes are plain JS files you write; dstory does everything else and stays out of your chart code.
- AI-assisted authoring: dstory is the substrate the companion data-storytelling Claude skill builds on — the agent writes the bespoke D3; the vetter gates what ships.
Status
Alpha — under active development. API may change before 1.0.
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
Quick start: notebook, zero JavaScript
One cell, DataFrame to shippable HTML:
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": "Animated bar-to-line sequence: 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)
kind: "vizzu" scenes are fully declarative — frames animate between any
chart configuration (bar → line → stacked → polar…, 40+ types; see
references/vizzu-chart-gallery.md).
No scene JS file exists for this story at all.
The cookbook
When you want the bespoke-D3 look without writing D3 from scratch: 10 complete, themed, responsive scene renderers — see them rendered.
dstory recipes # list: line-reveal, beeswarm, bump-chart, ...
dstory scene my-story scene-growth --from line-reveal # copy one in, wired and ready
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 still no BarChart() API and never will be: recipes are starting
points, not abstractions.
Quick start (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
# ...then edit my-story/data.json (datasets, headlines, claims)
# 3. Preview while authoring
dstory preview my-story
# 4. Bundle to a single self-contained HTML file
dstory bundle my-story --vendor
# → my-story/dist/story.html (--vendor inlines d3/scrollama/Motion: works offline)
# 5. Vet before delivery
dstory vet my-story/dist/story.html --data my-story/data.json
Other commands: dstory themes (the 8 brand presets), dstory recipes (the cookbook), dstory --help.
Claims that can't drift from the data
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,
)
compute_claim() derives the phrase from the value using the same vocabulary
and windows the vetter checks ("doubled"…"quintupled", percentages), so a claim
built this way cannot fail data-fidelity vetting — and a hand-written claim that
drifts from its value blocks the build. compute_ratio() and
compute_pct_change() tag their result with its unit, and the claim records it
("unit": "ratio" or "pct"), so a 3.4× ratio is never printed as "3.4%" and a
+2% change is never called "doubled". For a plain float, pass
kind="ratio" or kind="pct".
How is this different from…?
| dstory | |
|---|---|
| Streamlit / Dash | No server. The output is one HTML file that works from an email attachment. Narrative-first (scroll/slides), not dashboard-first. |
| ipyvizzu-story | dstory's vizzu scenes cover the same ground, then add: 5 other scene kinds (scrolly D3, full-bleed, pinned…), a brand/theming system, claim-vs-data vetting, and a11y/editorial gates. |
| Quarto | Quarto renders documents; dstory builds bespoke graphics-led stories with scroll-driven chart morphs, and vets the output in a real browser before you ship. |
| Flourish / Datawrapper | Code-first and version-controlled; your data never leaves your machine; no per-seat licensing. But you bring the polish — dstory brings the rails. |
Brand system
Brands let consumers fit dstory to their visual system without forking the package.
A brand is a TOML file (or a Brand Python object) defining color tokens, typography, motion personality, atmosphere, and chart palette. The 8 bundled presets are themselves brand TOMLs.
# acme-brand.toml
[brand]
name = "Acme Corp"
extends = "minimal-light" # inherit base; override below
[colors]
accent = "#FF6B35"
accent_2 = "#1B4332"
[colors.categorical]
palette = ["#FF6B35", "#1B4332", "#3A86FF", "#FFBE0B", "#8338EC", "#06A77D", "#D62828"]
[typography]
display = "'Fraunces', Georgia, serif"
body = "'Inter Tight', system-ui, sans-serif"
google_fonts = ["Fraunces:opsz,wght@9..144,400;9..144,700",
"Inter+Tight:wght@400;500;600"]
Use it via CLI or Python:
dstory init acme-story --theme ./acme-brand.toml
from dstory import Brand, init
brand = Brand.from_toml("acme-brand.toml") # or .from_preset("editorial-noir").extend(accent="#FF6B35")
init("acme-story", brand=brand)
The 8 bundled presets:
| Name | Vibe |
|---|---|
editorial-noir |
Urgent, somber, magazine-cover serious |
scientific-bright |
Rigorous, optimistic, paper-like |
tech-glow |
Dense, dark, terminal-influenced |
minimal-light |
Restrained, gallery, lots of whitespace |
warm-print |
Print-magazine warmth, ledger feel |
playful-pastel |
Friendly, illustrative, kid- and student-friendly |
brutalist |
Raw, unapologetic, monospace-heavy |
data-fi |
Futurist, cinematic, chart-as-art |
Scene kinds
A data.json story is composed of scenes; each declares its kind:
simple— single chart-driven moment (headline + commentary + chart mount)scrolly— sticky chart + stepping text; chart morphs on each step (custom D3 logic)bleed— full-viewport cinematic momentpinned— central object pinned for one scroll-section, transformed by scroll progressvizzu— animated data pivots declared asframes[]; supports rectangle/circle/line/area geometries × cartesian/polar coordinates × all channel combos. Seereferences/vizzu-chart-gallery.mdfor the 40+ chart types this enables.custom— chrome-free; the renderer owns the entire<section>mount. Use for kinetic typography, embedded interactives, full-bleed magazine layouts, anything the four-h2-then-mount pattern doesn't fit.
Per-scene layout overrides
| Field | Values | Effect |
|---|---|---|
width |
narrow (~36rem) / default (~56rem) / wide (~78rem) / full (100%) |
Override the scene's max-width without touching CSS |
chrome |
true (default) / false |
When false, omit the auto-generated h2/p/source elements; renderer takes full control |
Reaching every reader
meta fields that widen who can read (and share) the story:
| Field | Effect |
|---|---|
lang / dir |
Set <html lang dir> — screen-reader voice, hyphenation, RTL layout |
description |
<meta name="description"> + Open Graph/Twitter description (falls back to deck) |
share_image |
og:image / twitter:image for link unfurls (absolute URL) |
hero_image |
Full-bleed hero photo with a theme-aware scrim; local files are inlined at bundle time |
Per scene, set alt — one sentence stating what the chart shows and the
takeaway. It becomes role="img" + aria-label on the chart mount, and the
vetter flags chart scenes that lack it. The template also ships a
skip-to-content link, a reading progress bar, prefers-reduced-motion
support, a print stylesheet (browser print → clean PDF handout), and
#slide-N deep links in slides mode.
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.
For project-wide CSS overrides, set Story.extra_css in your data:
story = Story.model_validate({
"meta": {...},
"extra_css": ".my-special-scene { display: grid; ... }",
...
})
The bundler appends extra_css as a final <style> block, overriding anything in story.css.
Story modes
By default the story plays as a scroll-driven essay. Set meta.mode: "slides" in data.json to flip it into a click/keyboard slide deck (arrow keys, click-to-advance, prev/next buttons, position indicator). Same scenes, same charts, same theme — different pacing.
meta.mode |
Feels like | Pick for |
|---|---|---|
"scroll" (default) |
A film | Published web essays, blogs, mobile, social shares |
"slides" |
A presentation | Live talks, exec briefings, screen-shared meetings, walkthroughs |
Vetting (mandatory)
dstory vet (or dstory.vet()) grades the bundled HTML on five dimensions:
- Renders correctly — headless browser at 1440/768/480, no console errors, charts paint
- Data fidelity — every numeric claim cross-checked against
data.json(catches "tripled" vsvalue=2.4) - Editorial integrity — no slop phrases, no placeholders, sources present, headlines are insights
- Visual quality — full-page screenshots saved at all viewports for manual review
- Accessibility & ethics — color contrast, no PII leaks, dual-axis warnings, alt text
A bundled story is "ready to ship" only when all five pass.
Project layout
dstory/
├── src/dstory/
│ ├── api.py # build() — Story → bundled HTML in one call
│ ├── schema.py # pydantic models for data.json (Story, Meta, Scene, Claim, ...)
│ ├── brand.py # Brand class — TOML loader, preset extension, CSS emission
│ ├── scaffold.py # init() — copy template + write theme.css
│ ├── bundle.py # bundle() — inline CSS/JS/images/data; --vendor for offline
│ ├── vet.py # vet() — 5-dimension report (Playwright optional)
│ ├── prep.py # pandas helpers + compute_claim() (vetter-aligned)
│ ├── cli.py # `dstory` CLI
│ ├── cookbook/ # 10 complete scene recipes (+ registry & demos)
│ ├── themes/ # 8 preset brand TOMLs
│ └── templates/ # HTML/CSS/JS skeleton copied into new projects
├── docs/gallery/ # rendered cookbook gallery (GitHub Pages)
├── tests/ # 134 tests, pytest (browser e2e skips without Chromium)
└── pyproject.toml
Why does this exist?
Data storytelling is two distinct jobs:
- Plumbing — scaffolding, bundling, validating, vetting. Deterministic. Same bug shouldn't be re-fixed in every project.
- Creative work — the story arc, the chart design, the annotation language, the editorial voice. Varies by piece.
dstory packages the plumbing so you don't reinvent it (or its bugs) per project. The creative work — written in plain JS/D3 in your scenes/*.js files — stays under your control. There is intentionally no BarChart() component in this library.
For the creative authoring guide (story arcs, audience adaptation, chart selection, scrollytelling patterns, anti-slop rules), see the companion data-storytelling Claude skill.
License
MIT — see LICENSE.
Metadata
Release files for dstory 0.6.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| dstory-0.6.0.tar.gz | 73.6 kB | Without provenance | Details for dstory-0.6.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| dstory-0.6.0-py3-none-any.whl | Python 3 | none | any | Details for dstory-0.6.0-py3-none-any.whl |
Total release size: 169.1 kB
Release files / dstory-0.6.0.tar.gz
| Download URL | dstory-0.6.0.tar.gz |
|---|---|
| Size | 73.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
5f246ad6aa41cb7743721cbfee5722514cd8d27143e01a5c0f6b797772d36439
|
|
BLAKE2b-256 checksum How to use checksums |
5bcf67376f3e5d94cb1b7338cf96f4d9ef16d620e532361c09ec9756f0de49e9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.15 {"installer":{"name":"uv","version":"0.11.15","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":null,"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / dstory-0.6.0-py3-none-any.whl
| Download URL | dstory-0.6.0-py3-none-any.whl |
|---|---|
| Size | 95.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
30fc8794dbda9490979b25bbd2586d72cf7e12c547d9ac62eb3f23f76671c0da
|
|
BLAKE2b-256 checksum How to use checksums |
4db0d58acbcd2d640c99d62a20cae8dd1007c35abac1a931dcf47ffe99beb9ad
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.15 {"installer":{"name":"uv","version":"0.11.15","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":null,"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Security for dstory 0.6.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.