VisDelta Story
Story Documentation

Component reference

Supported MDX imports, components, variables and playback contracts.

Write a story in Studio

Open Story Studio to edit MDX and preview a complete scrolling article. The Studio application supplies the MDX compiler and narrative components described below. They are application components, not exports from the visdelta npm package.

  • story.mdx contains Markdown, JSX, JavaScript expressions, imports and exports.
  • chart.js declares const chart using the same rows and chart factories as Playground. Import it with import { chart } from './chart.js'.
  • data.json holds the editable records. Import CSV/JSON using the editor's data button, or use import rows from './data.json' directly in MDX.

You can also put declarations and reusable React components directly in MDX:

import { bar } from 'visdelta/bar'
import rows from './data.json'
import { Step } from './story-components.js'

export const chart = bar(rows).x('region').y('revenue')
export const title = 'A regional comparison'
export function RegionStep({ region }) {
  return <Step id={region} title={region} chart={chart.highlight({ region })}>
    <h2>{region}</h2><p>Keep the full dataset and focus our attention.</p>
  </Step>
}

<Hero eyebrow="Revenue report">

# {title}

Start with the overview, then compare the regions.

</Hero>

<Scrolly id="regions" layout="left" hold={0.45}>
  <Step id="all" title="Overview" chart={chart}><h2>All regions</h2></Step>
  {['North', 'South'].map(region => <RegionStep key={region} region={region} />)}
</Scrolly>

MDX compiles this normally; Studio resolves imports from ./chart.js, ./data.json, ./story-components.js, react, d3, visdelta, its six built-in chart entry points and visdelta/chart-style. Other module paths produce an explicit error; Studio is not a general npm bundler. The implicit chart variable from the first Story prototype still works, but explicit imports are preferred.

Component / propBehavior
Hero / eyebrowIntroductory heading and prose
Chapter / wideOrdinary article section; optional full width
Scrolly layoutleft, right, or overlay; compact previews stack the chart above the narrative
playbackstep (default): entering a step starts a 650ms transition; scrub: scroll position continuously controls the transition
holdIn scrub mode, fraction of each interval held at its starting state, default 0.45; range 0 <= hold < 1
offsetActivation line as a fraction of preview height, default 0.55; range 0..1
navigation, progressShow step buttons and a progress indicator; both default to true
chartStyled3, paper, dark, or editorial, passed to the real VisDelta controller
themeScoped inherit, paper, or dark story surface
Step chartComplete native chart state, including a builder or a spec object
Step viewNamed native chart from the nearest StoryData; choose either view or chart
Step id, titleStable navigation/hash target and button label; use unique IDs across the story

All sections share one preview scroll surface. Multiple Scrolly blocks own independent controllers. Entering a step plays automatically to its complete chart state, even after scrolling stops. Reverse scrolling plays toward the previous step; rapid step changes retarget from the currently rendered frame. Scrolling within the same step does not restart playback. Reduced motion snaps directly to the selected state. Initial mounting and restored hash locations also snap to the selected state. Use playback="scrub" to retain continuous scroll seeking. Step buttons and URL hashes use the same geometry as scrolling; images, fonts and resizing refresh that geometry.

Auto-run compiles after editing pauses. Run (⌘/Ctrl+Enter) compiles manually; Read story expands the preview. Drafts are saved on the current device. Save project (⌘/Ctrl+S) downloads the MDX, chart code and data together as a JSON file. Export MDX downloads the source only; local imports still need their companion files when moved into another app. Open accepts a saved project or .mdx file and waits for Run before executing imported code. CSV import infers scalar types using D3's autoType; edit JSON when identifiers must remain strings.

The editor supports standard Markdown plus GFM tables, task lists and strikethrough. Use ESM exports for metadata instead of the predecessor's YAML grammar. JavaScript and MDX run with the page's permissions; the import resolver is not a security sandbox. Run only content you trust. This follows the MDX compile/run contract, component model and plugin extension points.

Integrate with your own MDX application

