Skip to content

Interactive JS — Live Demo Channel

The biggest win over Photoshop: parameters the reader can move. Static images can't be explored; interactive demos let the reader develop intuition by playing with the underlying model.

Part of the muriel skill — see the top-level index for mission, universal rules, and channel map.

When to use

  • Blog post explainers where the reader should move parameters, not just read
  • Paper figures with sliders (CHI / ETTAC / CIKM submissions can include live demo URLs)
  • Worked examples and teaching artifacts
  • Audio-reactive visualizers
  • Eye-tracking replays with timeline scrubber
  • Anything where "watching it move" matters more than "seeing one frame"

Constraints (from ~/CLAUDE.md)

  • ES6 modules direct. No bundlers, no build step.
  • WebGL is the primary graphics stack. Canvas2D for non-shader 2D, D3 for data binding to DOM/SVG, Three.js when 3D scene management beats raw WebGL.
  • Defensive numerics: isFinite() guards, NaN traps, clamp before passing to GLSL.
  • Small focused functions over mega-files.
  • Inline comments explain "why."

Default scaffold — single-file HTML

<!DOCTYPE html>
<html data-mg-theme="dark">
<head>
  <meta charset="utf-8">
  <title>Demo</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/marginalia@latest/marginalia.css">
  <style>
    :root { color-scheme: dark; }
    body { max-width: 720px; margin: 2rem auto; padding: 0 1rem; }
    canvas { width: 100%; aspect-ratio: 16/9; display: block; background: #0a0a0f; }
    .controls { display: flex; gap: 1rem; padding: 1rem 0; }
  </style>
</head>
<body>
  <main>
    <h1>Title</h1>
    <p>One-paragraph context.</p>
    <canvas id="stage" width="1280" height="720"></canvas>
    <div class="controls">
      <label>Param <input type="range" id="p1" min="0" max="1" step="0.01" value="0.5"></label>
    </div>
  </main>
  <script type="module">
    'use strict';
    const cvs = document.getElementById('stage');
    const ctx = cvs.getContext('2d'); // or 'webgl2'
    const p1 = document.getElementById('p1');
    function frame() {
      const v = parseFloat(p1.value);
      if (!isFinite(v)) return requestAnimationFrame(frame);
      // ... draw using v ...
      requestAnimationFrame(frame);
    }
    frame();
  </script>
</body>
</html>

One file. No build. Marginalia handles editorial chrome. Demo lives at <project>/demos/<name>.html and can be linked from a blog post or paper.

Library choices

Need Library Why
Shaders / 3D / perf WebGL2 raw + Three.js for scenes Primary stack for shader-heavy work
Data binding to DOM/SVG D3 v7 Reactive scales, no virtual DOM
2D sketches / teaching p5.js Lowest activation energy
Audio-reactive Web Audio API + AnalyserNode Native, zero deps
Eye-tracking replay Custom Canvas2D + rAF Fixation timing matters; no abstraction
Reactive UI controls Vanilla <input type="range"> No framework needed for sliders
Rich typographic layout @chenglou/pretext Non-DOM text measurement + multi-line layout + rich inline spans. See Typographic canvas with pretext.

Demos with shareable state should follow a PermalinkManager pattern: URL hash encodes parameters, parsed on load, rewritten on change. One utility per project, referenced from every demo — if the project already has one, read it first and don't reinvent it.

Linked displays / coordinated analytic views

Linked displays are not "several charts on one page." They are one analytic state projected through several complementary views. If each chart owns its own date range, filter, cursor, or selection, the interface will feel smart for 30 seconds and then become untrustworthy. Use this contract when building brushing/linking demos, executive analysis surfaces, visual history explorers, or model-debug dashboards:

State contract

  • One central store. Keep focusDate, rangeStart, rangeEnd, active facet/lane, pinned entity, and preview entity in one object. Views read from it; views do not independently invent state.
  • Two interaction modes. Hover is a preview; click is a pin. The state bar should say which mode is active. mouseout clears previews back to the pinned state.
  • One global coordinate system. Context sparklines and small multiples use the same full x-domain even when the main view is zoomed. Draw the brushed range as an overlay; do not re-slice each metric chart.
  • Range is first-class. A brush should update all range-dependent views: event list, counts, metric start/end deltas, and mini-chart range bands.
  • Selection and range are different. A selected event/date drives point readouts; a brushed range drives aggregate summaries. Do not overload one cursor to mean both.
  • Visible state beats hidden magic. A small state bar (focus, range, facet, mode) is not chrome; it is the contract the analyst uses to trust the display.

View roles

Design each view with a job:

View Job Interaction
Overview timeline/map Show distribution and clusters Brush range; hover preview; click pin
Metric context strips Preserve common temporal context Cursor follows focus; band follows range
Facet/count panel Show composition of the current range Click facet to lens/filter
Detail/provenance card Explain the pinned entity Source link; caveats; local refs
Range table Make filtered records inspectable Row click pins; source link opens
Range summary Compare start/end values Uses range, not focus cursor

Implementation rules

  • No dynamic markLine/markArea churn for every mousemove. Use DOM overlays for cursors/ranges on small charts; mutate cheap CSS left/width values.
  • Throttle high-frequency events with requestAnimationFrame; never rerender five charts on every pixel-crossing if a CSS overlay will do.
  • Stable domains are testable. In runtime tests, assert that every metric chart has the same x-axis min/max and that cursor/range overlays line up across all cards.
  • Expose a tiny debug API on window for tests (setState, setRange, pinRecordById, previewDate, clearPreview). Good demos are inspectable.
  • Avoid "state tunneling." If a metric chart hover changes the focus date, it must use the same previewDate() path as a timeline hover.
  • Labels must not become the data. Dense point labels should hide/declutter under overlap; details belong in hover/pin panels.

Analyst use cases this supports

  • Product-history review: select a launch/decision and see metric context around it.
  • Funnel diagnosis: brush a cohort window; table, dropoff, source mix, and outcome views update together.
  • Content or marketplace analysis: lens by source/type/segment while preserving one time base for supply and demand metrics.
  • Experiment review: pin launch/decision dates; brush pre/post windows; inspect metric deltas without losing provenance.

Marginalia integration

Wrap demos in marginalia callouts for editorial context:

<aside class="mg-callout" data-type="tip">
  <h3>Try it</h3>
  <iframe src="demos/foveation.html" loading="lazy" style="width:100%;border:0;aspect-ratio:16/9"></iframe>
  <p>Drag the slider to see how cortical magnification compresses peripheral vision.</p>
</aside>

Reference exemplars

Good shapes to borrow rather than rebuild:

  • A shipped WebGL explainer for the concept you're teaching (foveation, diffusion, whatever) — reference its URL from your post, don't re-derive the math.
  • A project-local PermalinkManager utility — one implementation per repo, imported by every demo.
  • An audio-reactive shader pipeline elsewhere in the codebase — if a sibling project already solved the AnalyserNode → uniforms plumbing, import or vendor the small hot path instead of starting from scratch.

Distribution & hosting

Once a demo exists as a single-file HTML, the next decision is where it lives. Different hosts have sharply different strengths — choose based on audience (private vs public), forkability, and build friction.

Host Best for Trade
GitHub Pages Repo-hosted demos — canonical, diffable, co-located with source. Build/push latency; doesn't surface demos for browsing discovery.
CodePen Single-file shareable demos; sci-fi / FUI experiments; "paste this into a Pen" teaching artifacts; fork-to-iterate. Free Pens are public — don't paste unreleased client or research code.
Observable D3 data-viz figures with reactive cells — the right host for CHI / ETTAC / CIKM paper figures where the reader can move parameters. Cell-based, not a normal HTML file — different mental model.
gist Minimum-friction single-file distribution; linkable from notes and papers. No live preview without a third-party renderer (bl.ocks.org went read-only).
CodeSandbox / StackBlitz Full-project demos with npm dependencies; SDK examples. Overkill for single-file work; heavier iteration loop.
Local file:// Private iteration, screenshot capture, ffmpeg recording of interactive demos. Doesn't share without promoting to a host.

CodePen — the Prefill API

POST a JSON payload of HTML/CSS/JS to codepen.io/pen/define and open a new Pen with the code pre-populated, ready for the reader to run or fork. Good for "Try this in a Pen" buttons inside blog posts — one click and the reader is in a live editor with your code.

<form action="https://codepen.io/pen/define" method="POST" target="_blank">
  <input type="hidden" name="data" value='{
    "title": "FUI scan overlay",
    "html": "<canvas id=c></canvas>",
    "css":  "body{background:#000}",
    "js":   "/* demo code here */",
    "tags": ["fui","canvas","webgl"]
  }'>
  <button type="submit">Open in CodePen</button>
