Quill docs
Overview
Quill (Qirava Quill) is a Rust-native, zero-dependency UI and app framework. You write your pages in Rust, the server renders plain HTML, and by default the browser downloads no JavaScript at all. When a page needs interactivity you opt in with an island — a self-contained component that the server renders as a working static fallback and the tiny hand-written client runtime hydrates on demand. There is no framework runtime to install, no build step that pulls in npm, and no server-side JavaScript: every crate is std-only and depends only on other Quill crates. This page explains what Quill is, how its layers fit together, and where to begin.
What Quill is#
Quill gives you a Next.js-like authoring model — native server-side rendering, islands, and static export — with a shadcn-like headless+styled component split, all in Rust. The shipped client runtime is hand-written vanilla JavaScript of a few kilobytes with zero runtime dependencies, and it is only sent to the browser on pages that actually use an island. A page with no islands ships zero JavaScript; view-source shows no <script> tag at all.
- Author in Rust. Pages are functions that build a
Nodetree (via theview!{}macro or the builder API) and return framed HTML. - Zero JavaScript by default. The runtime is injected only when a page contains an island, and it carries only the behaviors that page uses.
- Islands hydrate on demand. The server renders a correct static fallback (works with JS disabled); the client re-instantiates from a props sidecar on the island's declared trigger.
- Zero third-party dependencies. Every crate is
std-only and depends only on siblingqquill-*crates. The shipped JS is hand-written, never bundled from npm. - One render path. The same code serves live HTML and exports a static site, so what you serve and what you deploy are byte-identical.
The layer map#
Quill is a set of small crates layered on one root view core. Products depend inward; the dependencies never point the other way. qquill-view is the root and has no dependencies; the icon, style, and theme layers build on it; qquill-ui (headless component state machines) and qquill-signal (the reactive core) sit beside them; qquill-design composes icons + style + theme + ui + view into a styled component library; and qquill-docs composes the full stack into documentation-site components. qquill-runtime (the client JS) and qquill-build (static export) stand alongside as standalone helpers, and qquill-cli is the quill scaffolder.
| Crate | Rust crate name | Role |
|---|---|---|
| qquill-view | qquill_view | View core and root crate. The borrow-friendly `Node` tree (Element / Text / Raw / Fragment, plus Island / Slot), the `view!{}` macro, the builder API (`el`, `text`, `raw`, `fragment`, `island`, `slot`), and a streaming HTML renderer with a hand-written escaper and correct void-element handling. No dependencies. |
| qquill-style | qquill_style | CSS compiler. One co-located style block compiles to deterministic, escaped CSS for inlining into the SSR `<head>`. Authored with the `style!{}` macro or the `StyleBlock`/`Rule` builder. |
| qquill-theme | qquill_theme | Typed design-token system. Tokens emitted as `--q-*` CSS custom properties; light / dark / contrast modes as token overrides switched via one `data-q-theme` attribute; a tiny no-flicker boot script; a WCAG-AA contrast API. |
| qquill-icons | qquill_icons | Icon set over the view tree. SVG stored as compile-time `const` data, an `Icon` enum giving compile-time name validation, plus the real Qirava brand assets and a sprite generator. |
| qquill-signal | qquill_signal | Reactive signal core. Server-inert / client-reactive `Signal<T>`: renders an initial value into static HTML with a `data-q-bind` marker and serializes into island props; `computed`/`effect` are client-only. |
| qquill-ui | qquill_ui | Headless component state machines (button, field, checkbox, toggle, tabs, disclosure, dialog, menu, tooltip, and more). Each is a pure `State` + transitions + an `attrs()` producing correct ARIA/`data-*` attributes, stamped onto a view `Node`. No styling, no DOM, no JS. |
| qquill-design | qquill_design | Styled component library. Maps a headless `qquill_ui` state onto a `Node` with variant classes and theme CSS-var colors, returning a `Styled` (node + companion style block + optional island hint). Variants are `const` lookup tables. |
| qquill-docs | qquill_docs | Reusable documentation-site components built on the full stack: `DocPage`, `DocShell`, `CodeBlock`, `CodeTabs`, `Callout`, `ApiEntry`, `PageNav`, and `heading`. |
| qquill-runtime | qquill_runtime | The hand-written, zero-import client islands runtime (signal core, lazy triggers, behavior registry, hydration scanner, behaviors). A Rust bundler concatenates and safely minifies the JS modules into one `<script>`, shipping only the behaviors a page uses. |
| qquill-build | qquill_build | Build / static-export helpers. Owns the URL-to-file mapping, the `public/` copy, and the Cloudflare Pages `_headers` / `_redirects` / `404.html` emission for `quill build`. |
| qquill-cli | quill (binary) | The scaffolder. `quill new <name>` writes a minimal, real Quill app; `quill build` exports the current app to a static `dist/`. |
qquill-view -- (root; no deps)
^ ^ ^
icons --+ | +-- signal, ui --+
style ----+ |
theme ----+ |
v v
qquill-design --------> qquill-docs
qquill-runtime, qquill-build (standalone)How a page becomes HTML#
A Quill app is a plain Rust binary. It keeps one PAGES list — each entry is a route id, a URL path, and a handler fn(&[u8]) -> FunctionResponse. Both serving and static export walk that one list, so they can never drift. A handler builds a Node tree, wraps it in a full document, and frames it as an HTTP response. The single render path (app::respond_html) renders the tree to bytes, and only if the tree contains an island does it append the props sidecars plus the one runtime <script> (carrying only the behaviors this page uses).
GET /counter
|
v
worker matches _sys_routes row -> qq.render.counter handler
|
v
page() builds a Node tree (view!{} or el(...))
|
v
document(title, extra_head, content) -> <html><head>(inline theme CSS)</head><body>...
|
v
respond_html(&tree):
render_into -> "<!doctype html>..."
page_has_islands? -- no --> ship as-is (ZERO JS)
\- yes --> append props sidecars + one <script> (only used behaviors)
|
v
framed text/html response (with Cache-Control)The view!{} macro is the ergonomic authoring grammar; it lowers to the same builder API (el/text/child/...) and produces byte-identical trees. Elements may use .class and #id shorthands; (expr) interpolates any impl IntoNode as escaped content; [expr] inserts trusted raw markup. For hyphenated attribute names (such as data-q-bind), use the builder API, since macro_rules cannot capture - in an identifier.
use qquill_view::{view, Node};
fn page() -> Node {
view! {
main {
h1 { "Hello from Quill" }
p .lead { "This page is server-rendered and ships zero JavaScript." }
p {
"Want interactivity without shipping a framework? See the "
a href="/counter" { "counter island" }
" -- it hydrates on its own, on demand."
}
}
}
}Islands: interactivity you opt into#
An island is a self-contained interactive component. The server renders a correct static fallback (so it works with JavaScript disabled), and the client runtime hydrates just that subtree on its declared trigger. You build one with the island(instance_id, kind, trigger, props, fallback) builder. The instance_id must be unique within the page; kind selects the client behavior; trigger decides when to hydrate; props is a JSON string the client re-instantiates from (escaped for the <script> sidecar at render time); and fallback is the static server HTML.
| Trigger | data-q-trigger value | Hydrates when |
|---|---|---|
| Trigger::Load | load | the runtime boots |
| Trigger::Visible | visible | the island scrolls into view (IntersectionObserver) |
| Trigger::Interaction | interaction | first pointer/focus/key interaction with the island |
| Trigger::Idle | idle | the main thread is idle (requestIdleCallback, setTimeout fallback) |
The SSR markup and the client behavior share a small attribute contract: data-q-bind marks the display target a behavior reads/writes, and data-q-action marks an action hook. The example below is the starter's counter island and the exact HTML the renderer emits for it. The boundary element carries data-q-island / data-q-kind / data-q-trigger; the props sidecar is collected separately and placed once before </body> alongside the runtime script.
Request
use qquill_view::{el, island, text, Node, Trigger};
fn counter(instance_id: &'static str, start: i64) -> Node {
let fallback = el("div")
.class("counter")
.child(
el("output")
.attr("data-q-bind", "count")
.child(text(start.to_string())),
)
.child(
el("button")
.attr("type", "button")
.attr("data-q-action", "inc")
.child(text("+1")),
);
let props = format!("{{\"start\":{start}}}");
island(instance_id, "counter", Trigger::Load, props, fallback)
}
// counter("counter-1", 0)Response
<!-- boundary element + server fallback, emitted in document order -->
<div data-q-island="counter-1" data-q-kind="counter" data-q-trigger="load"><div class="counter"><output data-q-bind="count">0</output><button type="button" data-q-action="inc">+1</button></div></div>Output
<!-- props sidecar, injected once before </body> by respond_html -->
<script type="application/qq" data-q-for="counter-1">{"start":0}</script>
<!-- followed by exactly one runtime <script> carrying only the `counter` behavior -->Get started in five steps#
The quill CLI lives in the repo at qquill/qquill-cli. Build it once with cargo build; the binary lands at qquill/qquill-cli/target/debug/quill. The CLI bakes in your Qirava checkout location at build time, so the apps it generates already point their path dependencies at the right place — no manual editing.
# 1. Build the scaffolder once
cd qquill/qquill-cli && cargo build # -> target/debug/quill (put on PATH)
# 2. Scaffold an app (run from your Qirava checkout root)
quill new myapp
# 3. Run it
cd myapp && cargo run # serves at http://127.0.0.1:7179
# 4. Export a static site (no server needed)
quill build # -> ./dist (wraps: cargo run --release -- build)
# 5. Deploy ./dist to Cloudflare Pages (or any CDN)
npx wrangler pages deploy distRequest
quill new myappResponse
Created Quill app `myapp`.
Next steps:
cd myapp && cargo run # serve at http://127.0.0.1:7179
cargo run -- build # export a static dist/ (no server)
open http://127.0.0.1:7179The starter serves the home page (pure server-rendered HTML, no <script> at all) and /counter (one hydrating island). It runs in memory (Qdb::new()), so there is nothing to clean up; swap main.rs to Qdb::open(dir)? to persist data across restarts. Override the listen address with the QUILL_ADDR environment variable (default 127.0.0.1:7179).
- quill new <name>
- Scaffold a new Quill app in ./<name>. Refuses to overwrite an existing directory; validates the name as a Cargo package name.
- quill build [outdir]
- Export the current app to a static dist/ (default: dist). A thin wrapper around
cargo run --release -- buildrun from inside the app directory. - cargo run
- Serve mode: open a db, register each page handler under qq.render.<id>, declare a _sys_routes row per page, and serve over a worker.
- cargo run -- build [out]
- Build mode: render every page in-process via the same render path, write HTML per route plus copied public/ assets and Cloudflare Pages control files. No database, no socket.
What gets generated#
quill new myapp writes a complete, real app. Every dependency is a path dependency into your Qirava checkout (zero third-party): qdms, qexec, qvalue, and the qquill-view / qquill-style / qquill-build / qquill-runtime crates.
myapp/
Cargo.toml # path deps into your Qirava checkout (zero third-party)
README.md
.gitignore # /target, Cargo.lock, /dist
public/
favicon.svg # everything under public/ is copied verbatim on export
src/
main.rs # the PAGES list + boot (serve) + cmd_build (export)
app/
mod.rs # document() shell + respond_html() (the island rule)
theme.rs # design tokens + base CSS via style!{} -- re-skin here
routes/
mod.rs # the route-module list
index.rs # GET / -- a static, zero-JS page (view!{})
counter.rs # GET /counter -- one hydrating islandAdding a page is three edits, then restart cargo run: write the route module (src/app/routes/<id>.rs exporting pub fn respond(_input: &[u8]) -> FunctionResponse), register the module in src/app/routes/mod.rs (pub mod <id>;), and add an entry to the PAGES array in src/main.rs. That is the entire contract — both serve and build read the one PAGES list.
// src/app/routes/about.rs -- GET /about, a static page.
use qexec::FunctionResponse;
use qquill_view::{view, Node};
use crate::app::{document, respond_html};
fn page() -> Node {
view! {
main {
h1 { "About" }
p .lead { "This page was added by hand in three steps." }
}
}
}
pub fn respond(_input: &[u8]) -> FunctionResponse {
let tree = document("About", Node::Fragment(vec![]), page());
respond_html(&tree)
}
// src/app/routes/mod.rs: pub mod about;
// src/main.rs PAGES: Page { id: "about", path: "/about", handler: app::routes::about::respond },Static export and deploy#
Because every page goes through the one render path, you can render every route once at build time and write plain files to disk. The output needs no database, no worker, and no socket — just a CDN. cmd_build walks the same PAGES list, calls each page's respond(&[]), strips the __headers frame to recover the bare HTML bytes plus the page's Cache-Control, and hands them to the qquill-build export helper, which owns the URL-to-file mapping and the Cloudflare Pages control files. Dynamic routes (such as /users/:id) have no static file and are reported as skipped.
Request
quill buildResponse
Exported 2 page(s) to dist/
dist/index.html
dist/counter/index.html
Copied 1 asset(s) from public/
Deploy dist/ to Cloudflare Pages -- it serves with no DMS running.dist/
index.html # GET / -> dist/index.html
counter/
index.html # GET /counter -> pretty URL: counter/index.html
favicon.svg # everything under public/ copied verbatim
_headers # per-route Cache-Control + immutable /assets/*
_redirects # documented placeholder (a pure static export needs none)
404.html # served for unmatched paths (Cloudflare Pages picks it up)Routes map to pretty URLs (/ to index.html, /counter to counter/index.html), and the bytes on disk are byte-identical to what the live server sends. To deploy with a Git-connected Cloudflare Pages build, set the build command to cargo run --release -- build and the build output directory to dist; Quill writes the _headers, 404.html, and _redirects files for you.
Where to go next#
- The view layer: the
Nodetree, theview!{}macro grammar, and the builder API inqquill-view. - Styling and theming: the
style!{}macro inqquill-styleand the--q-*token system, modes, and no-flicker boot inqquill-theme. - Components: headless state machines and the ARIA/
data-*contract inqquill-ui, and the styled, variant-driven library inqquill-design. - Interactivity: signals (
qquill-signal) and the client islands runtime, triggers, and per-page bundling inqquill-runtime. - Docs sites: the reusable
DocShell,CodeBlock,CodeTabs, andCalloutcomponents inqquill-docs.