An MDX application can import native VisDelta states and supply them to its own narrative components. VisDelta supplies chart state and rendering; the application supplies MDX compilation, layouts, scrolling, editing and error presentation. The VisDelta library itself does not export an MDX compiler or Scrolly/Step components; Studio implements these in its application layer.

A paragraph and its chart belong together

Start with the complete sales.mjs module. These three rows have unique regions, so Bar infers its mark key. This simple example needs no explicit key() or datumKey():

sales.mjs
import { bar } from 'visdelta';

export const sales = [
  { region: 'North', revenue: 18 },
  { region: 'South', revenue: 12 },
  { region: 'West', revenue: 9 },
];

export const chart = bar(sales).x('region').y('revenue');

The accompanying story.mdx uses the following application-owned component contract. The MDX application supplies Scrolly and Step as default components:

import { chart } from './sales.mjs'

# Which region sells the most?

<Scrolly>

<Step chart={chart}>

## Compare all three regions

Each bar shows one region's revenue.

</Step>

<Step chart={chart.highlight({ region: 'North' })}>

## Look at North

North has the highest revenue. The other regions remain visible.

</Step>

<Step chart={chart}>

## Return to the whole picture

Compare North with the other regions again.

</Step>

</Scrolly>

Read ordinary Markdown, use Scrolly to group the story, and give each Step the chart to show while reading it. Each chart prop is a complete immutable VisDelta declaration. Highlighting creates a new declaration; it does not modify the base chart or inherit changes from the preceding step. Repeating chart returns to the overview. The application collects those declarations in step order and creates the sequence; authors maintain no parallel states array.

chart is a prop name, not a new builder or a rendered controller. VisDelta does not export chart(), Scrolly or Step. Applications own their component registration and may allow explicit standard MDX imports to override defaults. They also own optional IDs, accessible titles, layouts and hold policies. Avoid reconstructing VisDelta as another YAML grammar or a second highlighting prop.

The onboarding target is to copy the template, change fields and add a highlight step in two minutes. This is a design target, not a measured user-study result. More complex stories can use the network-states.mjs Line example with explicit identity and temporal bindings. Dates serialize to ISO strings; compare instants rather than requiring rows() to return Dates.

Optional chart presentation

An application can expose a chartStyle prop on Scrolly and map preset names through the existing chartStylePresets export:

<Scrolly chartStyle="paper">

<Step chart={chart}>Compare all regions.</Step>

<Step chart={chart.highlight({ region: 'North' })}>Look at North.</Step>

</Scrolly>

chartStyle here is an application prop, not a new VisDelta builder or string option. The application resolves d3, paper, dark or editorial to a module from visdelta/chart-style, or accepts a native defineChartStyle(...) module, and passes it as the library's options.chartStyle. Both sequence() and the single-state transition(chart, chart) path need that option.

One module applies to the whole sequence. Steps keep using native chart declarations for authored color changes; they do not interpolate style modules. The application validates names, retains stable module identity through parent renders, and preserves desired progress when replacing a controller for a new module. Keep page-theme refreshes separate: they use resize().

See Styling and themes for CSS inheritance, explicit-color precedence and the difference between structural modules and CSS token themes.

Installed-package contract

Use import 'visdelta/style.css' once in the browser application. Pin the package version used by the story. The current integration baseline is 0.4.2.

An unreleased fix in this checkout preserves chart-root theme/font scope when sequence legs move out of their hidden preparation mounts. Version 0.4.2 can measure legend labels using the empty preparation host's font after a handoff. Integration snapshots containing this fix must use a distinct prerelease version, not be presented as the published 0.4.2 package.

The Paper neutral-ink correction is also unreleased: older builds can inherit light neutral marks from a dark page. Pin the tested package or integration snapshot rather than assuming this checkout's fixes exist in 0.4.2.