</form>

Full docs: blog.codepen.io/documentation/prefill. Tags, titles, descriptions, and external resources (including marginalia CDN) can all be set in the payload. For educational content, reach for a live fork before a screenshot.

CodePen — embeds

Two modes, both work inside marginalia pages:

<!-- Result only -->
<p class="codepen" data-height="400" data-default-tab="result"
   data-slug-hash="abcDEF" data-user="your-handle">
  See the Pen on CodePen.
</p>
<script src="https://cpwebassets.codepen.io/assets/embed/ei.js" async></script>

<!-- Full editor (html + css + js tabs) -->
<p class="codepen" data-height="600" data-default-tab="html,result"
   data-slug-hash="abcDEF" data-user="your-handle">...</p>

Wrap the embed in a marginalia callout for editorial framing inside a blog post:

<aside class="mg-callout" data-type="tip">
  <h3>Try it</h3>
  <p class="codepen" data-height="500" data-slug-hash="..." data-user="your-handle"></p>
</aside>

Observable — for paper figures

Observable notebooks (observablehq.com) are the right host for reactive D3 figures embedded in research papers. Cells are reactive — change one slider and every downstream cell recomputes. The embed runtime (@observablehq/runtime) lets you pull specific cells into a marginalia page, so the notebook becomes the source of truth and the blog post is a curated view into it. Ideal for CHI / ETTAC / CIKM figure supplements where the reader should be able to explore parameters.

