Player
The <castwright-demo> element, its attributes, and theming the frame.
<castwright-demo> is a custom element, so it works in plain HTML, in MDX, in a
server-rendered template, and in every JavaScript framework. Importing
@casoon/castwright-player registers it.
Attributes
| Attribute | Values | Meaning |
|---|---|---|
src |
URL | A pre-built .cast, fetched at runtime. |
autoplay |
boolean | Start when the demo first scrolls into view. |
loop |
boolean | Replay after a hold on the final frame. |
loop-delay |
ms | Length of that hold. Default 2000. |
poster |
seconds, or marker:<label> |
Render this moment as the initial frame. marker:<label> uses the time of the first marker event with that label. |
cols, rows |
number | Override the cast’s geometry. |
label |
string | Accessible name. Defaults to Terminal demo: <title>. |
title-text |
string | Shown in the window chrome. Decorative. |
chrome |
window, none |
none drops the title bar. |
controls |
visible, hover, none |
See below. |
speed |
number, 0.25–4 | Playback rate. Default 1. |
speed-control |
boolean | Adds a speed button to the transport row. |
copy |
boolean | Adds a copy button to the transport row. |
Changing any of these attributes, or setting cast, on a mounted element rebuilds the
player with the new values (the demo restarts). Before the first mount they are simply
read when the element scrolls into view.
title-text rather than title, because a native title attribute would produce a
browser tooltip and be announced by assistive technology — two names for one thing.
Where the cast comes from
Three sources, checked in this order:
- a
castproperty set from JavaScript; - an inlined
<script type="application/json" data-castwright-cast>child — what the Vite plugin and the Astro component emit at build time, so there is no fetch and no parser in the bundle; - the
srcattribute, fetched at runtime.
The YAML parser is never shipped to a browser.
Controls
visible— the transport is always shown. The default.hover— the transport is overlaid on the terminal and revealed on hover or keyboard focus. Visually absent, but still a real focusable pause control, which an autoplaying loop needs. See Accessibility.none— no transport at all. Appropriate for a short demo that does not loop; the player warns when it is combined with autoplay on something that loops or runs past five seconds.
Seeking, keyboard and events
The progress bar is a seek slider: click it, drag it (mouse, touch or pen), or focus it and
use the keyboard. A drag pauses playback, seeks continuously and restores the previous play
state on release; horizontal drags on the bar do not scroll the page (touch-action: pan-y),
vertical ones still do. Markers (m events in the cast) show as decorative ticks on the bar,
and the slider’s value text names the latest marker passed (“0:03 of 0:12, marker: build”).
While focus
is inside the demo, Left/Right seek 5 seconds, Home/End jump to the start/end, k toggles
play/pause, and Space toggles on the slider. See Accessibility.
The element dispatches bubbling CustomEvents, so integrations need not poll .player:
| Event | When | detail |
|---|---|---|
castwright:ready |
The player has mounted; .player is set. |
{ durationSeconds } |
castwright:play |
Playback starts or resumes (also on each loop). | { time } |
castwright:pause |
Playback is paused — by the user, off-screen, or a hidden tab. | { time } |
castwright:ended |
The final frame was reached. | { time } |
castwright:error |
The cast could not be loaded. | { error } |
play, pause and ended are also dispatched by mount() on its host. A hidden browser
tab pauses playback and resumes it on return, so missed time is not dumped into one frame.
Playback speed
speed="1.5" (or speed: 1.5 in mount(), or demo.player.speed = 1.5 at any time)
plays faster or slower. Only elapsed wall-clock time is scaled, never the cast: seek(),
the progress bar and the time display stay in cast time, and changing the speed mid-playback
keeps the current position. loop-delay is real time and is not scaled. Values outside
0.25–4 are clamped (the attribute ignores them with a warning).
speed-control adds a button to the transport row showing the current rate. Each press
steps through 0.5×, 1×, 1.5×, 2× and back; its accessible name is Playback speed 1×, change. It is a native button, so Tab/Enter/Space work, and it is hidden with
controls="none".
Copy button
copy adds a button that writes the demo’s final text — the same text as the accessibility
fallback — to the clipboard with navigator.clipboard.writeText. A visible “Copied” (or
“Copy failed”) message appears next to it for two seconds in an aria-live="polite" region.
Where the Clipboard API is unavailable (insecure context, old browser) the button is not
rendered at all. Off by default, and hidden with controls="none".
Recordings from asciinema rec
src= accepts asciicast v2 and v3 (v3 is converted to v2 when read) and honours
idle_time_limit: a longer gap plays as the limit. A truncated or malformed event line is
skipped with a console warning instead of rejecting the file. v1 and unknown versions fail
with a clear error.
Programmatic control
mount(element, cast, options) is exported, and the element exposes its handle as
.player:
const demo = document.querySelector('castwright-demo');
demo.player.pause();
demo.player.seek(3.5);
demo.player.state; // 'idle' | 'playing' | 'paused' | 'finished'
.player is undefined until the element has mounted — which, being lazy, may be well
after it is in the DOM, and never at all if the cast fails to load.
mount()
import { mount } from '@casoon/castwright-player';
const handle = mount(hostElement, cast, {
autoplay: false,
loop: false,
loopDelayMs: 2000,
chrome: 'window',
controls: 'visible',
speed: 1,
speedControl: false,
copy: false,
title: undefined,
label: undefined,
poster: undefined,
cols: undefined,
rows: undefined,
reducedMotion: undefined, // defaults to the media query
createTerminal: undefined, // defaults to xterm.js; injectable for tests
});
mount is eager — it builds the chrome and opens a terminal immediately. It also takes
over the host’s children: they are moved into a hidden fallback element, which is what
keeps them available to assistive technology. Call it on an element you own.
The handle
| Member | Meaning |
|---|---|
play() |
Resume, or restart from a finished state. |
pause() |
Stop the clock; the screen stays as it is. |
restart() |
Reset the terminal and return to idle. Does not start playing. |
seek(seconds) |
Replay up to that moment. Clamped to the duration. |
markers |
Read-only list of { label, time } (seconds) from the cast’s m events; empty if there are none. |
seekToMarker(label) |
Seek to the first marker with that label. Returns false if there is none. |
state |
'idle' | 'playing' | 'paused' | 'finished' |
durationSeconds |
The last output event’s time, including a closing wait:. |
destroy() |
Cancel timers, disconnect observers, dispose the terminal. |
durationSeconds is the time of the last output event. A demo that ends in wait: 2000
compiles to an empty output event at the end of that wait, so the final screen holds for
those two seconds and then for the loop hold. A pause after any other last step is not
part of the cast; the loop hold covers it.
Seeking is a replay rather than a skip: terminal state is cumulative, so jumping to a moment means re-feeding every byte up to it with the timing suppressed. Starting to write from the middle would show a broken screen.
Every method is a no-op after destroy(), which also restores the host’s role,
aria-label, data-* and --castwright-* values to what they were before mount().
If the terminal was resized by an asciicast r event, seeking, restarting and looping
restore the authored size first.
Failures, and what is not retried
A demo that cannot load must not take the page down with it. When the cast cannot be
resolved — a 404, a malformed file, no source at all — the element logs to
console.error, leaves its fallback children exactly as they are, and stops. The page
shows the terminal text it was always going to show without JavaScript.
There is no automatic retry. A failed fetch is not repeated, and no backoff runs in
the background; a demo is decoration, and a page full of them retrying a broken URL is a
worse problem than a demo that did not animate. To retry, set the cast property or the
src attribute again on a freshly re-inserted element.
Removing the element from the DOM aborts a fetch that is still in flight, destroys the player, and puts the original children back. Re-inserting it starts over cleanly: one mount per insertion, with no accumulated wrappers from the previous one.
Server-side rendering
Importing @casoon/castwright-player from a module that also runs on the server is safe.
Under Node the package registers no custom element and touches no DOM: customElements
does not exist, so defineCastwrightDemo() returns without doing anything, and what the
server renders is the element’s children — the terminal’s final text. The browser
upgrades the element on hydration.
There is therefore nothing to mark client-only in SvelteKit, Nuxt, Remix, SolidStart or
Qwik, and no ssr.noExternal entry to add.
deserializeCast, buildTimeline and toXtermTheme are pure and work on the server.
mount() needs a real DOM — call it from onMount, useEffect, or a <script>.
Content-Security-Policy
The player needs no 'unsafe-inline' for styles, so it works under a hash- or
nonce-based policy such as the one Astro’s security.csp emits. Its own CSS and
xterm.js’s go in as a constructed stylesheet (document.adoptedStyleSheets), which
style-src does not govern.
xterm.js itself styles the terminal at run time in two ways a strict style-src blocks:
<style> elements whose contents depend on the theme and the measured cell size, and
style attributes on cells with 24-bit colour. The player repairs both as they appear —
each <style> is mirrored into a constructed stylesheet, and a refused style attribute
is re-applied through the CSSOM. Browsers still report the blocked originals as CSP
violations in the console; the terminal renders correctly regardless. Where the policy
allows inline styles, both repairs change nothing.
The script itself is an ordinary module script, so script-src 'self' covers it when it
is served from the site. Verified in Chromium, Safari and Firefox.
Firefox and 24-bit colour. Firefox will not let a refused
styleattribute be re-applied through the CSSOM with the same value, so under a strictstyle-srccells in 24-bit colour ({#a6e3a1},show:highlighting) fall back to the default foreground. Layout, the theme and the 16-colour palette are unaffected. Allowing inline style attributes — not<style>elements — restores them:style-src 'self' …; style-src-attr 'unsafe-inline'Style attributes cannot load resources or match elements, so this is a much narrower allowance than
'unsafe-inline'onstyle-src.
If the page cannot load a script at all, the animated SVG export
(castwright build --format svg) needs neither: embedded as an <img>, it is subject
only to img-src.
Lazy by default
xterm.js is not instantiated until the element first scrolls into view, and playback pauses when it scrolls out. A documentation page with six demos does not pay for six terminals up front, and does not animate them all off-screen.