Skip to content
Regor · Open source UI library

Small signals.
Big possibilities.

Regor is a reactive UI library for TypeScript. Connect state to HTML and SVG, compose components, and update the DOM directly.

npm install regor

Start with existing markup. Add reactivity where you need it.

Two files. One reactive counter.REGOR
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>
Live preview · try it
0count
State → DOM. Directly.No Virtual DOM.
DOM-firstEnhance the markup you already have.
Fine-grained reactivityRefs, computed values, and observers.
TypeScript-nativeTyped props, contexts, and components.
01 / Closer to the web

State to screen.
With less ceremony.

Bind a single element or compose an application. The same primitives connect your state, markup, and components.

01

Explicit reactivity

Model state with refs, derive values with computed, and coordinate updates with observers and batching.

Explore Explicit reactivity
02

Expressive templates

Render lists and conditions, handle events, and bind form values with directives in your HTML.

Explore Expressive templates
03

Composable components

Define components in TypeScript, pass typed props, and share context across your interface.

Explore Composable components
02 / A beautiful full circle

Better together.
From UI to docs.

Regor 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.

03 / Find your next step

Start here.
Keep building.

Follow the guide to your first app, then use the directive and API references as you build.

04 / State in motion

Reactive data.
Live SVG.

Change the workload, adjust intensity, or toggle streams. The chart and its metrics update from the same state.

SIGNAL STUDIO

Three streams. One reactive view.

Static preview
Total average233 events/s
Stream peak151 events/s
Visible streams3 / 3
Simulated event rates across 60 samples 3 visible streams. Average combined rate 233 events per second. Peak stream rate 151 events per second. Static preview. 0 60 120 180 240 EVENTS / SECOND 0115304560
Sample 60 / 60
Search123Events53Jobs57
Synthetic data. Real reactive SVG.60 samples · 3 streams · no chart dependency
Explore the sourceState · SVG template · animation lifecycle
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.

Make your next idea reactive.

Add Regor to your project and connect your first piece of state.

Start building