Tag conventions

On CodePen specifically: tag each experiment with both a technical tag (webgl, canvas, svg, d3) and a genre tag (fui, hud, generative, typography, scrollytelling) so your work surfaces to the right community and theirs surfaces to yours.

Privacy caveat

Free CodePen Pens are public. Free Observable notebooks are public. Free gists can be "secret" but are still URL-accessible. Don't paste unreleased shaders, unpublished research data, or client work into any free tier — use GitHub Pages (private repo + gh-pages) or local files for anything sensitive. When in doubt, ask before pasting.

Typographic canvas with pretext

chenglou/pretext is a pure JS/TS library for multi-line text measurement and layout without ever touching the DOM. It side-steps getBoundingClientRect / offsetHeight, which trigger layout reflow — one of the most expensive operations in the browser — by implementing its own text measurement against the browser's font engine as ground truth.

For the design lineage behind the typographic ambition here (Cooper, Small, Ishizaki, Maeda → Processing → pretext), see vocabularies/visible-language.md.

When to reach for it

  • Virtualization / occlusion without height guesstimates — know the exact rendered height of every line before drawing
  • Text-in-shape layouts — fill letterforms with body text, flow copy through arbitrary polygonal bands, typeset inside a mask
  • Masonry / JS-driven flexbox alternatives where CSS-only layouts hit their limit (or introduce layout shift)
  • Canvas / SVG / server-side text where no DOM exists at all
  • AI-loop development — measure text outside the browser, verify that labels don't overflow, prevent layout shift when new text loads

Two API tiers

Tier 1 — simple paragraph wrap (one string, one font, fixed width):

import { prepareWithSegments, layoutWithLines } from 'https://esm.sh/@chenglou/pretext@0.0.5'

const prepared = prepareWithSegments('AGI 春天到了. بدأت الرحلة 🚀', '18px "Inter"')
const { lines } = layoutWithLines(prepared, 320 /* max width px */, 26 /* line height */)
for (let i = 0; i < lines.length; i++) {
  ctx.fillText(lines[i].text, 0, i * 26)
}

Handles Unicode properly — CJK, RTL, emoji, combining characters, the works. prepare* is a one-time cost; layout* is the cheap hot path on resize.

Tier 2 — rich inline (mixed weights, fonts, colors; text flowing through variable-width slots):