ConcernImplemented behaviorApplication responsibility
State constructionBuilders, rows(), toSpec() and delta() do not need a DOMKeep source data and base declarations stable; choose date and identity conventions
Mountawait sequence(states, options) requires at least two states and a browser DOMAttach an empty, exclusively owned target; set its dimensions
One static stateawait transition(state, state, options), then .progress(1)Keep a single-state story at authored position 0
Scroll positionSequence uses 0..states.length - 1; finite inputs clampMeasure anchors, apply holds and coalesce events; do not normalize the whole story to 0..1
Playback.progress() stops controller-owned playback; .play() returns the controllerChoose one input owner at a time; implement reduced-motion policy
Resize/theme.resize() retains current progress and re-reads dimensions/themeGive height: 'auto' a measurable CSS height; refresh after theme changes
InitializationCreation returns a Promise; no cancellation signal or separate controller readyBuffer desired progress and destroy late results from obsolete generations
ErrorsCreation can reject; invalid progress/dimensions throwShow the current generation's error and prevent stale errors replacing a newer preview
Cleanup.destroy() is idempotentDisconnect application observers, listeners and RAFs; remove the generation's mount

Position domains are independent of narrative progress. highlight() emphasizes marks, where() changes membership, and focus() changes the view. A legacy field named “focus” must be interpreted by its behavior rather than translated by spelling.

Inline declarations and React rendering

chart.highlight({ region: 'North' }) produces a fresh immutable object on each call, even when its declaration is unchanged. Collecting child props also creates a new array. An application must not use either reference alone to rebuild its session on every parent render or status update. Asking MDX authors to use React memoization defeats the simple authoring contract.

Within a compiled document generation, the application can compare the complete detached toSpec() declarations and retain equivalent step inputs. Preserve array order and distinguish real data, encoding and metadata changes. Keep plugin module identity and external dataset/reload inputs in the lifecycle dependencies; equal serialized specs alone do not prove equivalent custom plugins or remote data. Do not introduce a second chart normalizer.

Standard chart declarations are JSON-safe: serialization turns dates into ISO strings and does not preserve arbitrary callbacks or class instances. A custom builder's non-JSON output needs a conservative identity policy or a clear error, not a fingerprint that silently drops values. Test repeated inline expressions under parent/status/theme renders: initialization must settle, while changed chart data must rebuild. Those component tests belong to the application.

Isolate asynchronous generations

mount-story.mjs is a copyable application integration example. It creates a private child mount per call, buffers the latest seek while creation is pending, supports single states, and disposes a controller that arrives after unmount. Its ready property belongs to the example wrapper, not to VisDelta's controller.

const session = mountStory(host, states);
session.seek(currentPosition);
session.ready.catch(error => {
  if (thisGenerationIsCurrent()) showError(error);
});
// Theme change: session.resize();
// Effect cleanup: remove application listeners, then session.destroy();

Removing a mount does not cancel creation or network requests. Pending work can finish into the detached mount before its controller is destroyed. Do not reuse that target for a newer generation. A published preview can keep the previous chart until a replacement is ready; implementing that swap is application policy.

Sequence preparation uses hidden mounts under document.body. Put shared theme tokens on the document root so preparation and visible charts see the same theme. Scope styles and DOM queries per instance; avoid global chart IDs.

Verification and feedback

npm run authoring:check installs a frozen tarball in a separate consumer, bundles the actual example imports and checks authored endpoints against independent static renders. It also covers pending seeks, reverse scrubbing, jumps, resizing, static states, multiple instances, obsolete generations and failed preparation cleanup. It never aliases package imports to source files.

A downstream application still needs its own MDX compile/build, editing/save, scroll mapping, desktop/narrow layout and accessibility tests. Report library issues with the package version, native states, dimensions, requested progress, observed frame and expected behavior. An unsupported authoring option is not automatically a renderer bug. Diagnostic API proposals remain unimplemented.

One weather story, grounded in the archive

Choose Weather story or open the example. The older ?example=weather-case link opens the same document. There is one built-in weather story, rather than separate short and full versions.

The opening fact is the archive's unique highest daily maximum: 45.9°C on 7 February 2009, among 41,016 daily maxima. The narrative moves through seven questions: one year, the same seasonal window, a rank threshold, the selected days' years, their temperatures, one unit per day, and counts in half-degree bins. The last five views preserve the same selected population.

