Explicit reactivity
Model state with refs, derive values with computed, and coordinate updates with observers and batching.
Explore Explicit reactivityRegor is a reactive UI library for TypeScript. Connect state to HTML and SVG, compose components, and update the DOM directly.
npm install regorStart with existing markup. Add reactivity where you need it.
import { createApp, ref } from 'regor'
const count = ref(0)
createApp({
count,
increment: () => count(count() + 1),
reset: () => count(0),
}, { selector: '#counter' }) <div id="counter">
<output r-text="count">0</output>
<button @click="increment">+1</button>
<button @click="reset">Reset</button>
</div> Bind a single element or compose an application. The same primitives connect your state, markup, and components.
Model state with refs, derive values with computed, and coordinate updates with observers and batching.
Explore Explicit reactivityRender lists and conditions, handle events, and bind form values with directives in your HTML.
Explore Expressive templatesDefine components in TypeScript, pass typed props, and share context across your interface.
Explore Composable componentsRegor powers PureStack's UI. It renders PureStack's components and powers its interactive browser applications.
PureStack builds Regor's docs. It turns this site's Markdown, Regor components, and typed styles into HTML, CSS, and JavaScript.
This site puts both to work: PureStack generates the pages, and Regor runs the interactive examples.
Built with PureStack. Interactive with Regor.
Follow the guide to your first app, then use the directive and API references as you build.
Change the workload, adjust intensity, or toggle streams. The chart and its metrics update from the same state.
import { computed, ref, sref } from 'regor'
const samples = 60
const vertical = { top: 24, bottom: 264, ceiling: 240 }
const definitions = [
{ id: 'search', label: 'Search', base: 112, amplitude: 32, offset: 0 },
{ id: 'events', label: 'Events', base: 76, amplitude: 25, offset: 2.4 },
{ id: 'jobs', label: 'Jobs', base: 42, amplitude: 17, offset: 4.6 },
] as const
interface Point {
x: number
y: number
}
function smoothPath(points: Point[]) {
let path = `M ${points[0].x.toFixed(2)} ${points[0].y.toFixed(2)}`
for (let i = 1; i < points.length; i++) {
const before = points[Math.max(0, i - 2)]
const start = points[i - 1]
const end = points[i]
const after = points[Math.min(points.length - 1, i + 1)]
const control1 = {
x: start.x + (end.x - before.x) / 6,
y: start.y + (end.y - before.y) / 6,
}
const control2 = {
x: end.x - (after.x - start.x) / 6,
y: end.y - (after.y - start.y) / 6,
}
path += ` C ${control1.x.toFixed(2)} ${control1.y.toFixed(2)}, ${control2.x.toFixed(2)} ${control2.y.toFixed(2)}, ${end.x.toFixed(2)} ${end.y.toFixed(2)}`
}
return path
}
export function createChartModel(interactive = false) {
const phase = ref(0)
const intensity = ref(100)
const workload = ref('steady')
const running = ref(interactive)
const enabled = sref<string[]>(definitions.map((s) => s.id))
const selected = ref<number | null>(null)
const width = ref(980)
const plot = computed(() => ({ ...vertical, left: 58, right: width() - 36 }))
const index = computed(() => selected() ?? samples - 1)
const cursorX = computed(
() =>
plot().left + (index() / (samples - 1)) * (plot().right - plot().left),
)
const signals = computed(() =>
definitions.map((s) => ({ ...s, enabled: enabled().includes(s.id) })),
)
const series = computed(() => {
const time = phase()
const scale = intensity() / 100
const mode = workload()
const bounds = plot()
return definitions
.filter((s) => enabled().includes(s.id))
.map((signal) => {
const values = Array.from({ length: samples }, (_, i) => {
const x = i / (samples - 1)
const wave = Math.sin(i * 0.15 + time * 0.7 + signal.offset)
const detail = Math.sin(i * 0.39 - time * 0.4 + signal.offset) * 0.22
const surge =
mode === 'launch'
? Math.exp(
-(((x - (0.58 + Math.sin(time * 0.3) * 0.14)) / 0.13) ** 2),
) * 66
: 0
const pulse =
mode === 'waves' ? Math.sin(i * 0.28 + time * 1.3) * 22 : 0
return Math.max(
4,
Math.min(
bounds.ceiling - 4,
(signal.base +
(wave + detail) * signal.amplitude +
surge +
pulse) *
scale,
),
)
})
const points = values.map((value, i) => ({
x: bounds.left + (i / (samples - 1)) * (bounds.right - bounds.left),
y:
bounds.bottom -
(value / bounds.ceiling) * (bounds.bottom - bounds.top),
}))
const line = smoothPath(points)
return {
...signal,
values,
points,
line,
area: `${line} L ${bounds.right} ${bounds.bottom} L ${bounds.left} ${bounds.bottom} Z`,
}
})
})
const readout = computed(() =>
series().map((s) => ({
id: s.id,
label: s.label,
value: Math.round(s.values[index()]),
x: s.points[index()].x,
y: s.points[index()].y,
})),
)
const average = computed(() =>
Math.round(
series().reduce(
(sum, s) => sum + s.values.reduce((a, b) => a + b, 0) / samples,
0,
),
),
)
const peak = computed(() =>
Math.round(Math.max(0, ...series().flatMap((s) => s.values))),
)
const status = computed(() =>
!interactive ? 'Static preview' : running() ? 'Live simulation' : 'Paused',
)
const sampleLabel = computed(
() => `Sample ${String(index() + 1).padStart(2, '0')} / ${samples}`,
)
const description = computed(
() =>
`${series().length} visible streams. Average combined rate ${average()} events per second. Peak stream rate ${peak()} events per second. ${status()}.`,
)
return {
interactive,
phase,
intensity,
workload,
running,
signals,
series,
readout,
average,
peak,
status,
sampleLabel,
description,
selected,
index,
cursorX,
plot,
width,
grid: [0, 60, 120, 180, 240].map((value) => ({
value,
y:
vertical.bottom -
(value / vertical.ceiling) * (vertical.bottom - vertical.top),
})),
ticks: computed(() =>
[0, 14, 29, 44, 59].map((i) => ({
x: plot().left + (i / (samples - 1)) * (plot().right - plot().left),
label: String(i + 1).padStart(2, '0'),
})),
),
toggle: (id: string) => {
const current = enabled()
if (current.includes(id)) {
enabled(current.filter((s) => s !== id))
} else enabled([...current, id])
},
togglePlayback: () => running(!running()),
newWindow: () => phase(phase() + 12),
selectSample: (event: Event) =>
selected((event.target as HTMLInputElement).valueAsNumber),
inspect: (event: PointerEvent) => {
const rect = (
event.currentTarget as SVGSVGElement
).getBoundingClientRect()
const x = ((event.clientX - rect.left) / rect.width) * width()
const bounds = plot()
selected(
Math.max(
0,
Math.min(
samples - 1,
Math.round(
((x - bounds.left) / (bounds.right - bounds.left)) *
(samples - 1),
),
),
),
)
},
followLatest: () => selected(null),
}
}
export type ChartModel = ReturnType<typeof createChartModel> import { html } from 'regor'
export const chartTemplate = html`<div
class="signal-dashboard theme--dark tone--neutral"
>
<div class="signal-toolbar">
<div>
<span class="signal-kicker">SIGNAL STUDIO</span>
<h3>Three streams. One reactive view.</h3>
</div>
<div class="signal-actions">
<span class="signal-status" :class="{ 'is-live': running }"
><i aria-hidden="true"></i><span r-text="status"></span
></span>
<button
class="signal-button"
type="button"
:disabled="!interactive"
:aria-pressed="!running"
@click="togglePlayback"
r-text="running ? 'Pause' : 'Resume'"
></button>
<button
class="signal-button"
type="button"
:disabled="!interactive"
@click="newWindow"
>
Jump ahead
</button>
</div>
</div>
<div class="signal-controls">
<label class="signal-field"
>Workload
<select
r-model="workload"
:disabled="!interactive"
aria-label="Simulated workload"
>
<option value="steady">Steady flow</option>
<option value="launch">Launch spike</option>
<option value="waves">Traffic waves</option>
</select>
</label>
<label class="signal-field signal-intensity"
>Intensity <span r-text="intensity + '%'"></span>
<input
type="range"
min="40"
max="140"
step="1"
r-model.number="intensity"
:disabled="!interactive"
aria-label="Signal intensity"
/>
</label>
<div class="signal-legend" role="group" aria-label="Visible streams">
<button
r-for="signal in signals"
:key="signal.id"
class="signal-chip"
:class="['signal--' + signal.id, { 'is-hidden': !signal.enabled }]"
:aria-pressed="signal.enabled"
:disabled="!interactive"
type="button"
@click="toggle(signal.id)"
>
<i aria-hidden="true"></i><span r-text="signal.label"></span>
</button>
</div>
</div>
<div class="signal-metrics">
<div>
<span
title="Average combined rate of the visible streams across all 60 samples"
>Total average</span
><strong><span r-text="average"></span><small> events/s</small></strong>
</div>
<div>
<span title="Highest rate in any visible stream across all 60 samples"
>Stream peak</span
><strong><span r-text="peak"></span><small> events/s</small></strong>
</div>
<div>
<span>Visible streams</span
><strong><span r-text="series.length"></span><small> / 3</small></strong>
</div>
</div>
<div class="signal-plot">
<p class="signal-empty" r-if="series.length === 0">
Enable a stream to bring the chart back.
</p>
<svg
class="signal-svg"
:view-box.camel="'0 0 ' + width + ' 310'"
role="img"
aria-labelledby="signal-chart-title signal-chart-description"
@pointermove="inspect"
@pointerleave="followLatest"
>
<title id="signal-chart-title">
Simulated event rates across 60 samples
</title>
<desc id="signal-chart-description" r-text="description"></desc>
<defs>
<linearGradient
r-for="signal in signals"
:key="signal.id"
:id="'signal-fill-' + signal.id"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
class="signal-stop"
:class="'signal--' + signal.id"
stop-opacity="0.18"
/>
<stop
offset="100%"
class="signal-stop"
:class="'signal--' + signal.id"
stop-opacity="0"
/>
</linearGradient>
</defs>
<g class="signal-grid" r-for="line in grid" :key="line.value">
<line :x1="plot.left" :x2="plot.right" :y1="line.y" :y2="line.y" />
<text
x="42"
:y="line.y + 4"
text-anchor="end"
r-text="line.value"
></text>
</g>
<text class="signal-axis-label" x="58" y="14">EVENTS / SECOND</text>
<path
r-for="s in series"
:key="s.id"
class="signal-area"
:d="s.area"
:fill="'url(#signal-fill-' + s.id + ')'"
></path>
<path
r-for="s in series"
:key="s.id"
class="signal-line"
:class="'signal--' + s.id"
:d="s.line"
></path>
<line
class="signal-cursor"
:x1="cursorX"
:x2="cursorX"
:y1="plot.top"
:y2="plot.bottom"
/>
<circle
r-for="point in readout"
:key="point.id"
class="signal-point"
:class="'signal--' + point.id"
:cx="point.x"
:cy="point.y"
r="4.5"
></circle>
<text
class="signal-tick"
r-for="tick in ticks"
:key="tick.label"
:x="tick.x"
y="290"
text-anchor="middle"
r-text="tick.label"
></text>
</svg>
</div>
<div class="signal-inspector">
<span class="signal-sample" r-text="sampleLabel"></span>
<div class="signal-readouts">
<span
r-for="point in readout"
:key="point.id"
:class="'signal--' + point.id"
><i aria-hidden="true"></i><span r-text="point.label"></span
><strong r-text="point.value"></strong
></span>
</div>
<label class="signal-scrubber"
>Inspect<input
type="range"
min="0"
max="59"
step="1"
.value="index"
@input="selectSample"
:disabled="!interactive"
aria-label="Inspect chart sample"
/></label>
</div>
<div class="signal-footnote">
<span>Synthetic data. Real reactive SVG.</span
><span>60 samples · 3 streams · no chart dependency</span>
</div>
</div>` import { createApp, observe, onMounted, onUnmounted, useScope } from 'regor'
import { createChartModel } from './chart-model'
import { chartTemplate } from './chart-view'
const root = document.getElementById('signal-chart')
if (root) {
createApp(
useScope(() => {
const model = createChartModel(true)
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)')
if (reducedMotion.matches) model.running(false)
let visible = false
let frame = 0
let lastTime = 0
// Regor owns the state and SVG bindings. The browser supplies a frame clock.
const tick = (time: number) => {
if (lastTime === 0) lastTime = time
if (time - lastTime >= 32) {
model.phase(model.phase() + Math.min(time - lastTime, 80) / 1000)
lastTime = time
}
frame = requestAnimationFrame(tick)
}
const reconcile = () => {
cancelAnimationFrame(frame)
frame = 0
lastTime = 0
if (visible && model.running() && !document.hidden)
frame = requestAnimationFrame(tick)
}
const visibility = new IntersectionObserver(
(entries) => {
visible = entries.some((entry) => entry.isIntersecting)
reconcile()
},
{ threshold: 0.1 },
)
const motionChanged = () => {
if (reducedMotion.matches) model.running(false)
}
const resize = new ResizeObserver((entries) => {
const width = entries[0]?.contentRect.width
if (width > 0) model.width(width)
})
observe(model.running, reconcile)
onMounted(() => {
visibility.observe(root)
const svg = root.querySelector('.signal-svg')
if (svg) resize.observe(svg)
document.addEventListener('visibilitychange', reconcile)
reducedMotion.addEventListener('change', motionChanged)
})
onUnmounted(() => {
cancelAnimationFrame(frame)
visibility.disconnect()
resize.disconnect()
document.removeEventListener('visibilitychange', reconcile)
reducedMotion.removeEventListener('change', motionChanged)
})
return model
}),
{ element: root, template: chartTemplate },
)
} Refs hold the state. Computed values derive the curves and metrics. Keyed SVG elements update in place. Learn about reactivity and lifecycle cleanup.
Add Regor to your project and connect your first piece of state.
Start building