Core Concepts
Components & Templating
A .wizz file is one component: a template with exactly one root element, plus up to one each of <script>, <wizz:head>, and <wizz:style>. The compiler turns it into DOM-creation and update instructions — there is no template interpreter in the browser.
Anatomy of a component
<script>
// 1. Optional script: imports, props, state, handlers, hooks.
import Avatar from "./Avatar.wizz";
export let user; // prop (read-only here)
let expanded = false; // reactive state
function toggle() {
expanded = !expanded;
}
</script>
<wizz:head>
<title>{user.name} - Profile</title>
</wizz:head>
<article class="profile"> <!-- 2. Exactly one root element -->
<Avatar src={user.avatar} />
<h2>{user.name}</h2>
<button on:click={toggle} aria-expanded={expanded}>Details</button>
</article>
<wizz:style>
.profile { display: grid; gap: 1rem; }
h2 { margin: 0; } /* scoped: other h2s are untouched */
</wizz:style>- Template — exactly one native root element, per the language contract. Wrap siblings in a single element.
- <script> — top-level
letis reactive state,export letdeclares a prop, named functions are handlers, andonMount/onDestroyregister hooks with no import. - <wizz:head> — only
<title>,<meta>, and<link>. - <wizz:style> — raw CSS, scoped to this component. A plain
<style>element is rejected with a pointer to<wizz:style>.
Template expressions
Curly braces accept a deliberately small, statically analyzable grammar: identifiers, member access, string and number literals, and strict equality. Function calls and ternaries fail with WIZZ-P033. Treat anything outside that subset as unsupported even when it happens to parse — compute it in the script and reference the result by name.
<script>
let user = { name: "Ada", role: "admin" };
let items = ["a", "b", "c"];
// Derive anything non-trivial in the script, then reference it by name.
let itemCount = items.length;
let isAdmin = user.role === "admin";
let greeting = "Welcome back, " + user.name;
</script>
<section>
<h2>{greeting}</h2> <!-- identifier -->
<p>{user.name}</p> <!-- member access -->
<p>{"v1.0"} · {42}</p> <!-- string / number literals -->
{#if user.role === "admin"} <!-- strict equality -->
<p>{itemCount} items</p>
{/if}
</section>Text output escapes &, <, and >; attribute values additionally escape ". To show a literal angle bracket in prose, interpolate a string literal such as {"<script>"} — HTML entities are not decoded in templates.
Dynamic attributes
Bind an attribute by making its entire, unquoted value an expression: class={cardClass}. value, checked, and disabled update as DOM properties; every other name updates as an HTML attribute.
<script>
let name = "Ada";
let selected = true;
let busy = false;
let active = true;
// Compose multi-part values in the script:
let cardClass = active ? "card is-active" : "card";
let profileHref = "/users/" + name.toLowerCase();
</script>
<div class={cardClass}>
<a href={profileHref}>Profile</a>
<input value={name} checked={selected} disabled={busy} aria-label={name} />
</div>Props
A child declares inputs with export let. The parent passes them as attributes on the component tag: static attributes pass strings, bare attributes pass true, and {expressions} pass values that re-evaluate whenever their dependencies change.
<script>
export let name = "Guest"; // default when the parent omits it
export let count; // bare declaration defaults to undefined
</script>
<p>Hello {name}, clicked {count} times</p><script>
import Greeting from "./components/Greeting.wizz";
let clicks = 0;
function click() {
clicks += 1;
}
</script>
<main>
<Greeting name="Ada" count={clicks} emphasized />
<button on:click={click}>Click</button>
</main>- New values arrive through the child's
setProps()and it rerenders in place — DOM identity and internal state survive. - Props are read-only in the child: assigning one is a compile error (
WIZZ-G015). - An omitted prop keeps its default; passing
undefinedexplicitly restores it. Undeclared attributes are ignored.
Live: props in action
This sandbox is two real Wizz components. The parent owns the state; the child Meter only receives props. Every value the child shows is computed by the parent, so the child stays a pure view.
Live · PropsDemo.wizz → Meter.wizz
Parent → child prop updates: 0 · the child is never remounted.
<script>
import Meter from "./Meter.wizz";
let level = 40;
let barStyle = "transform: scaleX(0.4)";
function handleLevel(event) {
level = Number(event.target.value);
barStyle = "transform: scaleX(" + level / 100 + ")";
}
</script>
<div class="props-demo">
<input type="range" value={level} on:input={handleLevel} />
<Meter label="CPU budget" value={level} barStyle={barStyle} />
</div>Composition & imports
Import components with a default import of a relative .wizz path and render them as self-closing tags inside a native element. The build rewrites the import to the compiled .js path.
- No children or slots — pass content through props, or compose with native elements around the tag.
- No
on:directives on component tags — pass a callback prop instead. - No components inside {#each} — render native markup in loops.
- Destroying a parent destroys every imported child.
<script>
import SearchBar from "./SearchBar.wizz";
let query = "";
// Children talk to parents through callback props:
function handleSearch(next) {
query = next;
}
</script>
<div class="toolbar">
<SearchBar onSearch={handleSearch} placeholder="Filter…" />
</div>Scoped styles
Every element a component renders carries a generated data-wizz-s attribute, and each selector's terminal element gains it — so two components can both style h2 without colliding. @keyframes names are suffixed the same way. Global resets and tokens stay in App.css.
<main>
<Card />
<h2>Page heading</h2>
</main>
<wizz:style>
h2 { font-size: 24px; }
/* compiles to: h2[data-wizz-s="s1ab2c3"] { font-size: 24px; } */
@keyframes fade { from { opacity: 0; } }
/* the name gains the scope suffix: @keyframes fade-s1ab2c3 */
</wizz:style>Document head
<wizz:head> declares per-page metadata. Expressions evaluate at render time on the server and at mount on the client; the server delivers the head in the document and hydration adopts it. Head content follows navigation, not state — reactive head updates are out of scope.
<script>
export let post;
</script>
<wizz:head>
<title>{post.title} — My Blog</title>
<meta name="description" content={post.summary}>
<link rel="canonical" href={post.url}>
</wizz:head>
<article>
<h1>{post.title}</h1>
</article>When several mounted components declare a <title>, the last in tree order wins with a development warning; <meta> and <link> concatenate. Keep the document shell free of its own <title> so a page's title is the only one.
Lifecycle & DOM rendering
<script>
let width = 0;
let observer = null;
onMount(() => {
// Runs after the component is attached, its children are mounted,
// and the initial update has completed. Browser APIs are safe here.
const node = document.querySelector(".panel");
observer = new ResizeObserver(([entry]) => {
width = Math.round(entry.contentRect.width);
});
observer.observe(node);
});
onDestroy(() => {
// Runs before children are destroyed and the root is removed.
observer.disconnect();
});
</script>
<section class="panel">
<p>{width}px wide</p>
</section>onMountruns once after attach, child mounts, and the initial update. State assigned there flows through the batched scheduler.onDestroyruns whendestroy()is called — before children are destroyed and the root is removed.- Wizz removes every listener it attached through
on:. Listeners you add towindowordocumentare yours to remove. - Wizz owns the DOM it creates: do not reorder, remove, or replace its nodes while mounted.
Outside the router, every compiled module can be mounted by hand:
import mountComponent from "./dist/components/Counter.js";
const handle = mountComponent(document.querySelector("#slot"), { label: "Clicks" });
handle.setProps({ label: "Taps" }); // present when the component declares props
handle.destroy(); // call exactly onceRules the compiler enforces
| Rule | Instead | Code |
|---|---|---|
| A template needs a root element | Wrap content in one native element | WIZZ-G001 |
One <wizz:head> / <wizz:style> | Merge the blocks | WIZZ-P013 / P014 |
No plain <style> | Use <wizz:style> | WIZZ-P020 |
| No children on component tags | Pass props | WIZZ-G008 |
No on: on component tags | Pass a callback prop | WIZZ-G011 |
| Component tags inside a native element | Wrap the tag | WIZZ-G010 |
| Props are read-only | Copy into a local let | WIZZ-G015 |
No calls or ternaries in {...} | Compute in the script | WIZZ-P033 |
| {#each} inside a native element | Wrap the block | WIZZ-G002 |
| One root element per {#each} body | Wrap the row | WIZZ-G003 |
No components / on: inside {#each} | Native markup + delegation | WIZZ-G004 / G006 |
A few behaviors compile cleanly but are worth knowing:
- {#if} is chosen at mount. Toggle visibility of reactive UI with a bound class instead, and keep dynamic bindings out of branches that may not be taken.
- Avoid empty-string text interpolations in server-rendered components: an empty text node does not survive HTML parsing, so hydration falls back to a client render. Initialize display strings to something visible.
- Keep scripts LF-only. The hydration walk compares template whitespace exactly, and the browser normalizes CRLF.
- Use
constfor locals inside helper functions. The state scanner treatsletdeclarations as component state.