streaming-ui-script · stopwatch ← Back to docs
JS demo · Script() + ctx.cleanup

A stopwatch driven entirely by Script()

The same <streaming-ui-script> program drives the display, the controls, and the laps list — but the actual ticking is wired by a single Script(...). It uses setInterval for sub-second precision, ctx.cleanup to dispose the timer on pause / reset, and re-runs whenever $running flips.

Live preview

Start, pause, lap, and reset all flow through plain Action([@Set(...)]) — only the timer itself lives in JS. Toggle Show source below the demo to inspect the program.

UI Script source

Plain streaming-ui-script with two scripts: ticker increments $elapsedMs every 50 ms while running, and laptimer formats the latest split. Everything else is declarative.

$elapsedMs = 0
$running = false
$laps = []
$nextLapId = 1

ticker = Script("ticker", "if (!ctx.state.get('running')) return; const start = performance.now() - (ctx.state.get('elapsedMs') ?? 0); let raf = 0; const loop = () => { if (ctx.signal.aborted) return; ctx.state.set('elapsedMs', Math.round(performance.now() - start)); raf = requestAnimationFrame(loop); }; raf = requestAnimationFrame(loop); ctx.cleanup(() => cancelAnimationFrame(raf));", ["running"])

formatter = Script("formatter", "const ms = ctx.state.get('elapsedMs') ?? 0; const total = Math.floor(ms); const m = Math.floor(total / 60000); const s = Math.floor((total % 60000) / 1000); const cs = Math.floor((total % 1000) / 10); const pad = (n, w=2) => String(n).padStart(w, '0'); ctx.state.set('display', pad(m) + ':' + pad(s) + '.' + pad(cs));", ["elapsedMs"])

$display = "00:00.00"

display = Card([
  CardHeader("Elapsed", "mm:ss.cs"),
  TextContent($display, "large-heavy")
], "elevated")

primary = $running ? "Pause" : "Start"
primaryVariant = $running ? "secondary" : "primary"

controls = Buttons([
  Button(primary, Action([@Set($running, !$running)]), primaryVariant),
  Button("Lap", Action([
    @Js("const id = ctx.state.get('nextLapId') ?? 1; const laps = ctx.state.get('laps') ?? []; const previousMs = laps.length > 0 ? laps[0].totalMs : 0; const totalMs = ctx.state.get('elapsedMs') ?? 0; const splitMs = totalMs - previousMs; const fmt = (ms) => { const m = Math.floor(ms/60000); const s = Math.floor((ms%60000)/1000); const cs = Math.floor((ms%1000)/10); const p = (n) => String(n).padStart(2,'0'); return p(m)+':'+p(s)+'.'+p(cs); }; ctx.state.set('laps', [{id: id, label: 'Lap ' + id, total: fmt(totalMs), split: fmt(splitMs), totalMs: totalMs}, ...laps]); ctx.state.set('nextLapId', id + 1);")
  ]), "ghost"),
  Button("Reset", Action([@Set($running, false), @Reset($elapsedMs, $laps, $nextLapId)]), "ghost")
])

lapEmpty = TextContent("No laps yet. Hit “Lap” to record splits.", "small", "muted")

lapTable = Table([
  Col("Lap", $laps.label),
  Col("Total", $laps.total),
  Col("Split", $laps.split)
])

lapsView = @Count($laps) > 0 ? lapTable : lapEmpty
lapsCard = Card([CardHeader("Lap log", "Most recent first"), lapsView])

root = Stack([display, controls, lapsCard, ticker, formatter])

How it works