At the default settings, the 31 January–14 February window contains 1,695 observations from 113 years. Rank ≥95 selects 118 days (7.0%); rank ≥100 still selects 21 days spanning 39.5–45.9°C. These supplied integer ranks have an undocumented reference population, so the story does not call them an exact top-5% seasonal selection or an independently verified climate baseline.

chart.js holds the native line-chart template. data.json holds the default date, measure and threshold. MDX supplies prose, DatePicker, Select, Range, Value, Show and named Step view declarations. WeatherNarrative loads and prepares the archive and provides the generic StoryData model. The older weather adapter exports remain available for saved documents.

Seasonal windows align month/day against a common 365-day calendar and wrap at New Year. Leap-day records are excluded from seasonal comparisons; selecting 29 February centres the window on 28 February. The annual line retains all available observations, including leap days. The source has no observation for 8 July 2021 in either measure; 2022 is partial. Missing values stay missing. A high rank for daily minimum selects warm nights. The story states these limits and distinguishes this file's maximum from an independently verified station record.

The source and calculations are retained in scripts/analysis/weather-story.py and docs/case-studies/weather-analysis.json. The script independently profiles the archive and recomputes the opening counts and per-bin totals. The website's weather tests reconcile native VisDelta selected rows, units and histogram counts against independent archive predicates.

Shared variables and controls

Story creates an isolated variable scope. Use initial={settings[0]} for defaults from JSON, or declare individual variables with Var. Declare each name once:

<Story>
<Var name="threshold" value={95} />
<Range bind="threshold" label="Percentile" min={0} max={100} />

The selected threshold is <Value name="threshold" />.
</Story>

Range, DatePicker and Select update their named variable. Any number of controls and Value components can share it. Select accepts text options such as options={['tmax', 'tmin']} or { value, label } objects with text or numeric values. Controls groups fields into a responsive panel. Place these components anywhere within the same Story; nested Story scopes remain independent.

Variables support text, finite numbers, booleans and null. Controls check their expected types, bounds and options. Unknown names and duplicate declarations show an author error. Prose edits preserve reader selections; changing a declared default resets that variable. Selections are reader state and are not saved into the exported authoring files.

Var does not create a JavaScript identifier: use <Value name="threshold" /> in prose rather than {threshold}. Custom React components may import useStoryVariable from ./story-components.js and use const [value, setValue] = useStoryVariable('threshold') to join the same binding.

<Weather chart={chart} bind> reads date, threshold, station and metric from its Story. Custom names can be mapped with bind={{ threshold: 'percentile', date: 'day' }}; omitted fields use Weather's settings/defaults. The chart template and generated views remain native VisDelta charts. The earlier <Weather settings={settings[0]}> form retains its built-in controls for saved documents.

Reusable story models

StoryData separates the data story's logic from its MDX prose. A synchronous model function receives the explicitly bound variables and returns named views, derived values, boolean conditions, and optional native named dataset bindings in data. Put it in chart.js, where all six native VisDelta factories, rows and d3 are available:

const chart = bar(rows).x('region').y('revenue');
const model = ({ minimum }) => {
  const selected = chart.where({ revenue: { gte: minimum } });
  return {
    views: { all: chart, selected },
    values: { count: selected.rows().length },
    conditions: { hasRows: selected.rows().length > 0 },
  };
};

chart.js can declare chart, model, or both. Import the one you need; a model-only story does not need a dummy chart. The derived count reads VisDelta's transformed rows, so prose and plotted marks follow the same selection.

import { model } from './chart.js'

<Story>
<Var name="minimum" value={15} />
<StoryData model={model} bind={['minimum']}>
<Range bind="minimum" label="Minimum revenue" max={30} />

Selected regions: <Value name="count" />.

<Show when="hasRows">These regions meet the threshold.</Show>

<Show when="hasRows">
<Scrolly>
<Step view="all" title="Overview">

## All regions

Compare the whole population.

</Step>
<Step view="selected" title="Selected regions">

## Focus the selection

Change the slider to explore the same VisDelta filter.

