One design principle
OlumJS draws a single line through its whole template syntax: is an attribute fundamentally code or a string? Native HTML already answers it — onclick="" is code, class="" is a string.
- Code-shaped attributes — when, each, key, on*, html — take a JavaScript expression directly inside the quotes.
- Everything else is a literal string, with {expr} for the dynamic bits.
That's the entire mental model. No naked braces, no colon-bindings, no directives to memorize. This guide walks every feature the same way: a sentence, then a snippet.
Component file structure
A component is one .html file with up to three parts: a <script> for logic, a scoped <style>, and the template (everything else). State, methods, and props are all in the same closure, so the template can reference them directly. Component tag names are PascalCase — that's how the compiler tells a component from a plain element.
<!-- Counter.html --> <script> const state = { count: 0 }; const inc = () => state.count++; </script> <style> .counter { font-weight: 700; } </style> <div class="counter"> Count: {state.count} <button onclick="inc()">+</button> </div>
State & reactivity
Declare reactive state as const state = { … }. Mutating or reassigning state re-renders the component. Only state is reactive — plain const/let variables are not tracked.
<script> const state = { count: 0, user: { name: "Ann" } }; const inc = () => state.count++; // mutate → re-render const rename = () => (state.user = { name: "Bo" }); </script> <p>{state.count} — {state.user.name}</p>
Text interpolation
Any {expr} in text is evaluated as JavaScript and HTML-escaped by default, so it's XSS-safe. null and undefined render as an empty string. There is no escape for a literal { — every {…} is treated as interpolation; use {String.fromCharCode(123)} if you need a literal brace.
<p>Hello {state.user.name}</p> <p>Total: {state.items.length} items</p> <p>{state.count > 0 ? 'positive' : 'zero'}</p>
Watchers
Declare const watcher = { … } with a function per state key. It fires whenever that key changes, receiving (oldValue, newValue) — handy for derived logging or side effects.
<script> const state = { count: 0, log: "—" }; const watcher = { count(old, next) { state.log = `count: ${old} → ${next}`; }, }; </script>
Conditionals: if / else-if / else
when is always a JS expression. The element is physically added to or removed from the DOM as the condition changes.
<if when="state.tab === 'a'"> <p>Tab A</p> </if> <else-if when="state.tab === 'b'"> <p>Tab B</p> </else-if> <else> <p>Fallback</p> </else>
Show / hide
Like <if>, but the element stays in the DOM and only its display is toggled. Reach for <show> when you want to preserve DOM state or avoid re-mounting cost; reach for <if> when the element should truly not exist.
<show when="state.visible"> <div class="panel">Stays in the DOM; only display toggles.</div> </show>
Loops: for
each is a JS expression with three forms — array (item of list), numeric range (i of N, where i runs 1 → N), and object keys (key in obj). When the loop body is a component, add key so instances are reused by identity across reorders instead of by position. On plain elements, key is a harmless no-op.
<!-- array --> <for each="fruit of state.fruits"> <li>{fruit.name}</li> </for> <!-- numeric range: i goes 1 → N --> <for each="i of 6"><span>Step {i}</span></for> <!-- object keys --> <for each="key in state.settings"> <div>{key}: {state.settings[key]}</div> </for> <!-- keyed (only matters for component loops) --> <for each="todo of state.todos" key="todo.id"> <TodoRow todo="{todo}" /> </for>
Events
Native on* attributes hold code, just like real HTML. Use a method call, or an inline arrow that gets extracted into a method automatically. Pass the DOM event with $event. There are no event modifiers — call e.preventDefault() in the handler instead.
<button onclick="inc()">+</button> <button onclick="inc(), log()">multi</button> <input oninput="(e) => state.text = e.target.value" /> <input oninput="setValue($event)" /> <form onsubmit="(e) => { e.preventDefault(); save() }"></form>
Two-way binding
There is no model attribute — bind manually with a value plus an oninput handler. Explicit, greppable, and it works for any form field.
<input type="text" value="{state.text}" oninput="(e) => state.text = e.target.value" /> <p>Echo: {state.text}</p>
Attributes: strings vs code
String attributes are the default — a literal string with {expr} inside for the dynamic parts, including inline style. Code attributes (when, each, key, on*, html) evaluate their quoted value as JavaScript.
<!-- string attributes: literal + {expr} --> <div class="card {state.active ? 'is-active' : ''}"></div> <a href="/users/{state.user.id}" title="Open {state.user.name}">Profile</a> <div style="color:{state.color}; background:{state.bg}; padding:8px;">box</div> <!-- code attributes: value IS an expression --> <if when="state.count > 0"> … </if> <button onclick="inc()">+</button>
Raw HTML (opt-in)
All interpolation is escaped by default. Opt out only for trusted, sanitized HTML, two ways: html="expr" replaces an element's content, and olum.html(value) injects raw HTML inline inside {}. Both are deliberate, greppable bypasses.
Never pass untrusted user content to html= or olum.html() — sanitize first.
<!-- element-level: replaces children with raw HTML --> <div html="state.articleHtml"></div> <!-- inline, mixed with text --> <p>Intro: {olum.html(state.snippetHtml)} — end.</p>
Components & props
Import a child in <script> and use it by its PascalCase tag. A prop is a literal string by default; prop="{expr}" passes the expression's real value and type; and {} inside text yields an interpolated string.
<!-- Parent.html --> <script> import StatusBadge from "./StatusBadge"; const state = { name: "Ann", score: 5 }; </script> <StatusBadge label="Online" <!-- "Online" → string --> count="{state.score}" <!-- 5 → number (type kept) --> data="{state.user}" <!-- {...} → object (type kept) --> greet="Hi {state.name}" <!-- "Hi Ann" → interpolated string --> />
Reading props in the child
Import props from olum and call it at the top level of <script> — no onMount required. Destructuring takes a one-time snapshot (the initial value); call props() again wherever you need the latest value after a parent re-render. Writing props().x = value (not a destructured local) propagates the change back up to the owner's state.
<!-- StatusBadge.html --> <script> import { props } from "olum"; const { label, color } = props(); // one-time snapshot (INITIAL) </script> <!-- call props() again for the LATEST value after a parent re-render --> <span class="badge" style="color:{props().color}">{props().label}</span>
Slots
Content placed between a component's tags is exposed as children on props(). Destructuring snapshots it once; render props().children directly in the template to always reflect the latest slot content.
<!-- parent --> <CounterCard title="Score"> <em>passed from the parent</em> </CounterCard> <!-- CounterCard.html --> <script> import { props } from "olum"; </script> <div class="slot">{props().children}</div>
Scope: public / private
A component's top-level props and methods are private by default — encapsulated. Add scope keywords to the <script> tag to expose them: public (both), public-props, public-methods, and their private-* counterparts. An exclude-<name> attribute carves a single exception out of the currently open group (camelCase names become kebab-case, e.g. getAge → exclude-get-age). Attributes read left to right, so each keyword opens a group that following exclude-* attach to.
<!-- No attributes → everything PRIVATE (the default) --> <script> … </script> <!-- Expose all props AND methods --> <script public> … </script> <!-- Expose props only; methods stay private --> <script public-props> … </script> <!-- Public props EXCEPT one; private methods EXCEPT one --> <script public-props exclude-state private-methods exclude-get-age> … </script>
Scoped CSS
A component's <style> is automatically scoped — selectors only affect that component's own elements, so nothing leaks. Just write normal CSS.
<style> .title { color: #4f46e5; } /* won't leak to other components */ </style> <h1 class="title">Hello</h1>
Lifecycle: onMount & host
Import onMount from olum and pass a callback that runs when the component mounts; return a function from it to run cleanup on unmount (e.g. when an <if> toggles it off or a keyed item is removed). Inside onMount, host refers to this component's own root element — query within it instead of document.querySelector, which could match another instance.
<script> import { onMount } from "olum"; onMount(() => { const main = host.querySelector("main"); // host = this component's root console.log("mounted"); return () => console.log("unmounted"); // cleanup on removal }); </script>
Escaping & security
OlumJS escapes by default to prevent XSS. Every text and attribute {expr} runs through olum.esc. The only way to render raw HTML is the deliberate opt-out — html="expr" or olum.html() — which you should reserve for trusted, pre-sanitized content.
Common mistakes
OlumJS has no naked braces and one way to do each thing. The patterns you might reach for from other frameworks don't work here:
- Values for when / each / on* live inside the quotes — write when="x", not when={x}
- Bind inputs with value="{state.x}" plus an oninput handler — there's no model
- No prop shorthand or naked-brace props — write a="{a}", not {a} or a={a}
- Inline styles are a string with {expr}: style="color:{x}" — not :style
- Components are PascalCase — <Comp/>, not <comp/>
- Any {…} in text is interpolation — for a literal brace use {String.fromCharCode(123)}
- No event modifiers — call e.preventDefault() in the handler
Limitations to know
OlumJS is deliberately small, so a few sharp edges come with it — each with a by-design workaround:
- Re-renders rebuild the whole stateful component (and its children), so untracked DOM state — a playing <video>, a running animation, a focused input — is lost. Keep that reactive state in a parent and let the media/input component stay stateless.
- There's no integrated unit-testing story yet; test plain JS logic with any external tool.
- The global store works but is awkward: reach a component through its location key, e.g. olum.app.store["page>App#0"]. For now, use one dedicated store component.
<!-- ✗ state inside the media component → re-render restarts the <video> --> <!-- ✓ lift state to a parent, keep the media component stateless --> <!-- Parent.html --> <script> const state = { count: 0 }; // re-renders the counter, not the video const inc = () => state.count++; </script> <MediaBox /> <!-- stateless → the <video> is safe --> <button onclick="inc()">{state.count}</button>
Cheat-sheet
The entire template syntax on one screen — bookmark this block.
<!-- TEXT (auto-escaped) --> {state.value} {a > b ? 'x' : 'y'} <!-- RAW / UNESCAPED HTML (opt out of escaping) --> <div html="state.articleHtml"></div> {olum.html(state.snippetHtml)} <!-- STRING ATTRIBUTES (literal + {expr}) --> <div class="box {state.cls}" style="color:{state.color}" title="Hi {state.name}"></div> <!-- EVENTS (code in "") --> <button onclick="save()">Save</button> <input oninput="(e)=> state.text = e.target.value" /> <form onsubmit="(e)=> { e.preventDefault(); submit() }"></form> <!-- CONDITIONALS --> <if when="state.tab === 'a'">…</if> <else-if when="state.tab === 'b'">…</else-if> <else>…</else> <!-- SHOW --> <show when="state.visible">…</show> <!-- LOOPS --> <for each="item of state.items" key="item.id"><Row item="{item}" /></for> <for each="i of 6">{i}</for> <for each="key in state.map">{key}</for> <!-- COMPONENTS + PROPS + SLOT --> <Card title="Hi" count="{n + 1}" data="{state.obj}"> <span>slot content → {children}</span> </Card> <!-- PROPS (in the child's <script>) --> const { title, children } = props(); // INITIAL snapshot, no onMount needed {props().title} // LATEST value, call anywhere {props().children} // LATEST slot content <!-- host: this component's root element, inside onMount --> onMount(() => { host.querySelector("main"); });
Where to go next
That's the whole framework, summarized. For the full detail on any topic, read the docs; to try it hands-on, open the Playground and edit a live example. When you're ready to build something, the todo-app pattern — state, a list, events, and a conditional empty state — is a great first project.
Eissa Saber
Creator of Olumjs