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 chartusing the samerowsand chart factories as Playground. Import it withimport { 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 / prop | Behavior |
|---|---|
Hero / eyebrow | Introductory heading and prose |
Chapter / wide | Ordinary article section; optional full width |
Scrolly layout | left, right, or overlay; compact previews stack the chart above the narrative |
playback | step (default): entering a step starts a 650ms transition; scrub: scroll position continuously controls the transition |
hold | In scrub mode, fraction of each interval held at its starting state, default 0.45; range 0 <= hold < 1 |
offset | Activation line as a fraction of preview height, default 0.55; range 0..1 |
navigation, progress | Show step buttons and a progress indicator; both default to true |
chartStyle | d3, paper, dark, or editorial, passed to the real VisDelta controller |
theme | Scoped inherit, paper, or dark story surface |
Step chart | Complete native chart state, including a builder or a spec object |
Step view | Named native chart from the nearest StoryData; choose either view or chart |
Step id, title | Stable 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():
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.
| Concern | Implemented behavior | Application responsibility |
|---|---|---|
| State construction | Builders, rows(), toSpec() and delta() do not need a DOM | Keep source data and base declarations stable; choose date and identity conventions |
| Mount | await sequence(states, options) requires at least two states and a browser DOM | Attach an empty, exclusively owned target; set its dimensions |
| One static state | await transition(state, state, options), then .progress(1) | Keep a single-state story at authored position 0 |
| Scroll position | Sequence uses 0..states.length - 1; finite inputs clamp | Measure anchors, apply holds and coalesce events; do not normalize the whole story to 0..1 |
| Playback | .progress() stops controller-owned playback; .play() returns the controller | Choose one input owner at a time; implement reduced-motion policy |
| Resize/theme | .resize() retains current progress and re-reads dimensions/theme | Give height: 'auto' a measurable CSS height; refresh after theme changes |
| Initialization | Creation returns a Promise; no cancellation signal or separate controller ready | Buffer desired progress and destroy late results from obsolete generations |
| Errors | Creation can reject; invalid progress/dimensions throw | Show the current generation's error and prevent stale errors replacing a newer preview |
| Cleanup | .destroy() is idempotent | Disconnect 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.