</Step>
</Scrolly>
</Show>
</StoryData>
</Story>

Declare input variables outside StoryData. Only the names listed in bind are passed to the model; missing names produce author errors. Models run again when these values or the model change. They are pure synchronous calculations; data loading belongs in a reusable React adapter. They do not implement another chart renderer or expression language.

A view is a native chart or { chart, via: [intermediateChart] }. Named steps use the existing native sequence/transition controller, scroll trigger, reverse playback and reduced-motion handling. Explicit Step chart and via remain available. Unknown views and conditions produce author errors.

For empty populations, use Show to hide the chart and display an authored empty-state paragraph. Story does not add its own empty-chart transition renderer.

Value first reads derived values from the nearest StoryData, then falls back to a Story input variable. Use distinct names for derived results and inputs. Derived values support the same primitive types as variables and are read-only; controls bind writable Story variables. Show reads a named boolean condition. Nested data scopes keep their own derived values, views and conditions.

React adapters can provide <StoryData views={views} values={values} conditions={conditions}> directly. The weather story uses the same generic Step view, Value and Show components. Weather-specific adapters handle the archive loading, preparation and view recipes; the older WeatherStep, WeatherCaseStep, WeatherValue and WeatherCaseContent exports remain compatible with saved documents. The adapters and archive are still needed when exporting weather MDX.

Control updates render the active step directly through VisDelta. They retain the chapter host and invalidate cached routes rather than preparing every future transition. Scroll playback prepares only the native legs it needs, including explicit via waypoints, and caches them for reverse navigation. Data edits invalidate those legs; generation checks discard stale preparations. At chart family boundaries Story still dissolves native rendered endpoints.

Browse Story examples

The left Stories panel uses the same sidebar as the other Studio views. Search by title, description or source, select a case, or collapse the panel to give the editor and preview more room. Narrow screens open it as a drawer.

The built-in cases are Regional sales (a short introduction), Energy demand (illustrative data with bound controls), and Weather story (archived data). Each case has its own locally saved authoring draft. Switching cases retains edits, including after a reload; reader slider choices are not saved as defaults. The selected case has a shareable ?example=sales, energy, or weather URL.

Keep controls available while scrolling

Wrap a persistent control area in Sticky. It pins to the top of the Story reading pane after reaching it. Use one persistent area per story; the chart leaves room for its measured height, including when a details panel expands. Long panels scroll internally and use at most 40% of the reading pane.

<Sticky>
<details>
<summary>Explore another day or measure</summary>
<Controls>
<DatePicker bind="date" label="Date" required />
<Select bind="metric" label="Measure" options={['tmax', 'tmin']} />
</Controls>
</details>
</Sticky>

The sticky area remains in the same variable scope. Its inputs still update the current charts and prose; they are not copied into a separate toolbar.

HTML, Tailwind and custom styling

Standard JSX elements work directly in MDX. Use className for classes and a React style object for computed values:

<div className="sticky top-0 z-20 rounded-md border bg-fd-background p-4">
<details>
<summary className="cursor-pointer font-semibold">Explore another day</summary>
<Controls className="gap-3 border-0 p-0">
<DatePicker bind="date" label="Date" required />
</Controls>
</details>
</div>

The Story preview compiles rendered classes locally with the installed Tailwind compiler. New arbitrary values, responsive/state variants and classes produced by custom components work without rebuilding the site. There is no CDN script. Generated utility selectors and theme variables are scoped to the reading pane; no second Preflight reset is injected. Site font and color tokens remain usable. Responsive md: variants follow the browser viewport, as in ordinary Tailwind.

Story, Hero, Chapter, Scrolly, Step, Controls, Value and Sticky accept className and style. Native HTML can always be used for other layouts. Sticky is optional: unlike a plain sticky div, it also measures its height so Story charts leave room below it. Use it when that coordinated layout is wanted.

CSS imported by arbitrary module paths and additional Tailwind plugins/config files are not part of this in-browser editor. Exported MDX retains its classes; a separate host must provide Tailwind compilation or equivalent CSS.

On this page