import {
  prepareRichInline,
  layoutNextRichInlineLineRange,
  materializeRichInlineLineRange,
} from 'https://esm.sh/@chenglou/pretext@0.0.5/rich-inline'

// Build a list of items: text fragments each with their own font
const items = [
  { text: 'HEADLINER',     font: '900 16px Inter' },
  { text: ' · ',           font: '400 16px Inter' },
  { text: 'Secondary Act', font: '600 16px Inter' },
  // …
]
const colors = ['#ffffff', 'rgba(180,180,200,0.35)', '#4ecdc4', /* … */]

const prepared = prepareRichInline(items)

// Flow through a series of slots (e.g., bands inside a letterform)
let cursor = 0
for (const slot of slots) {
  const range = layoutNextRichInlineLineRange(prepared, slot.width, cursor)
  if (!range) break
  const line = materializeRichInlineLineRange(prepared, range)
  for (const frag of line.fragments) {
    ctx.font = frag.font
    ctx.fillStyle = colors[frag.itemIndex] ?? '#ffffff'
    ctx.fillText(frag.text, slot.x + frag.x, slot.y + frag.y)
  }
  cursor = range.end
}

This is what makes text-in-shape layouts actually work: the library takes your variable-width slots and tells you exactly which rich-inline characters fit in each one, cursor-carrying across slot boundaries.

ESM direct, no build step

Pretext publishes as @chenglou/pretext on npm and serves via esm.sh. Import in a single-file HTML demo — matches the muriel no-bundler doctrine:

<script type="module">
  import { prepareRichInline } from 'https://esm.sh/@chenglou/pretext@0.0.5/rich-inline'
  // …
</script>

Pin the version (@0.0.5 at time of writing) for reproducibility in shipped demos. Pretext is early (0.0.x) and the API is still stabilizing; expect breaking changes if you use @latest.

Font preloading is mandatory

Canvas-side text measurement depends on the actual font being loaded. Measure against the fallback and the layout is wrong the instant Inter (or whatever) arrives. Preload before the first call to prepare*:

if (document.fonts?.load) {
  await Promise.all([
    document.fonts.load('900 200px Inter'),
    document.fonts.load('900 13px Inter'),
    document.fonts.load('700 13px Inter'),
    document.fonts.load('400 13px Inter'),
  ])
}

This is the Interactive JS-channel version of the Web Rendering channel's "wait for fonts" rule — same failure mode, different timing.

Reference exemplar: pretext-coachella

A publicly available worked example of pretext Tier 2: andyed/pretext-coachella — a 2026 Coachella lineup typeset inside the letterforms of "COACHELLA". Four views over the same 169-artist JSON exercise the whole API surface:

View File Pretext API What it does
Index index.html none landing page + cards linking the other three
Flow flow.html prepareRichInline + whole-document walk all 169 names as one shrinkwrapped paragraph, each word colored by genre family
Poster poster.html prepareRichInline + layoutNextRichInlineLineRange + materializeRichInlineLineRange the signature view: letters of COACHELLA sliced into bands and slots; artists sorted by tier descending, rendered 500–900 weight, stage-color per artist, justified per slot, live perf stats
Wall wall.html prepareWithSegments + layoutWithLines perspective wall of individually laid-out artist names

Reads as a direct descendant of David Small's 1996 Shakespeare navigation: text as navigable space, multi-scale rendering via tier-based weight (instead of Small's greeking), categorical color for structure, Canvas2D at 60fps because pretext measures outside the DOM reflow path. Good scaffold for any new typographic-canvas work.

Radial-menu shape flow

For pie or marking menus, keep the host's SVG or DOM wedge as the semantic target and use Pretext as the renderer-independent text compositor. First lock separate status, command-glyph, label, and exact-value rails. For horizontal copy, intersect text rails with the padded annular sector. For an explicit arc-follow probe, give Pretext the guarded arc length at each candidate label radius; do not wrap against a chord and bend the line afterward. Select one legal label tier across the peer ring; do not let short labels grow or status glyphs squeeze their local wedge. Routine state is glyphed, only decision-changing values remain wedge text, and neither is appended inline to the command label. Target paths remain byte-stable across hover and focus.

