A signal, a class, and an HTML template. That's all you need, whether you're writing the code yourself or working with an AI agent.
Kasper exists because building reactive UIs shouldn't require understanding a compiler, a scheduler, a virtual DOM reconciler, and a hook dependency system.
No build step required. Drop a 16KB script tag and you have a complete reactive component framework — signals, router, slots, lazy loading, the works. The only thing a build pipeline adds is the .kasper single-file component format, which needs the Vite plugin to transform. Everything else runs directly in the browser.
Templates that read like HTML. Kasper directives are standard HTML attributes: @if, @each, @on:click. Write @if where you'd write if, @each where you'd write a loop. Any template is readable by anyone who knows HTML, with no framework knowledge required. The expression evaluator is a custom recursive-descent parser, not eval and not new Function, so it works under strict Content Security Policies too.
Components that clean up after themselves. A component is a class. this.watch(), this.effect(), and this.computed() are all released automatically when the component is destroyed, via a single AbortController the class owns. No dependency arrays. No return () => unsubscribe(). No stale closure warnings. The component lifecycle is the class lifecycle.
Under 4000 lines of TypeScript. 95% test coverage. Zero runtime dependencies. Built to stay that way.
Option 1: Script tag, no build step.
<!DOCTYPE html>
<html>
<body>
<counter></counter>
<script type="module">
import { App, Component, signal } from 'https://cdn.jsdelivr.net/npm/kasper-js/dist/kasper.min.js';
class Counter extends Component {
count = signal(0);
}
Counter.template = `
<div>
<p>Count: {{count.value}}</p>
<button @on:click="count.value++">+</button>
</div>
`;
App({ root: document.body, entry: 'counter', registry: { counter: { component: Counter } } });
</script>
</body>
</html>Option 2: Vite project with single-file components.
npm create kasper-app@latest my-project
cd my-project
npm install
npm run devWith the Vite plugin, using single-file components:
<!-- Counter.kasper -->
<template>
<div class="counter">
<p>Count: <strong>{{count.value}}</strong></p>
<p>Double: {{double.value}}</p>
<button @on:click="increment()">+</button>
<button @on:click="reset()">Reset</button>
</div>
</template>
<script>
import { Component, signal, computed } from 'kasper-js';
export class Counter extends Component {
count = signal(0);
double = computed(() => this.count.value * 2);
increment() { this.count.value++; }
reset() { this.count.value = 0; }
}
</script>
<style>
.counter { font-family: sans-serif; text-align: center; }
</style>// main.ts
import { App } from 'kasper-js';
import { Counter } from './Counter.kasper';
App({
root: document.body,
entry: 'counter',
registry: { counter: { component: Counter } },
});- No build step — runs from a CDN import or a local dist file. Signals, router, slots, and lazy loading all work without a bundler. The only thing a build adds is the
.kaspersingle-file component format. - HTML-first templates — directives are standard HTML attributes:
@if,@elseif,@else,@each,@let,@on:event,@attr,@class,@style,@ref. Structural directives use DOM boundaries — lightweight comment-node bookmarks that surgically replace only their own content when dependencies change. - Automatic cleanup — components are classes.
this.watch(),this.effect(), andthis.computed()are all released automatically when the component is destroyed via a sharedAbortController. No dependency arrays, no manual unsubscribe. - Fine-grained signals —
signal(),computed(),effect(),batch(),peek(). Reactivity is tracked at the binding level, not the component level. When a signal changes, Kasper updates only the specific DOM text nodes, attributes, and structural boundaries that depend on it. Siblings, parent elements, and unrelated components are untouched. - Event modifiers —
@on:submit.prevent,@on:click.stop,@on:click.once,@on:scroll.passive,@on:click.capture. All@on:listeners are registered via the component'sAbortControllerand removed automatically on destroy. - Client-side router — built-in
<router>,<route>,<guard>components. Supports static paths, dynamic:paramsegments, catch-all*routes, per-route guards, and<guard>groups for protecting multiple routes under a single async check. Routes are declared in the template, no configuration object needed. - Slots — default and named content transclusion via
<slot />and@slot. Named slots use@name/@slotfor attribute consistency across the framework. - Lifecycle hooks —
onMount,onRender,onChanges,onDestroywith clear execution ordering.onMountfires once after the first render with the DOM ready andargspopulated.onRenderfires after every render cycle.onChangesfires before each reactive re-render, not on first mount. - State management — global signals as plain ES modules. No store class, no reducers, no context API, no provider tree. Import a signal from any file; any component that reads it in its template will update automatically.
- Single-file components (optional) — with the Vite plugin, write
<template>,<script>, and<style>in one.kasperfile. Imports in the script block are hoisted into template scope via$importsautomatically. - Expression language — custom recursive-descent parser supporting arrow functions, ternary, optional chaining, nullish coalescing, pipeline operator (
|>), spread, array/object literals, and method calls. Noeval, nonew Function, compatible with strict Content Security Policies. - TypeScript — fully typed throughout. Declaration files generated separately.
.kasperfiles typed via ambient module declaration. - Zero dependencies — no runtime dependencies. Ships as a single ES module.
HTML string
→ TemplateParser (HTML → KNode AST)
→ Transpiler (KNode AST → live DOM)
→ ExpressionParser (directive values → expression AST)
→ Interpreter (expression AST → JS values, tracked via Scope)
→ Signal bindings (effect() per DOM node, surgical updates)
Scope chain — each component creates a Scope that resolves names first against the component instance, then against $imports (module-level imports collected by the Vite plugin), then against window. Nested directives (@each, @let) push child scopes onto this chain, giving iteration variables and local bindings proper lexical visibility.
DOM Boundaries — structural directives (@if, @each) do not wrap their content in extra elements. Instead, they insert paired comment nodes as bookmarks. When a condition or list changes, only the nodes between the boundary markers are destroyed and recreated. Adjacent nodes are never touched.
Component rendering — the transpiler resolves custom element tag names against the registry, instantiates the component class, injects a $render closure for manual re-render support, sets up the scope chain, and renders the component's template nodes into the element. Each component gets its own AbortController for event cleanup.
Router — <router> is a built-in component auto-registered at startup. <route> and <guard> are config nodes — the transpiler's extractRoutes() walks their KNode children to build a RouteConfig[] at parse time, never rendering the config nodes themselves. On navigation, the router evaluates any async guard, destroys the previous mounted component, and mounts the matched component into its container using mountComponent().
import { signal, computed, effect, watch, batch } from 'kasper-js';
const count = signal(0);
const double = computed(() => count.value * 2);
// effect runs immediately and re-runs whenever count changes
const stopEffect = effect(() => console.log(`double is ${double.value}`));
// watch runs only on change and provides old value
const stopWatch = watch(count, (newVal, oldVal) => {
console.log(`${oldVal} → ${newVal}`);
});
// batch defers effect flushes until the callback returns
batch(() => {
count.value++;
count.value++;
}); // effect fires once, not twice
stopEffect(); // unsubscribe
stopWatch(); // unsubscribe
// peek() reads the current value without registering a dependency
const current = count.peek();
// Signals use reference equality — only reassignment triggers reactivity
// Arrays: reassign, never mutate in-place
items.value = [...items.value, newItem]; // triggers ✓
items.value.push(newItem); // does NOT trigger ✗
// Objects: same rule — mutating a nested property does NOT trigger
user.value = { ...user.value, name: 'Alice' }; // triggers ✓
user.value.name = 'Alice'; // does NOT trigger ✗Signals declared as component fields are garbage-collected when the component is destroyed. Effects created with effect() return a stop function — call it in onDestroy if you create one outside the framework's tracking. Template bindings are cleaned up automatically.
<!-- Only the text node updates when its signal dependencies change -->
<p>Hello, {{user.name ?? 'Anonymous'}}!</p>
<p>Total: {{price.value * qty.value}}</p><div @if="session.isAdmin.value">Admin panel</div>
<div @elseif="session.isLoggedIn.value">Dashboard</div>
<div @else>Please log in</div>Boundaries — not wrapper elements — delimit conditional DOM. When the signal changes, only the content between boundary markers is replaced.
<!-- Basic iteration -->
<ul>
<li @each="item of items.value">{{item.name}}</li>
</ul>
<!-- With index -->
<ul>
<li @each="item with i of items.value">{{i + 1}}. {{item.name}}</li>
</ul><button @on:click="increment()">+</button>
<input @on:input="search($event)" />
<form @on:submit.prevent="submit()">...</form>
<button @on:click.stop.once="handleOnce()">...</button>
<input @on:keydown.enter="addTodo()" />
<button @on:click.ctrl="specialAction()">...</button>Available modifiers:
- Event:
.prevent,.stop,.once,.passive,.capture - Keyboard:
.enter,.escape,.tab,.space,.up,.down,.left,.right,.delete,.backspace, etc. - System:
.ctrl,.shift,.alt,.meta
<!-- Object merge — class and style are deeply merged with static values -->
<div class="btn" @attr="{ class: isPrimary ? 'btn-primary' : 'btn-secondary', disabled: isLoading }"></div>
<!-- Shorthand -->
<div @class="isActive ? 'active' : ''"></div>
<div @style="{ color: textColor, opacity: isVisible ? 1 : 0 }"></div><!-- @let scopes the variable to the element and its children -->
<div @let="discount = price.value * 0.1">
<p>You save: {{discount}}</p>
<p>Total: {{price.value - discount}}</p>
</div>
<!-- @ref captures the element into a component property -->
<input @ref="inputEl" type="text" /><!-- Custom functional pipelines without intermediate variables -->
<p>{{items.value |> filter(x => x.active) |> length}}</p><!-- UserCard.kasper -->
<template>
<div class="user-card">
<img :src="avatar" @attr="{ alt: name }" />
<h2>{{name}}</h2>
<p @if="bio">{{bio}}</p>
<p @else class="muted">No bio provided.</p>
</div>
</template>
<script>
import { Component } from 'kasper-js';
export class UserCard extends Component {
get name() { return this.args.name ?? 'Unknown'; }
get avatar() { return this.args.avatar ?? '/default.png'; }
get bio() { return this.args.bio ?? null; }
}
</script><!-- @: passes any expression — signal, object, function, or literal -->
<user-card
@:name="currentUser.value.name"
@:avatar="currentUser.value.avatarUrl"
@:bio="currentUser.value.bio"
></user-card>Args are evaluated as full expressions and passed by reference. Signals passed as args remain reactive inside the child.
Note:
@:onClick="add(item)"evaluatesadd(item)immediately during render and passes its return value as the prop. This is intentional — it supports patterns like factory functions that return a handler. Ifadd(item)returns a function, that function becomes the click handler. If it returnsundefined(a void function), the prop receivesundefinedand the click does nothing. When the called function has reactive side effects that write to a signal it also reads, this creates an infinite reactive loop. In development mode, Kasper warns when a call expression is detected in anon*prop binding as a reminder.<!-- add(item) is called during render — if it returns a function, that's the handler --> <my-btn @:onClick="add(item)"></my-btn> <!-- pass a method reference directly (no args) --> <my-btn @:onClick="add"></my-btn> <!-- for parameterized handlers on native elements, use @on:click --> <button @on:click="add(item)">Add</button>
App({
root: document.body,
entry: 'app',
registry: {
'user-card': { component: UserCard },
'product-list': { component: ProductList },
'nav-bar': { component: NavBar },
},
});export class DataTable extends Component {
rows = signal<Row[]>([]);
loading = signal(true);
error = signal<string | null>(null);
async onMount() {
// Fires once after first render. DOM is ready, args are populated.
// Use for data fetching, timers, third-party lib init, focus management.
try {
this.rows.value = await fetchRows(this.args.endpoint);
} catch (e) {
this.error.value = String(e);
} finally {
this.loading.value = false;
}
}
onRender() {
// Fires after every render cycle — after onMount on first render, then after each reactive update.
// Use for things that must run on each update: scroll sync, external state.
// Do not use for one-time setup — that belongs in onMount.
console.log('table rendered, rows:', this.rows.value.length);
}
onChanges() {
// Fires before each reactive re-render (not on first mount).
// Use for logging, derived non-signal state, or gating updates.
console.log('table data changed, rows:', this.rows.value.length);
}
onDestroy() {
// Fires when removed from DOM.
// @on: listeners are cleaned up automatically via AbortController.
// this.watch() and this.effect subscriptions are released automatically.
// Manual timers, WebSocket connections, etc. go here.
}
}First mount:
new ComponentClass() ← constructor runs here if needed
└─ template rendered into DOM
└─ onMount() ← DOM ready, args populated, runs once
└─ onRender() ← also fires here, and on every subsequent update
Reactive update:
signal.value changes
└─ onChanges()
└─ affected bindings updated surgically
└─ onRender()
Destroy:
parent @if / @each removes the component
└─ onDestroy()
└─ @on: listeners aborted (AbortController)
└─ this.effect() / this.watch() / this.computed() stopped
└─ reactive effects stopped
For cases where imperative re-rendering is preferable to signal-driven updates (e.g., integrating with non-reactive data or third-party libraries):
export class Clock extends Component {
time = new Date().toLocaleTimeString();
onMount() {
setInterval(() => {
this.time = new Date().toLocaleTimeString();
this.render(); // tears down and rebuilds the component's template
}, 1000);
}
}render() is a full teardown and rebuild — prefer signals for normal reactivity.
<!-- App.kasper -->
<template>
<nav-bar></nav-bar>
<router>
<route @path="/" @component="HomePage" />
<route @path="/about" @component="AboutPage" />
<route @path="/users/:id" @component="UserPage" />
<route @path="/login" @component="LoginPage" />
<guard @check="checkAuth">
<route @path="/dashboard" @component="DashboardPage" />
<route @path="/settings" @component="SettingsPage" />
<guard @check="checkAdmin">
<route @path="/admin" @component="AdminPage" />
</guard>
</guard>
<route @path="*" @component="NotFound" />
</router>
</template>
<script>
import { Component } from 'kasper-js';
import { HomePage, AboutPage, UserPage, LoginPage,
DashboardPage, SettingsPage, AdminPage, NotFound } from './pages';
import { checkAuth, checkAdmin } from './guards';
export class App extends Component {}
</script>import { navigate } from 'kasper-js';
navigate('/dashboard');
navigate('/users/42');navigate uses the History API (pushState) — URLs are clean, no hash routing.
Dynamic segments are passed to the component via this.args.params:
export class UserPage extends Component {
user = signal<User | null>(null);
async onMount() {
const { id } = this.args.params;
this.user.value = await fetchUser(id);
}
}<template>
<div @if="user.value">
<h1>{{user.value.name}}</h1>
</div>
</template>Guards are async functions that return Promise<boolean>. A false return blocks the route. The guard is responsible for redirecting:
import { navigate } from 'kasper-js';
export async function checkAuth(): Promise<boolean> {
const user = await fetchCurrentUser();
if (!user) {
navigate('/login');
return false;
}
return true;
}
export async function checkAdmin(): Promise<boolean> {
const user = await fetchCurrentUser();
if (!user?.isAdmin) {
navigate('/dashboard');
return false;
}
return true;
}<guard @check="checkAdmin"> applies the guard to all child routes. Guards can be nested for layered permission levels.
Signals are plain objects — define them at module scope and import them anywhere. No store class, no reducers, no dispatch.
// src/store/cart.ts
import { signal, computed } from 'kasper-js';
export const cartItems = signal<CartItem[]>([]);
export const cartCount = computed(() => cartItems.value.length);
export const cartTotal = computed(() => cartItems.value.reduce((sum, i) => sum + i.price, 0));
export function addItem(item: CartItem) {
cartItems.value = [...cartItems.value, item];
}
export function removeItem(id: string) {
cartItems.value = cartItems.value.filter(i => i.id !== id);
}Any component that reads a global signal in its template updates automatically — no prop drilling, no context API:
// CartBadge.kasper
import { Component } from 'kasper-js';
import { cartCount } from '../store/cart';
export class CartBadge extends Component {
count = cartCount; // template reads count.value directly
}// ProductCard.kasper
import { Component } from 'kasper-js';
import { addItem } from '../store/cart';
export class ProductCard extends Component {
add() {
addItem({ id: this.args.id, name: this.args.name, price: this.args.price });
}
}Use this.watch() to subscribe to an external signal inside a component. The subscription is released automatically when the component is destroyed — no onDestroy cleanup needed:
export class Navbar extends Component {
onMount() {
this.watch(currentUser, (user) => {
console.log('session changed', user);
});
}
}Calling onChange directly on a global signal inside a component works but leaks — the subscription outlives the component. this.watch() is always preferred inside components.
<!-- Modal.kasper -->
<template>
<div class="modal" role="dialog">
<header class="modal-header">
<slot @name="title" />
</header>
<main class="modal-body">
<slot />
</main>
<footer class="modal-footer">
<slot @name="actions" />
</footer>
</div>
</template><modal>
<h2 @slot="title">Confirm deletion</h2>
<p>This action cannot be undone. All associated data will be permanently removed.</p>
<div @slot="actions">
<button @on:click="cancel()" class="btn-secondary">Cancel</button>
<button @on:click="confirm()" class="btn-danger">Delete</button>
</div>
</modal>Content without a @slot attribute goes to the default slot. Named slots use @name (in the child template) and @slot (on the slotted element) — consistent with the @ prefix convention used across all framework attributes.
npm install kasper-js
npm install -D vite-plugin-kasper// vite.config.ts
import { defineConfig } from 'vite';
import kasper from 'vite-plugin-kasper';
export default defineConfig({
plugins: [kasper()],
});The plugin transforms a .kasper file into a standard TypeScript module:
- Extracts the
<template>block and assigns it asClassName.template = "..." - Collects all imported names from the
<script>block (import { X } from '...', default and namespace imports included) and assigns them asClassName.$imports = { X, Y, Z } - Passes the
<script>block through esbuild as TypeScript - Injects
<style>as a self-executing style tag into<head>
At runtime, the scope resolver checks $imports as a fallback when a name is not found on the component instance — making imported helper functions, utility classes, and constants available directly in template expressions without re-declaring them as class fields.
// src/kasper.d.ts
declare module '*.kasper' {
import type { Component } from 'kasper-js';
type AnyComponent = new (...args: any[]) => Component;
const exports: Record<string, AnyComponent>;
export = exports;
}<template>
<p>{{formatDate(createdAt.value)}}</p>
<user-card @:user="currentUser.value" />
</template>
<script>
import { Component, signal } from 'kasper-js';
import { UserCard } from './UserCard.kasper';
import { formatDate } from '../utils/date';
import { currentUser } from '../store/user';
export class ProfilePage extends Component {
createdAt = signal(new Date());
}
</script>formatDate, UserCard, and currentUser are module-level imports — the plugin makes them available in the template automatically via $imports.
Kasper has 600+ tests across 17 spec files, all passing. Tests run directly against src/ — no build step required. Coverage is measured with V8's native instrumentor via Vitest.
Coverage:
| Metric | Coverage |
|---|---|
| Statements | 95.33% |
| Branches | 88.41% |
| Functions | 97.46% |
| Lines | 96.18% |
All core modules — boundary.ts, scope.ts, utils.ts, viewer.ts — are at 100% statement and line coverage. The branch gap in transpiler.ts covers defensive guards for malformed templates that are intentionally unreachable in the normal execution path.
pnpm test # single run
pnpm test:watch # watch mode
pnpm test --coverage # with V8 coverage reportnpm install kasper-js
npm install -D vite-plugin-kasperimport { App } from 'kasper-js';
import { MyApp } from './App.kasper';
App({
root: document.body,
entry: 'my-app',
registry: {
'my-app': { component: MyApp },
},
mode: import.meta.env.MODE as any,
});MIT © eugenioenko