Reactive JSX, compiled to direct DOM updates.

In Sigil, state is a plain let. The compiler turns every expression that reads it into a small update of its own, so there's no virtual DOM and nothing re-renders. Grimoire adds the server half: file routes, data loading, form actions and WebSockets, all running on Bun.

bunx @sigil-dev/cli create my-app

You write

let count = $state(0);
let doubled = $derived(count * 2);

export const Counter = () => (
  <button onClick={() => count++}>
    {count} × 2 = {doubled}
  </button>
);
Running on this page:

The browser gets

import { createSignal, createEffect, createMemo } from "@sigil-dev/runtime";
const count = createSignal(0);
let doubled = createMemo(() => count() * 2);
export const Counter = () => (() => {
  const _el0 = document.createElement("button");
  _el0.addEventListener("click", () => count.set(count() + 1));
  const _el1 = document.createTextNode("");
  createEffect(() => {
    _el1.textContent = String(count());
  });
  _el0.append(_el1);
  _el0.append(document.createTextNode(" × 2 = "));
  const _el2 = document.createTextNode("");
  createEffect(() => {
    _el2.textContent = String(doubled());
  });
  _el0.append(_el2);
  return _el0;
})();

Updates, not re-renders

A component runs once. After that, a change to count re-runs only the updates that read count. Nothing diffs a tree.

One source, three builds

Each component compiles to DOM code for the browser, to HTML strings for the server, and to hydration code that adopts the server's markup instead of rebuilding it.

Small enough to read

The compiler, runtime and server are a few thousand lines of TypeScript. When the docs don't answer a question, the internals pages show you where to look.

Before you pick it

  • Sigil is young and pre-1.0. The API can still change between minor versions.
  • It runs on Bun only: the dev server, the production server, and the build.
  • There's no ecosystem of components or plugins to lean on yet, and some edges are rough. Known limitations lists them.