The default hybrid for existing SVG wheels is normal positioned DOM content over stable SVG targets. This follows iBlipper's production pattern: Pretext predicts line breaks and the wheel-wide type tier, ordinary DOM carries the actual typography, and a settled getBoundingClientRect() pass checks rendered truth, peer type variance, glyph/text rail gaps, and label/value separation.

An arc-follow renderer materializes the winning Pretext fragments as individual grapheme spans. Preserve kerning with cumulative-prefix measurement and reparameterize the line-to-arc blend so Cartesian interpolation cannot contract letter spacing on side wedges. Keep path bow and per-letter tangent turn as independent probe parameters, reverse traversal to keep lower-half text upright, and leave command and status glyphs unrotated. Start near 30% bow and 10% orientation follow; a 40-degree command word is a readability failure, not a target. The complete command remains on the semantic wedge and the per-letter DOM layer is visual projection.

Carrier selection precedes decoration. Short-label rings may use the shallow DOM bow. Value-dense rings and multiword commands use upright word lines, with the full source command retained in the accessible name and caption when a shorter visible displayLabel is needed.

Load references/radial-menus.md for the full state, geometry, typography, glyph, keyboard, touch, stop-condition, and proof contract. The runnable examples/radial-menu-pretext.html compares fixed midpoint SVG stacks with an adaptive carrier: a vendored Pretext 0.0.5 shallow per-grapheme bow for Psychodeli and an upright value-dense stack for Pot Luck.

Sci-fi UI (FUI) patterns

When the output wants to feel like telemetry — live stats, mission clocks, bearing readouts, signal traces — reach for the FUI vocabulary. The full grammar, substrate decision table, and common-failure list live in vocabularies/fui.md; this section is the interactive-channel seam.

Canonical stack (lifted from the vocabulary's substrate decision):

  • HTML + CSS Grid for layout and text, with font-variant-numeric: tabular-nums on any changing digits.
  • SVG for thin-stroke geometry — compass rings, corner brackets, grid overlays, leader lines.
  • Canvas2D + rAF for tickers, waveforms, and anything that re-draws per frame.
  • CSS repeating-linear-gradient for scan-line overlays; WebGL2 fragment shader if the post-process needs to vary spatially (glitch, chromatic aberration).
  • Motion from muriel's brand tokens: --mg-duration-reveal, --mg-ease-emphasis, plus a project-local --mg-stagger (80–200ms).

Runnable scaffold: examples/fui-scaffold.html. Open in a browser. Single file, no build. Four primitives on one screen:

  1. Data ticker — rAF counter with leading zeros, mission clock, packet hex.
  2. Radial compass — SVG rings + 36 tick marks + rotating needle.
  3. Canvas waveform — scrolling signal trace with RMS/peak readouts.
  4. Staggered reveal — CSS animation across the four blocks at 120ms intervals.

Corner brackets via ::before / ::after. Scan-line overlay via repeating-linear-gradient. prefers-reduced-motion fallback baked in. Every text element clears 8:1 against #0a0a0f. Swap the four CSS custom properties at the top for a brand's palette and the composition inherits.

CodePen Prefill the scaffold to hand readers a live fork — see the Prefill API section above. Useful when a blog post describes a FUI primitive; one click and the reader is in a live editor.

Lessons from past projects

  • No bare console.log. Route through a debug manager or conditional logger so shipped demos stay clean.
  • Don't dim/fade search-result cards on hover or brush. It breaks scanability more than it rewards the hovered row.
  • WebGL variable names: read the source, don't guess.
  • Shader changes need verification. Run tests or capture a screenshot before claiming a fix is working.
  • Pretext: always pin @x.y.z, never @latest, in shipped demos.
  • Pretext: always preload fonts before the first prepare* call, or the measurement is against the fallback font.

Anti-patterns

  • Don't set up listeners or rAF loops without cleanup. Memory leaks on hot reload; double-fires on navigation.
  • Don't poll state in a rAF loop when an event listener would do. Events are cheaper and correct by construction.
  • Don't use alert() inside a demo. It halts the demo's timing and disrupts screen-capture pipelines.
  • Don't hardcode parameter defaults outside PermalinkManager. Shared-state demos silently diverge when defaults live in two places.