Lightweight reactive UI framework for the browser.
No virtual DOM. ~22 KB gzip with router, store, devtools, composables, useHead, and SSR primitives. Vue/Alpine ergonomics.
Author: Vanjex — Version: 0.7.1
| Courvux | Alpine | Petite-Vue | Preact | |
|---|---|---|---|---|
| Size (gzip) | ~20 KB (full) | ~15 KB | ~6 KB | ~5 KB (core only) |
| Reactivity | Proxy | Proxy | Proxy | Signals |
| Virtual DOM | ❌ | ❌ | ❌ | ✅ |
| Components | ✅ | Limited | ✅ | ✅ |
| Router | Built-in | ❌ | ❌ | External |
| Store | Built-in | ❌ | ❌ | External |
| DevTools | Built-in overlay | ❌ | ❌ | External |
| SSR | ✅ (basic) | ❌ | ❌ | ✅ |
| Composables | Built-in | External | External | External |
| Vite plugin | ✅ | — | — | ✅ |
1. Everything you need in one bundle, none of the wiring.
Router, store, devtools, useHead, composables, SSR primitives — all in 20 KB gzip. With Alpine you'd add 4–5 third-party libraries; with Vue 3 + vue-router + pinia + devtools-package you're around 60–80 KB. Courvux ships the same surface area at a third of the cost, fully integrated (the store knows about the router, devtools knows about both).
2. No build step required to evaluate it.
Courvux is a single ES module. Drop a <script type="importmap"> and a <script type="module"> into any HTML file and you're done — no Webpack, no Vite, no npm install. When you outgrow the importmap pattern, the same code works under Vite without touching imports. See Installation.
3. Built-in DevTools, no browser extension. A draggable overlay with live state inspection and inline editing ships with the framework. Your reviewer / colleague / future-self gets the debugger without installing anything per-browser. See DevTools.
4. SEO-correct by default.
useHead + courvux/plugin/ssg pre-render every route to its own <path>/index.html at build time. Crawlers and Open Graph previewers see real per-route HTML, not an empty SPA shell. The docs site you're reading uses this — every page has its own title, meta, canonical, and JSON-LD inlined statically. See Static Site Generation.
The fastest way to feel the framework is to open the live demo and the importmap example side-by-side:
- Live TodoMVC demo: vanjexdev.github.io/courvux/demo —
cv-for,cv-model, computed, deepwatch, dynamic:cv-ref, all in one file. - No-build example:
examples/01-todomvc/— clone the repo andnpx serve .from the root, then open/examples/01-todomvc/. The example uses an importmap pointing at the localdist/, no bundler required. - SSG example with router +
useHead:examples/03-ssg-blog/—pnpm install && pnpm build && pnpm previewshows what production-deployed HTML looks like.
Courvux targets the last two major versions of each modern browser. The framework is tested against the table below; older versions may work but are not validated.
| Browser | Minimum version | Status |
|---|---|---|
| Chrome | 90+ | ✅ |
| Edge | 90+ | ✅ |
| Firefox | 88+ | ✅ |
| Safari (macOS / iOS) | 15+ | ✅ (verified since 0.4.4 on iOS Safari) |
| Samsung Internet | 18+ | ✅ (verified since 0.4.4) |
| iOS WebView | iOS 15+ | ✅ |
| Android WebView | Chrome 90+ | ✅ |
What "supported" means: every release runs unit tests + SSR / SSG self-tests + a Playwright E2E suite on Chromium and Firefox. WebKit-class browsers (Safari, Samsung Internet, iOS WebView) are validated on real devices for each release plus by the WebKit project in Playwright (CI integration is roadmap Fase 5.2).
If you hit a bug on a supported browser, open an issue — these are first-priority fixes, same class as the 0.4.4 / 0.4.5 / 0.4.7 patches.
- Why Courvux?
- Try it in 30 seconds
- Browser support
- Installation
- Quick Start
- createApp
- Template Syntax
- Components
- autoInit()
- Computed Properties
- Watchers
- Lifecycle Hooks
- Instance Properties
- Custom Directives
- Transitions — cv-transition
- cv-intersect — Intersection Observer
- Router
- Store
- Provide / Inject
- Batch Updates — $batch
- Error Boundaries — onError
- Plugin System
- Composables
- SEO and
useHead - Static Site Generation (SSG)
- Event Bus
- Reactivity escape hatches
- DevTools
- Server-Side Rendering (SSR)
- Testing
- Progressive Web App (PWA)
- Building
- Development
- Production readiness
- Who's using Courvux
- Known Limitations
- Top-level exports
pnpm add github:vanjexdev/courvux
# or
npm install github:vanjexdev/courvuxPin a tag for stable installs:
pnpm add github:vanjexdev/courvux#v0.7.1Without a bundler — use an import map:
<script type="importmap">
{
"imports": {
"courvux": "./node_modules/courvux/dist/index.js"
}
}
</script>
<script type="module" src="./main.js"></script>With Vite / a bundler — import directly:
import { createApp } from 'courvux';The repo ships a Vite plugin that inlines templateUrl references at build time, eliminating runtime fetch calls for templates and enabling HMR on .html files in dev.
// vite.config.js
import { defineConfig } from 'vite';
import courvux from 'courvux/plugin';
export default defineConfig({
plugins: [courvux()]
});This makes the templateUrl pattern viable for production, eliminating the relative-path resolution issue noted in Known Limitations.
pnpm remove courvux && pnpm add github:vanjexdev/courvux
dist/is committed to the repo. Courvux does not run a build step on install.
import { createApp } from 'courvux';
createApp({
template: `
<h1>Hello, {{ name }}!</h1>
<button @click="greet">Click me</button>
`,
data: { name: 'World' },
methods: {
greet() { this.name = 'Courvux'; }
}
}).mount('#app');import { createApp } from 'courvux';
const app = createApp(config);
app.use(plugin).directive('name', def).mount('#app');| Option | Type | Description |
|---|---|---|
template |
string |
Inline HTML template |
templateUrl |
string |
Path to an external .html file (fetched at mount time) |
data |
object |
Reactive state — keys become this.key inside methods/hooks |
methods |
object |
Functions bound to the component state via this |
computed |
object |
Derived state — see Computed Properties |
watch |
object |
Change callbacks — see Watchers |
components |
object |
Globally registered child components |
directives |
object |
Globally registered custom directives |
router |
Router |
Router instance from createRouter |
store |
object |
Global store from createStore |
provide |
object | () => object |
Values provided to all descendants via inject |
inject |
string[] | object |
Keys to receive from an ancestor's provide |
inheritAttrs |
boolean |
Default true. Set false to suppress automatic attribute inheritance |
onBeforeMount |
function |
Called before the DOM walk begins |
onMount |
function |
Called after mounting is complete |
onBeforeUnmount |
function |
Called before the component is destroyed |
onDestroy |
function |
Called after the component is destroyed |
onError |
(err: Error) => void |
Catches errors from descendant components |
On the root
createAppconfig,onMountfires after the first route is fully rendered — safe for third-party DOM libraries like Lucide Icons.
| Method | Description |
|---|---|
.use(plugin) |
Install a plugin. Chainable. |
.directive(name, def) |
Register a global custom directive. Chainable. |
.component(name, config) |
Register a global component. Chainable. |
.provide(key, value) |
Provide a value to all descendants via inject. Chainable. Also accepts an object: .provide({ key: val }). |
.magic(name, fn) |
Register a global $name property. fn receives the component instance and its return value is assigned as this.$name in every component. Chainable. |
.mount(selector) |
Mount on a CSS selector. Returns Promise<CourvuxApp>. |
.mountAll(selector?) |
Mount on all matching elements (default [data-courvux]). Returns Promise<CourvuxApp>. |
.mountEl(el) |
Mount on a specific HTMLElement. Returns Promise<state>. |
.unmount(selector?) |
Destroy the mounted instance at selector, or all instances if omitted. |
.destroy() |
Destroy all instances created by this app. |
.router |
The router instance (useful inside plugins). |
app.magic() example:
createApp(config)
.magic('fmt', () => ({
currency: (val) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(val),
date: (val) => new Date(val).toLocaleDateString(),
upper: (str) => String(str).toUpperCase(),
}))
.magic('http', () => axios)
.mount('#app');<!-- available in every component template -->
<p>{{ $fmt.currency(price) }}</p>
<p>{{ $fmt.date(createdAt) }}</p>
<button @click="$http.post('/api/save', data)">Save</button><p>{{ count }}</p>
<p>{{ price * qty }}</p>
<p>{{ active ? 'On' : 'Off' }}</p>
<p>{{ $store.user }}</p>Full JavaScript expressions are supported (requires no strict CSP — see Known Limitations).
When you write a component template inside a JS template literal, the $ character is consumed by JS before Courvux ever sees it:
// ❌ JS parses ${{ as a template expression — ReferenceError at runtime
template: `<button>Price: ${{ price }}</button>`
// ✅ Option 1 — use a regular string (no JS interpolation)
template: '<button>Price: ${{ price }}</button>'
// ✅ Option 2 — use the html tagged template helper
import { html } from 'courvux';
template: html`<button>Price: \${{ price }}</button>`
// ^ backslash escapes $ from JS, html tag restores itThe html tag reads the raw string (before JS escape processing), replaces \$ with $, and returns the final string. It does not do any HTML escaping.
<button cv:on:click="increment">+1</button>
<input cv:on:input="handleInput" />
<form cv:on:submit="onSubmit"></form>The cv:on: prefix is the native Courvux syntax. The shorthand @event is also accepted and behaves identically — both can be used interchangeably.
Inline expressions — no method needed:
<button cv:on:click="count++">+</button>
<button cv:on:click="count = 0">Reset</button>
<button cv:on:click="items.push('new')">Add</button>$event — access the raw DOM event:
<input cv:on:input="search = $event.target.value" />
<button cv:on:click="doThing($event)">Click</button>Custom parameters:
<button cv:on:click="deleteItem(item.id)">Delete</button>
<button cv:on:click="doThing(item, $event)">Action</button>Event modifiers — chain with .:
<form cv:on:submit.prevent="onSubmit">...</form>
<button cv:on:click.stop="doThing">...</button>
<button cv:on:click.once="runOnce">...</button>
<div cv:on:click.self="onSelf">...</div>Listener options (passive / capture):
<div cv:on:scroll.passive="onScroll">...</div>
<div cv:on:click.capture="onCapture">...</div>Key modifiers:
<input cv:on:keydown.enter="submit" />
<input cv:on:keydown.esc="cancel" />
<input cv:on:keydown.tab="nextField" />Available key modifiers: enter, esc / escape, space, tab, delete, backspace, up, down, left, right.
<input :disabled="count > 10" />
<img :src="avatarUrl" :alt="user.name" />
<my-card :title="$store.user" :max="100"></my-card>Supports string, object, and array syntax. Merged with any static class attribute.
<!-- Object — keys applied when value is truthy -->
<div :class="{ active: isActive, 'text-danger': hasError }"></div>
<!-- Array — each item can be a string, object, or nested array -->
<div :class="['base', isActive ? 'active' : 'inactive']"></div>
<!-- Combined with static class -->
<div class="card" :class="{ highlighted: isPinned }"></div>Supports object and string syntax. Merged with any static style attribute.
<!-- Object — camelCase CSS properties -->
<span :style="{ color: textColor, fontSize: size + 'px' }"></span>
<!-- String — raw CSS (replaces inline style) -->
<span :style="'color: red; font-weight: bold'"></span><!-- Text input -->
<input type="text" cv-model="name" />
<!-- Checkbox → boolean -->
<input type="checkbox" cv-model="active" />
<!-- Checkbox → array (multiple values) -->
<input type="checkbox" cv-model="skills" value="HTML" />
<input type="checkbox" cv-model="skills" value="CSS" />
<!-- Radio -->
<input type="radio" cv-model="color" value="red" />
<input type="radio" cv-model="color" value="blue" />
<!-- Select -->
<select cv-model="country">
<option value="es">Spain</option>
<option value="mx">Mexico</option>
</select>Modifiers:
<!-- .lazy — update on blur/change instead of every keystroke -->
<input cv-model.lazy="query" />
<!-- .trim — strip leading/trailing whitespace -->
<input cv-model.trim="username" />
<!-- .number — coerce to number -->
<input type="number" cv-model.number="price" />
<!-- .debounce — update 300ms after last keystroke (default 300ms) -->
<input cv-model.debounce="search" />
<!-- Custom delay in ms -->
<input cv-model.debounce.500="search" />
<!-- Combine modifiers -->
<input cv-model.debounce.trim="query" />Store binding — cv-model works directly on $store keys:
<input cv-model="$store.user" /><!-- Array -->
<li cv-for="item in items">{{ item }}</li>
<li cv-for="(item, index) in items">{{ index }}: {{ item }}</li>
<!-- Object -->
<li cv-for="(value, key) in person">{{ key }}: {{ value }}</li>Keyed reconciliation — add :key for stable identity. When the list changes, Courvux reuses existing DOM nodes for matching keys, only creating/destroying nodes for new/removed keys, and moving nodes for reorders.
<li cv-for="user in users" :key="user.id">{{ user.name }}</li>Without :key, all nodes are destroyed and recreated on every change. With :key, only the diff is applied. Duplicate keys log a console warning.
List transitions with :key — combine with cv-transition on the cv-for element:
<ul>
<li cv-for="item in items" :key="item.id" cv-transition="fade">
{{ item.name }}
</li>
</ul>Entering nodes get {name}-enter, leaving nodes get {name}-leave.
Elements are inserted/removed from the DOM.
<p cv-if="count > 10">High</p>
<p cv-else-if="count > 0">Low</p>
<p cv-else>Zero</p>Toggles display: none — element stays in the DOM.
<div cv-show="isVisible">Panel</div>Sets innerHTML reactively. Sanitized by default since 0.6.0 — strips <script>, on*= handlers, and javascript: URLs so user-submitted content is safe to render. Add .raw to opt out when the markup is something you authored (Markdown rendered server-side, hand-curated copy).
<!-- Sanitized (default) — safe for user-submitted content -->
<div cv-html="userContent"></div>
<!-- Raw — only for content you authored and trust -->
<div cv-html.raw="myTrustedContent"></div>The sanitizer uses the native Sanitizer API when available, and falls back to a DOMParser-based approach that removes <script>, <iframe>, onerror/onclick inline handlers, and javascript: URLs.
Migrating from <0.6.0? The pre-0.6
cv-html.sanitizemodifier still works (it's now a no-op since sanitization is the default). To restore the old raw behavior on a binding you control, switchcv-html→cv-html.raw.
Renders once with the initial value and never updates. Useful for static content that reads from state.
<strong cv-once>{{ initialValue }}</strong>Stores a reference in this.$refs. On a native element, stores the HTMLElement. On a custom component, stores the child's reactive state (see $refs on components).
<input cv-ref="myInput" placeholder="..." />methods: {
focus() {
this.$nextTick(() => this.$refs.myInput.focus());
}
}Dynamic refs in lists — prefix with : to compute the ref name from an expression:
<input cv-for="todo in todos" :key="todo.id"
:cv-ref="'edit_' + todo.id" />Access with bracket notation:
this.$refs['edit_' + someId]?.focus();Moves the element to a different DOM node while keeping its reactivity.
<div cv-show="showModal"
cv-teleport="body"
style="position:fixed;top:0;left:0;...">
{{ modalMessage }}
</div>The element is physically appended to body (or any CSS selector) but reacts to the local component state.
Hides content until mounting completes. Prevents a flash of un-rendered template text.
createApp() automatically injects [cv-cloak]{display:none!important} — no manual CSS needed.
<div id="app" cv-cloak></div>
<!-- or on individual components -->
<my-card cv-cloak></my-card>The attribute is removed from each element as the framework processes it during the DOM walk.
Create a self-contained reactive scope directly on any element — no component registration needed.
<!-- Inline data object -->
<div cv-data="{ count: 0, step: 1 }">
<button @click="count -= step">−</button>
<span>{{ count }}</span>
<button @click="count += step">+</button>
</div>Methods can be included in the same object using shorthand syntax:
<div cv-data="{ open: false, toggle() { this.open = !this.open } }">
<button @click="toggle()">{{ open ? 'Close' : 'Open' }}</button>
<div cv-show="open">Panel content</div>
</div>Nested scopes — a child cv-data inherits all keys from the parent scope. Child keys shadow parent keys of the same name, but do not mutate the parent.
<div cv-data="{ user: 'Alice', tags: ['admin', 'dev'] }">
<p>Outer: {{ user }}</p>
<div cv-data="{ role: 'Editor', idx: 0 }">
<!-- reads parent's user + tags -->
<p>Inner: {{ role }} — user from parent: {{ user }}</p>
<p>Tag: {{ tags[idx] }}</p>
<button @click="idx = (idx + 1) % tags.length">Next</button>
</div>
</div>Named component reference — use a registered component name as the value to reuse a component's data and methods without mounting a full component:
<div cv-data="my-counter">
<button @click="dec()">−</button>
<span>{{ n }}</span>
<button @click="inc()">+</button>
</div>// Registered globally
app.component('my-counter', {
data: { n: 0 },
methods: {
inc() { this.n++; },
dec() { this.n--; }
}
});
cv-datascopes are lighter than components — no lifecycle hooks, no slots, no emits. Use components when you need those features.
For TypeScript projects, defineComponent provides type inference for component config without runtime overhead — it's an identity function that helps the type checker understand the this binding inside methods/hooks.
import { defineComponent } from 'courvux';
export const UserCard = defineComponent({
data: { name: '', age: 0 },
computed: {
label() { return `${this.name} (${this.age})`; }
},
template: `<p>{{ label }}</p>`
});import { defineAsyncComponent } from 'courvux';
const HeavyChart = defineAsyncComponent({
loader: () => import('./HeavyChart.js'),
loadingTemplate: '<div class="skeleton">Loading chart...</div>',
errorTemplate: '<p class="error">Failed to load chart</p>',
delay: 200,
timeout: 5000,
});Components are registered in components on either the root app config or any ComponentConfig. Child components are available within that component's template and all its descendants.
createApp({
components: {
'user-card': {
templateUrl: './user-card.html',
data: { name: '', role: '' }
},
'alert-box': {
template: `<div class="alert">{{ message }}</div>`,
data: { message: '' }
}
},
template: `<user-card :name="$store.user" :role="'admin'"></user-card>`
}).mount('#app');Route-level components can also be registered inside a route's component.components:
{
path: '/dashboard',
component: {
templateUrl: './dashboard.html',
components: {
'stat-card': { template: `...`, data: { value: 0 } }
}
}
}Pass reactive data from parent to child with :propName. The child declares them in data with initial/default values.
<!-- parent template -->
<user-card :name="currentUser" :role="'editor'"></user-card>// user-card component
{
data: { name: '', role: '' },
template: `<h3>{{ name }}</h3><span>{{ role }}</span>`
}Props are reactive — parent changes flow down automatically.
Child notifies parent without tightly coupling them.
// child component
methods: {
close() { this.$emit('close'); },
submit(data) { this.$emit('submit', data); }
}<!-- parent template -->
<modal-dialog @close="onClose" @submit="onSubmit"></modal-dialog>cv-model="x" on a component is sugar for :modelValue="x" @update:modelValue="x = $event". The child calls this.$emit('update:modelValue', newValue).
// child: mi-input
{
data: { modelValue: '' },
template: `<input :value="modelValue" @input="onInput" />`,
methods: {
onInput(e) { this.$emit('update:modelValue', e.target.value); }
}
}<!-- parent -->
<mi-input cv-model="search"></mi-input>Use cv-model:propName to bind multiple props simultaneously. The child emits update:propName.
<!-- parent -->
<dual-editor cv-model:title="docTitle" cv-model:body="docBody"></dual-editor>
<p>Title: {{ docTitle }} | Body: {{ docBody }}</p>// dual-editor component
{
data: { title: '', body: '' },
template: `
<input :value="title" @input="$emit('update:title', $event.target.value)" />
<textarea :value="body" @input="$emit('update:body', $event.target.value)"></textarea>
`
}<!-- parent -->
<my-panel><p>Content from parent</p></my-panel><!-- my-panel template -->
<div class="panel">
<slot></slot>
</div><!-- parent -->
<my-card>
<span slot="header">Title</span>
<p>Body content</p>
<em slot="footer">Footer note</em>
</my-card><!-- my-card template -->
<div class="card">
<header><slot name="header"><em>Default header</em></slot></header>
<main><slot></slot></main>
<footer><slot name="footer"></slot></footer>
</div>Use $slots.slotName to check whether the parent provided content for a slot.
<!-- my-card template -->
<div class="card">
<header cv-if="$slots.header">
<slot name="header"></slot>
</header>
<slot></slot>
</div>The component exposes data up to the parent via :binding on <slot>. The parent accesses it via v-slot.
<!-- parent -->
<item-list :items="products" v-slot="{ item, index }">
<strong>{{ index }}.</strong> {{ item.name }} — {{ item.price }}
</item-list><!-- item-list template -->
<ul>
<li cv-for="(item, i) in items">
<slot :item="item" :index="i"></slot>
</li>
</ul>Named scoped slots:
<my-table v-slot:row="{ row }">
<td>{{ row.name }}</td>
<td>{{ row.value }}</td>
</my-table>Mounts the component whose name (or config object) matches the expression. Destroys the previous component and mounts the new one when the value changes.
<component :is="activeView"></component>data: { activeView: 'tab-home' },
methods: {
switchTab(name) { this.activeView = name; }
},
components: {
'tab-home': { template: `<p>Home tab content</p>` },
'tab-settings': { template: `<p>Settings content</p>` }
}cv-ref on a custom component exposes the child's reactive state (not the DOM element) in the parent's $refs. This lets the parent call child methods directly.
<!-- parent template -->
<counter-widget cv-ref="counter"></counter-widget>
<button @click="$refs.counter.reset()">Reset from parent</button>
<button @click="$refs.counter.add(5)">+5 from parent</button>// counter-widget component
{
data: { value: 0 },
template: `<p>Value: {{ value }}</p><button @click="value++">+1</button>`,
methods: {
add(n) { this.value += n; },
reset() { this.value = 0; }
}
}By default, non-framework attributes passed to a component (e.g. id, data-*, class) are applied to the component's root element. Set inheritAttrs: false to suppress this and access them via $attrs instead.
<fancy-input id="email" data-required="true" label="Email"></fancy-input>// fancy-input component
{
inheritAttrs: false,
data: { label: '' },
template: `
<label>{{ label }}</label>
<ul>
<li cv-for="(val, key) in $attrs">{{ key }}: {{ val }}</li>
</ul>
`
}$attrs contains all attributes that were not consumed as props (:binding attrs) or events.
Every component receives a $parent reference injected automatically, pointing to the parent component's reactive state. Useful for tightly-coupled parent–child patterns.
<!-- child template -->
<p>Parent says: {{ $parent.message }}</p>Prefer props + emit for general communication.
$parentcreates implicit coupling.
$slots is a plain object where each key is true if the parent provided content for that slot name. Use it for conditional rendering inside a component.
<div cv-if="$slots.header">
<slot name="header"></slot>
</div>Automatically recalculate when their dependencies change.
{
data: { price: 10, qty: 3 },
computed: {
total() { return this.price * this.qty; }
}
}<p>Total: {{ total }}</p>Computed setter — provide a { get, set } object to handle writes:
computed: {
fullName: {
get() { return `${this.first} ${this.last}`.trim(); },
set(val) {
const [f, ...rest] = val.split(' ');
this.first = f ?? '';
this.last = rest.join(' ');
}
}
}<!-- Writing this input calls the setter, which splits into first + last -->
<input cv-model="fullName" />Dependencies are detected by parsing this.key references in the getter's source. Computed values support $store and other reactive keys.
React to state changes. All watchers receive (newVal, oldVal) and have this bound to the component state.
Simple watcher:
watch: {
search(newVal, oldVal) {
if (newVal) this.fetchResults(newVal);
}
}Watcher with options:
watch: {
// immediate — runs once on mount with the current value
count: {
immediate: true,
handler(newVal, oldVal) {
this.log.push(`${oldVal ?? 'init'} → ${newVal}`);
}
},
// deep — detects nested mutations inside objects/arrays
user: {
deep: true,
handler(newVal) {
console.log('user changed:', newVal);
}
}
}Programmatic watcher — $watch:
Create a watcher at runtime from onMount or anywhere in your code. Returns an unsubscribe function.
onMount() {
const stop = this.$watch('count', (newVal, oldVal) => {
console.log(oldVal, '→', newVal);
}, { immediate: true });
// later, to stop watching:
// stop();
}All hooks have this bound to the reactive component state.
| Hook | When it fires |
|---|---|
onBeforeMount |
Before the DOM walk begins — DOM is not yet processed |
onMount |
After mounting is complete — DOM is ready, $el is set |
onBeforeUnmount |
Before the component is destroyed — cleanup here |
onDestroy |
After the component is destroyed |
onActivated |
When a keepAlive component is restored from cache |
onDeactivated |
When a keepAlive component is stored in cache |
onError |
When a descendant component throws — see Error Boundaries |
{
data: { ticks: 0 },
onBeforeMount() {
console.log('before DOM walk');
},
onMount() {
this._timer = setInterval(() => this.ticks++, 1000);
console.log('root element:', this.$el.tagName);
},
onBeforeUnmount() {
clearInterval(this._timer);
},
onDestroy() {
console.log('component destroyed');
}
}Initialize cv-data elements automatically on page load — no createApp() call required. Ideal for adding interactivity to server-rendered HTML.
import { autoInit } from 'courvux';
autoInit(); // scans [cv-data] on DOMContentLoaded<!-- No JavaScript setup beyond the import -->
<div cv-data="{ count: 0, inc() { this.count++ } }">
<button @click="inc()">Clicks: {{ count }}</button>
</div>
<div cv-data="{ open: false }">
<button @click="open = !open">Toggle</button>
<p cv-show="open">Visible!</p>
</div>Options:
autoInit({
components: {
'my-card': MyCardComponent,
},
directives: {
tooltip: myTooltipDirective,
},
globalProperties: {
appName: 'My Site',
},
});Named component shorthand:
autoInit({ components: { dropdown: DropdownDef } });<div cv-data="dropdown">
<!-- uses DropdownDef data + methods -->
</div>autoInit finds all top-level [cv-data] elements — elements nested inside another [cv-data] are handled by their outer scope's walk, not re-mounted by autoInit.
These are available as this.x inside any method, hook, computed getter/setter, or watcher, and as {{ $x }} in templates.
| Property | Description |
|---|---|
this.$el |
The root DOM element of the component |
this.$refs |
Object of refs collected via cv-ref |
this.$route |
Current route — { path, params, query, meta } |
this.$router |
The router instance — call this.$router.navigate('/path') |
this.$store |
The global store |
this.$attrs |
Non-prop, non-event attributes passed to this component |
this.$slots |
{ slotName: true } for each slot provided by the parent |
this.$parent |
The parent component's reactive state |
this.$emit(event, ...args) |
Emit an event to the parent component |
this.$dispatch(event, detail?, opts?) |
Fire a bubbling CustomEvent from $el — any DOM ancestor can listen with @event |
this.$watch(key, handler, opts?) |
Register a watcher programmatically |
this.$watchEffect(fn) |
Auto-tracked side effect, stopped on destroy |
this.$forceUpdate() |
Re-notify all reactive keys — force full DOM refresh |
this.$addCleanup(fn) |
Register a teardown function run on component destroy |
this.$batch(fn) |
Group multiple state mutations into one DOM flush |
this.$nextTick(cb?) |
Run a callback after the next DOM update |
$nextTick example:
methods: {
addItem() {
this.items.push({ id: Date.now(), name: 'New' });
// DOM is not yet updated here — wait for the next flush
this.$nextTick(() => {
this.$refs.list.lastElementChild?.scrollIntoView();
});
}
}$nextTick returns a Promise if no callback is given:
async save() {
this.saved = true;
await this.$nextTick();
console.log('DOM updated, saved badge is visible');
}Standalone import:
nextTickis also exported as a top-level function for use outside component context (stores, plugins, test setup):import { nextTick } from 'courvux'; await nextTick(); // resolves after the next DOM flush
| Direction | Reach | |
|---|---|---|
$emit(event, ...args) |
Parent only | One level |
$dispatch(event, detail?, opts?) |
DOM bubble | Any DOM ancestor |
Use $emit for normal parent-child communication. Use $dispatch when the event should travel multiple component levels without each parent re-emitting it manually.
$dispatch example:
// child component
methods: {
select(item) {
this.$dispatch('item-selected', { id: item.id, name: item.name });
}
}<!-- parent template — catches the bubbling event -->
<div @item-selected="onSelected">
<product-list></product-list>
</div>The event bubbles from the child component's $el up through the DOM tree. Any ancestor element with an @event listener will receive it.
Register directives globally via app.directive() or per-component via directives in the config.
// Full definition
app.directive('focus', {
onMount(el, binding) { el.focus(); },
onUpdate(el, binding) { /* reactive update */ },
onDestroy(el, binding) { /* cleanup */ }
});
// Shorthand — called on mount only
app.directive('highlight', (el, binding) => {
el.style.background = binding.value ?? 'yellow';
});Use in templates:
<!-- Plain directive -->
<input cv-focus />
<!-- With value (reactive) -->
<p cv-highlight="activeColor">Text</p>
<!-- With argument and modifiers -->
<div cv-pin:top.once="offset"></div>DirectiveBinding object:
| Property | Description |
|---|---|
value |
Evaluated expression value (reactive in onUpdate) |
arg |
Argument after : — cv-pin:top → arg = 'top' |
modifiers |
Object of modifier flags — cv-pin.once → modifiers.once = true |
Reactive directives — provide both onMount and onUpdate to react to value changes:
app.directive('color', {
onMount(el, b) { el.style.color = b.value; },
onUpdate(el, b) { el.style.color = b.value; }
});<strong cv-color="selectedColor">Text</strong>Cleanup — use onDestroy to remove event listeners or cancel timers:
app.directive('tooltip', {
onMount(el, b) {
el._tip = createTooltip(el, b.value);
},
onDestroy(el) {
el._tip?.destroy();
}
});Courvux supports two styles of enter/leave transitions, both tied to cv-show.
Add cv-transition directly on any cv-show element for instant fade/scale animations — no CSS needed.
<!-- Fade only (default) -->
<div cv-show="open" cv-transition>Panel</div>
<!-- Fade + scale -->
<div cv-show="open" cv-transition.scale>Panel</div>
<!-- Custom scale origin (0–100) and duration (ms) -->
<div cv-show="open" cv-transition.scale.90.duration.300>Panel</div>
<!-- Scale without fade -->
<div cv-show="open" cv-transition.scale.opacity>Panel</div>Modifiers:
| Modifier | Effect |
|---|---|
| (none) | Fade in/out |
.scale |
Fade + scale (default origin 0.9) |
.scale.N |
Custom scale origin — e.g. .scale.85 = scale(0.85) |
.duration.N |
Animation duration in ms — e.g. .duration.300 |
Attach fine-grained CSS class sets to control every phase of the transition.
<div
cv-show="open"
cv-transition:enter="transition ease-out duration-300"
cv-transition:enter-start="opacity-0 scale-95"
cv-transition:enter-end="opacity-100 scale-100"
cv-transition:leave="transition ease-in duration-200"
cv-transition:leave-start="opacity-100 scale-100"
cv-transition:leave-end="opacity-0 scale-95"
>
Panel
</div>Class timeline per phase:
| Phase | Classes applied | Then removed |
|---|---|---|
| Enter | :enter + :enter-start |
:enter-start → :enter-end → wait → remove all |
| Leave | :leave + :leave-start |
:leave-start → :leave-end → wait → hide + remove all |
Each step is separated by a double requestAnimationFrame to guarantee the browser paints the start state before the transition begins.
Works with any CSS utility framework (Tailwind, UnoCSS, etc.).
Wraps an element and controls visibility via a :show prop. Uses the built-in named transitions.
<button @click="show = !show">Toggle</button>
<cv-transition name="fade" :show="show">
<div class="panel">Animated content</div>
</cv-transition>Built-in names: fade, slide-down, slide-up.
CSS classes injected:
{name}-enter— applied during the enter phase, removed when done{name}-leave— applied during the leave phase, element hidden after
Custom transition — define your own CSS:
.my-pop-enter {
animation: pop-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.my-pop-leave {
animation: pop-out 0.2s ease-in forwards;
}
@keyframes pop-in { from { opacity: 0; transform: scale(0.85); } }
@keyframes pop-out { to { opacity: 0; transform: scale(0.85); } }<cv-transition name="my-pop" :show="showModal">
<div class="modal">...</div>
</cv-transition>Runs an expression when an element enters or leaves the viewport. Backed by the native IntersectionObserver API — no-op if unsupported.
<!-- Fire when element scrolls into view -->
<div cv-intersect="loadMore()">...</div>
<!-- Separate enter / leave handlers -->
<div
cv-intersect:enter="onEnter()"
cv-intersect:leave="onLeave()"
>...</div>
<!-- Only fire once, then stop observing -->
<div cv-intersect.once="trackImpression()">...</div>Threshold modifiers — how much of the element must be visible:
<!-- 50% visible -->
<div cv-intersect.half="handler()">...</div>
<!-- 100% visible -->
<div cv-intersect.full="handler()">...</div>
<!-- Arbitrary % (0–100) -->
<div cv-intersect.threshold-75="handler()">...</div>Margin modifier — expand or shrink the detection zone:
<!-- Trigger 200px before the element reaches the viewport -->
<div cv-intersect.margin-200="prefetch()">...</div>Combining modifiers:
<div cv-intersect.once.half.margin-100="animate()">...</div>Modifier reference:
| Modifier | Description |
|---|---|
.once |
Disconnect observer after first intersection |
.half |
threshold = 0.5 (50% visible) |
.full |
threshold = 1.0 (100% visible) |
.threshold-N |
threshold = N / 100 |
.margin-N |
rootMargin = Npx (positive = expand zone) |
import { createApp, createRouter } from 'courvux';
const router = createRouter(routes, options);
createApp({ router, template: `<router-view></router-view>` }).mount('#app');Navigation links:
<router-link to="/">Home</router-link>
<router-link to="/about" class="nav-link">About</router-link>router-link renders an <a> tag. Attributes (including class) are forwarded to the anchor. The active CSS class and aria-current="page" are added automatically when the link matches the current route.
| Option | Type | Description |
|---|---|---|
path |
string |
URL pattern — supports :param segments and * (wildcard) |
component |
ComponentConfig | LazyComponent |
Component to render (single view) |
components |
Record<string, ComponentConfig | LazyComponent> |
Named views — see Named router views |
redirect |
string | (route) => string |
Redirect to another path |
layout |
string |
HTML wrapper template containing a <router-view> |
transition |
string |
Per-route transition override |
keepAlive |
boolean |
Cache DOM and state when navigating away |
meta |
object |
Arbitrary data — accessible as $route.meta |
loadingTemplate |
string |
HTML shown while a lazy component is loading |
beforeEnter |
NavigationGuard |
Per-route navigation guard |
children |
RouteConfig[] |
Nested child routes |
| Option | Type | Description |
|---|---|---|
mode |
'hash' | 'history' |
Routing mode — default 'hash' |
transition |
string |
Global transition applied to all routes |
beforeEach |
NavigationGuard |
Global guard — runs before every navigation |
afterEach |
(to, from) => void |
Runs after every navigation |
scrollBehavior |
ScrollBehavior |
Controls scroll position |
{ path: '/user/:id', component: { template: `<p>ID: {{ $route.params.id }}</p>` } }$route is available in any component rendered by the router:
| Property | Description |
|---|---|
$route.path |
Current pathname, e.g. /user/42 |
$route.params |
Path params — { id: '42' } |
$route.query |
Query string as a plain object — { page: '2', filter: 'active' } |
$route.meta |
Route-level metadata object |
<!-- template -->
<p>Page: {{ $route.query.page ?? '1' }}</p>
<p>Filter: {{ $route.query.filter ?? 'all' }}</p>// Static redirect
{ path: '/home', redirect: '/' },
// Dynamic redirect — receives the current RouteMatch
{ path: '/old/:id', redirect: (route) => `/new/${route.params.id}` }{ path: '/dashboard', component: () => import('./dashboard.js') }// dashboard.js
export default {
template: `<h1>Dashboard</h1>`,
data: { /* ... */ },
methods: { /* ... */ }
};With Vite and .html templates:
// dashboard.ts
import template from './dashboard.html?raw';
export default { template, data: {}, methods: {} };Show a placeholder while a lazy component resolves (first load only):
{
path: '/heavy',
loadingTemplate: `<p>Loading...</p>`,
component: () => import('./heavy.js')
}A layout is an HTML string containing a <router-view> where the route component is injected. The layout template has access to $store.
const sidebarLayout = `
<aside>{{ $store.user }}</aside>
<main><router-view></router-view></main>
`;
{ path: '/dashboard', layout: sidebarLayout, component: DashboardComp }The parent component must contain a <router-view>. Child paths are relative to the parent.
{
path: '/panel',
component: {
template: `
<nav>
<router-link to="/panel/summary">Summary</router-link>
<router-link to="/panel/config">Config</router-link>
</nav>
<router-view></router-view>
`
},
children: [
{ path: '/summary', component: SummaryComp },
{ path: '/config', component: ConfigComp }
]
}The parent stays mounted while navigating between children.
Render multiple components for a single route using named <router-view> elements.
<!-- app template -->
<router-view></router-view>
<aside><router-view name="panel"></router-view></aside>{
path: '/extras',
components: {
default: MainComponent,
panel: SidebarComponent
}
}Routes that don't define a panel component leave that view empty.
Built-in: 'fade', 'slide-up'.
// Global — applies to all routes
createRouter(routes, { transition: 'fade' });
// Per-route override
{ path: '/modal', transition: 'slide-up', component: ModalComp }Caches the DOM and state of a route. When navigating back, the component is restored without re-mounting.
{ path: '/form', keepAlive: true, component: FormComp }onActivated / onDeactivated hooks fire when the component enters/exits the cache.
// Global guard
createRouter(routes, {
beforeEach(to, next) {
if (!isLoggedIn() && to.path !== '/login') {
next('/login');
} else {
next(); // or next('/other') to redirect
}
}
});
// Per-route guard
{
path: '/admin',
beforeEnter(to, next) {
if (!isAdmin()) next('/');
else next();
},
component: AdminComp
}next() — continue. next('/path') — redirect. Omitting the call blocks navigation.
createRouter(routes, {
scrollBehavior(to, from) {
return { x: 0, y: 0 }; // scroll to top on every navigation
}
});methods: {
goHome() { this.$router.navigate('/'); },
goToUser(id) { this.$router.navigate(`/user/${id}`); },
goBack() { this.$router.back(); },
// Navigate with query string
search(term) {
this.$router.navigate('/results', { query: { q: term, page: '1' } });
},
// → /results?q=term&page=1
// Replace (no history entry)
redirect(path) {
this.$router.replace(path, { query: { from: 'redirect' } });
},
}| Method | Description |
|---|---|
navigate(path, opts?) |
Push a new history entry. opts.query is serialized as ?key=value. |
replace(path, opts?) |
Replace current history entry (no back-button entry). |
back() |
Go to previous history entry. |
forward() |
Go to next history entry. |
A global reactive state container. Access via $store in any template or component.
import { createStore } from 'courvux';
const store = createStore({
state: { user: 'guest', count: 0 },
actions: {
setUser(name) { this.user = name; },
increment() { this.count++; }
}
});
createApp({ store, template: `...` }).mount('#app');<!-- any template -->
<p>{{ $store.user }}</p>
<input cv-model="$store.user" />
<button @click="$store.increment()">+</button>State keys and action names must be distinct — a warning is logged if they collide.
Organize the store into namespaced sub-stores. Each module is a full standalone store.
const store = createStore({
state: { theme: 'light' },
actions: {
toggleTheme() { this.theme = this.theme === 'light' ? 'dark' : 'light'; }
},
modules: {
counter: {
state: { n: 0 },
actions: {
inc() { this.n++; },
dec() { this.n--; },
reset() { this.n = 0; }
}
},
user: {
state: { name: 'guest', role: 'viewer' },
actions: {
login(name, role) { this.name = name; this.role = role; }
}
}
}
});<!-- access module state -->
<p>Count: {{ $store.counter.n }}</p>
<p>Role: {{ $store.user.role }}</p>
<!-- call module actions -->
<button @click="$store.counter.inc()">+</button>
<button @click="$store.user.login('Alice', 'admin')">Login</button>Module state and actions are fully reactive.
Pass data deep into the component tree without threading props through every level.
Provide — on any ancestor component (including the root app):
createApp({
provide: {
theme: 'dark',
apiUrl: 'https://api.example.com'
},
// ...
})
// Or as a function for reactive values
{
provide() {
return { currentUser: this.user };
}
}Inject — in any descendant component:
// Array shorthand — key names match provide
{ inject: ['theme', 'apiUrl'] }
// Object form — rename on injection
{ inject: { localTheme: 'theme', endpoint: 'apiUrl' } }Injected keys are available as this.theme, {{ theme }}, etc. — just like data.
Group multiple state mutations so they trigger only one DOM update cycle instead of one per change.
methods: {
updateAll() {
this.$batch(() => {
this.a++;
this.b++;
this.c = 'new';
// DOM is updated once after this block
});
}
}Also available as a named export:
import { batchUpdate } from 'courvux';
batchUpdate(() => {
store.counter.n = 10;
store.user.role = 'admin';
});An onError hook catches errors thrown by any descendant component's onMount. The error does not propagate further — the component with onError handles it.
{
data: { hasError: false, errorMsg: '' },
onError(err) {
this.hasError = true;
this.errorMsg = err.message;
},
template: `
<p cv-if="hasError" class="error">Error: {{ errorMsg }}</p>
<div cv-if="!hasError">
<risky-widget></risky-widget>
</div>
`
}The recommended API. createPlugin provides dedupe by name — installing the same plugin twice is a no-op.
import { createPlugin } from 'courvux';
export const lucidePlugin = createPlugin({
name: 'lucide',
install(app) {
app.router?.afterEach(() => createIcons());
}
});
createApp(config).use(lucidePlugin).mount('#app');A plugin is an object with an install(app) method. Install before mounting.
const myPlugin = {
install(app) {
// Hook into the router
if (app.router) {
const prev = app.router.afterEach;
app.router.afterEach = (to, from) => {
prev?.(to, from);
analytics.track(to.path);
};
}
}
};
createApp(config)
.use(myPlugin)
.mount('#app');Plugins are installed in order. Duplicate installs are silently ignored.
pnpm add lucideImport map (no bundler):
<script type="importmap">
{
"imports": {
"courvux": "./node_modules/courvux/dist/index.js",
"lucide": "./node_modules/lucide/dist/esm/lucide.mjs"
}
}
</script>import { createApp } from 'courvux';
import { createIcons, Home, Star, User } from 'lucide';
const ICONS = { Home, Star, User };
const lucidePlugin = {
install(app) {
if (app.router) {
const prev = app.router.afterEach;
app.router.afterEach = (to, from) => {
prev?.(to, from);
createIcons({ icons: ICONS });
};
}
}
};
createApp({
onMount() { createIcons({ icons: ICONS }); },
// ...
})
.use(lucidePlugin)
.mount('#app');<i data-lucide="home"></i>
<i data-lucide="star"></i>Courvux ships a small set of composables covering common app needs without third-party dependencies. All preserve this binding, are SSR-safe, and integrate with $addCleanup for automatic teardown.
| Composable | Purpose |
|---|---|
cvStorage(key, defaults) |
Reactive object backed by localStorage, auto-persists |
cvFetch(url, callback, options) |
Reactive HTTP fetch with { data, loading, error } callback |
cvDebounce(fn, ms) |
Debounced function preserving this |
cvThrottle(fn, ms) |
Throttled function preserving this |
cvMediaQuery(query, callback) |
matchMedia with reactive callback |
cvListener(target, event, handler, opts?) |
addEventListener with cleanup return |
cvStorage for app settings:
import { cvStorage } from 'courvux';
const settings = cvStorage('app-settings', { theme: 'light', sidebar: true });
settings.theme = 'dark'; // automatically persisted to localStorage
settings.$clear(); // reset to defaults + remove from localStoragecvFetch for reactive data:
onMount() {
const { execute, abort } = cvFetch('/api/users', ({ data, loading, error }) => {
this.users = data ?? [];
this.loading = loading;
this.error = error;
});
this.$addCleanup(abort);
}cvDebounce inside a method:
methods: {
search: cvDebounce(function(q) {
return fetch(`/search?q=${q}`)
.then(r => r.json())
.then(r => this.results = r);
}, 300)
}cvMediaQuery for responsive logic:
onMount() {
cvMediaQuery('(max-width: 768px)', (matches) => {
this.isMobile = matches;
});
}cvListener with auto-cleanup:
onMount() {
const off = cvListener(window, 'keydown', (e) => {
if (e.key === 'Escape') this.close();
});
this.$addCleanup(off);
}useHead is the per-component head management composable. It updates document.title, inserts/upserts <meta> and <link> tags, and lets each route declare its own metadata. Tags are reverted on cleanup so navigating away from a route restores the previous head exactly.
import { useHead } from 'courvux';
export default {
onMount() {
const cleanup = useHead({
title: 'Installation',
titleTemplate: '%s — Courvux',
meta: [
{ name: 'description', content: 'Get started with Courvux in under 60 seconds.' },
{ property: 'og:title', content: 'Installation — Courvux' },
{ property: 'og:description', content: 'Get started with Courvux in under 60 seconds.' },
{ property: 'og:image', content: '/og/installation.png' },
{ name: 'twitter:card', content: 'summary_large_image' },
],
link: [
{ rel: 'canonical', href: 'https://courvux.dev/installation' },
],
});
this.$addCleanup(cleanup);
}
};| Field | Type | Notes |
|---|---|---|
title |
string |
Replaces document.title. Restored on cleanup. |
titleTemplate |
string | (t) => string |
String form: %s is replaced. Function form: receives the title and returns the final string. |
meta |
HeadMeta[] |
Each entry becomes a <meta> tag. Dedupe by name, then property, then http-equiv. |
link |
HeadLink[] |
Each entry becomes a <link> tag. rel="canonical" is unique. Other links dedupe by rel + href. |
script |
HeadScript[] |
Each entry becomes a <script> tag. Use innerHTML for inline content. Always inserted fresh — use sparingly. |
htmlAttrs |
Record<string,string> |
Sets attributes on <html> (e.g. lang, class). Restored on cleanup. |
bodyAttrs |
Record<string,string> |
Sets attributes on <body>. Restored on cleanup. |
Inject Schema.org structured data via the script field:
useHead({
script: [{
type: 'application/ld+json',
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: 'Courvux',
applicationCategory: 'DeveloperApplication',
operatingSystem: 'Any',
offers: { '@type': 'Offer', price: '0' },
}),
}],
});useHead is a no-op when document is unavailable, so it's safe to call during SSR. SSG integration that captures these tags during renderToString for static HTML emission is on the roadmap.
Tip — SEO baseline. Pair
useHeadwithmode: 'history'in the router so each route has a real URL the crawler can fetch. Hash routing (#/path) prevents servers and crawlers from seeing per-route content.
Courvux ships a Vite plugin that pre-renders every route to its own index.html at build time. Crawlers, Open Graph previewers, and static hosts (GitHub Pages, Netlify, Cloudflare Pages) see real per-route HTML — not an empty SPA shell.
The plugin captures useHead calls during render, so each emitted page has its correct <title>, meta tags, canonical link, and JSON-LD inlined into <head>. A sitemap.xml and robots.txt are emitted alongside.
// vite.config.js
import { defineConfig } from 'vite';
import courvuxSsg from 'courvux/plugin/ssg';
export default defineConfig({
plugins: [
courvuxSsg({
// Required — async function returning the route list.
// Each entry: { path, component, head?, prerender? }
routes: async () => (await import('./src/routes.js')).default,
// Site base URL — required for sitemap.xml + robots.txt
baseUrl: 'https://courvux.dev',
// Optional — page shell with %head%, %app%, %mountId% placeholders.
// Defaults to a minimal HTML5 shell.
// template: '<!doctype html>...',
// Optional — id of the mount root in the shell. Default: 'app'.
// mountId: 'app',
// Optional — also emit sitemap.xml + robots.txt. Default: true.
// sitemap: true,
}),
],
});const routes = [
{
path: '/',
component: HomePage,
// Optional fallback head if the component does not call useHead
head: { title: 'Home — Courvux' }
},
{
path: '/installation',
component: InstallationPage, // calls useHead({ title, meta, ... }) in onMount
},
{
// Dynamic route: the plugin calls prerender() to learn which paths to emit
path: '/blog/:slug',
component: BlogPost,
prerender: async () => {
const posts = await fetch('https://api.example.com/posts').then(r => r.json());
return posts.map(p => `/blog/${p.slug}`);
},
},
];dist/
├── index.html ← /
├── installation/index.html ← /installation
├── blog/
│ ├── intro/index.html ← /blog/intro (from prerender)
│ └── faq/index.html ← /blog/faq
├── sitemap.xml
└── robots.txt
During SSG, useHead calls are buffered instead of mutating the document. The plugin merges them per route, applies dedupe rules (same as runtime), and inlines them into the <head> of the emitted HTML. If a component does not call useHead, the route-level head field is used as a fallback.
onMount is invoked during SSG so the standard useHead pattern works as-is. Errors thrown from onMount (e.g. for client-only APIs like IntersectionObserver) are caught and logged — guard SSR-incompatible code with typeof window === 'undefined'.
If you don't use Vite, the same primitives are exported:
import { renderPage, renderHeadToString } from 'courvux';
const { html, head } = await renderPage(componentConfig);
const headHtml = renderHeadToString(head);
// → embed `headHtml` in your shell, then `html` in the mount rootFor cross-component signals that don't belong in the store (analytics events, IPC bridges, plugin hooks), Courvux exports a typed event bus:
import { createEventBus, type EventBus } from 'courvux';
interface AppEvents {
'user:login': { id: string; name: string };
'cart:update': { count: number };
}
const bus: EventBus<AppEvents> = createEventBus();
const off = bus.on('user:login', payload => { /* ... */ });
bus.emit('user:login', { id: '1', name: 'Alice' });
bus.once('cart:update', payload => { /* fires once */ });
off(); // unsubscribe
bus.clear('user:login'); // clear all listeners for an eventProvide it via createApp({ provide: { bus } }) and inject in components.
import { markRaw, toRaw, readonly, batchUpdate } from 'courvux';| Helper | Use case |
|---|---|
markRaw(obj) |
Skip Proxy wrapping (third-party class instances like Chart.js or xterm.js controllers) |
toRaw(reactive) |
Get the underlying non-Proxy object (serialization, JSON.stringify, deep equality) |
readonly(obj) |
Wrap so writes are silently ignored (use for provide values that shouldn't mutate downstream) |
batchUpdate(fn) |
Group multiple mutations into one DOM flush — see Batch Updates |
{
data: {
chart: markRaw(new Chart(canvas, opts)), // not made reactive — internal slots stay intact
}
}Native built-ins like Date, Map, Set, RegExp, and typed arrays are automatically skipped from Proxy wrapping (they rely on internal slots that break under Proxy).
Courvux ships an in-app DevTools panel — no browser extension required. It mounts a draggable badge in the corner of the page that opens a panel showing all mounted components, their reactive state, and the global store, with inline live editing: click any value to edit it, press Enter to commit.
import { createApp, setupDevTools, mountDevOverlay } from 'courvux';
const app = createApp(config);
if (import.meta.env.DEV) {
const hook = setupDevTools();
mountDevOverlay(hook);
}
await app.mount('#app');The hook is also exposed at window.__COURVUX_DEVTOOLS__ for use by external tooling (a Chrome extension is on the roadmap).
Hook API:
interface DevToolsHook {
instances: DevToolsComponentInstance[];
stores: DevToolsStoreEntry[];
on(event: 'mount' | 'update' | 'destroy' | 'store-update', cb): () => void;
}Each instance exposes getState(), setState(key, value), and subscribe(cb) for programmatic introspection.
Courvux supports basic SSR via renderToString, plus client-side hydration. Requires jsdom or happy-dom as a peer dependency on the server.
// server.js
import { JSDOM } from 'jsdom';
const { window } = new JSDOM('<!DOCTYPE html><html><body></body></html>');
globalThis.document = window.document;
globalThis.window = window;
import { renderToString } from 'courvux';
const html = await renderToString(myConfig, { data: { /* SSR data */ } });
// → '<root data-courvux-ssr="true">Hello</root>'The client-side mount() automatically detects data-courvux-ssr and hydrates instead of re-rendering. SSR is currently best-suited to small static sites and SSG; it's not yet optimized for high-throughput SSR servers.
SSR-related exports:
| Export | Purpose |
|---|---|
renderToString(config, opts?) |
Renders a component config to an HTML string |
SSR_ATTR |
The hydration marker attribute (data-courvux-ssr) — useful for tooling |
A first-class SSG plugin (courvux/plugin/ssg) that pre-renders every static route at build time is on the roadmap.
Courvux exports a Vitest-compatible test utility from 'courvux/test-utils':
import { mount } from 'courvux/test-utils';
import { describe, it, expect } from 'vitest';
describe('counter', () => {
it('increments on click', async () => {
const w = await mount({
template: '<button @click="count++">{{ count }}</button>',
data: { count: 0 }
});
w.find('button').click();
await w.nextTick();
expect(w.find('button').textContent).toBe('1');
w.destroy();
});
});Run with vitest. The recommended test environment is happy-dom:
// vitest.config.js
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: { environment: 'happy-dom' }
});The wrapper exposes:
| Method | Description |
|---|---|
state |
The mounted reactive state |
find(selector) |
First matching element inside the mount |
findAll(selector) |
All matching elements |
nextTick() |
Wait for the next DOM flush |
destroy() |
Tear down the mount |
Courvux does not bundle PWA tooling — the manifest and service worker strategy are always app-specific. This section covers the minimal setup to make any Courvux app installable and offline-capable, plus an optional utility for reacting to install and connectivity events in your components.
Create public/manifest.json:
{
"name": "My App",
"short_name": "MyApp",
"description": "A Courvux application",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#3b82f6",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}Link it in index.html:
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#3b82f6" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />Install the Vite plugin:
npm install -D vite-plugin-pwaConfigure in vite.config.ts:
import { defineConfig } from 'vite';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [
VitePWA({
registerType: 'autoUpdate',
manifest: false, // use your own public/manifest.json
workbox: {
// cache the app shell and all static assets
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
// runtime caching for API calls
runtimeCaching: [
{
urlPattern: /^https:\/\/api\.yourapp\.com\//,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
networkTimeoutSeconds: 5,
expiration: { maxEntries: 100, maxAgeSeconds: 60 * 60 * 24 },
},
},
],
},
}),
],
});Cache strategies at a glance:
| Strategy | Best for |
|---|---|
CacheFirst |
Static assets (fonts, images, icons) |
NetworkFirst |
API calls — fresh data when online, fallback when offline |
StaleWhileRevalidate |
Non-critical data — instant from cache, updates in background |
The browser fires beforeinstallprompt when the app is installable, but only once. Capture it early — before any user interaction — and surface it at the right moment.
Create src/pwa.ts:
interface PWAState {
installable: boolean;
installed: boolean;
online: boolean;
prompt: (() => Promise<void>) | null;
}
export function createPWA(): PWAState {
const state: PWAState = {
installable: false,
installed: window.matchMedia('(display-mode: standalone)').matches,
online: navigator.onLine,
prompt: null,
};
let deferredPrompt: any = null;
window.addEventListener('beforeinstallprompt', (e: any) => {
e.preventDefault();
deferredPrompt = e;
state.installable = true;
state.prompt = async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') {
state.installed = true;
state.installable = false;
}
deferredPrompt = null;
state.prompt = null;
};
});
window.addEventListener('appinstalled', () => {
state.installed = true;
state.installable = false;
deferredPrompt = null;
});
window.addEventListener('online', () => { state.online = true; });
window.addEventListener('offline', () => { state.online = false; });
return state;
}Use it in your app:
import { createApp } from 'courvux';
import { createPWA } from './pwa';
const pwa = createPWA();
createApp({
data: { pwa },
template: `<router-view></router-view>`,
// ...
}).mount('#app');Then in any template:
<!-- offline banner -->
<div cv-if="!pwa.online" class="offline-banner">
Sin conexión — usando datos en caché
</div>
<!-- install button -->
<button cv-if="pwa.installable && !pwa.installed" @click="pwa.prompt()">
Instalar aplicación
</button>What createPWA does and does not do:
| Does | Does not |
|---|---|
Captures beforeinstallprompt before it expires |
Register or manage the service worker |
Exposes a prompt() function to trigger the install dialog |
Handle cache versioning or update notifications |
Tracks online / offline state reactively |
Decide cache strategies — that belongs in vite.config.ts |
| Detects if already running in standalone mode | Polyfill Safari's lack of beforeinstallprompt |
Safari / iOS: The install prompt API is not supported. Users must add to home screen manually via the share button. You can detect iOS and show a custom instruction with
navigator.userAgent.includes('iPhone') || navigator.userAgent.includes('iPad').
pnpm buildProduces:
| File | Description |
|---|---|
dist/index.js |
Minified ES module (~20 kB gzip with all features) |
dist/index.d.ts |
TypeScript declarations |
dist/types.d.ts |
Exported type definitions |
dist/dom.d.ts |
DOM walk context types |
dist/reactivity.d.ts |
Reactivity primitives |
dist/router.d.ts |
Router types |
pnpm dev # TypeScript watch + dev server at http://localhost:3000The dev server (devserver.js) serves:
/dist/— built framework files/node_modules/— npm packages (for import maps)app/— demo application (SPA fallback for history mode)
Courvux is pre-1.0 and not yet on npm — install via the GitHub URL with a tag pin (see Installation). Honest assessment of what's safe to ship today vs what isn't:
Use it for
- Marketing sites and docs (this site is built with it).
- Internal tools / dashboards / admin panels — anywhere the audience and the team are bounded.
- Mobile/desktop apps via Tauri / Electron / Capacitor — the small bundle is a real win in those shells.
- Side projects, prototypes, MVPs.
- Static sites that need real per-route HTML (SSG).
Hold off if
- Your project requires strict CSP that disallows
new Function— the expression evaluator falls back to a property-access-only mode, which loses inline JS expressions inside templates. - You need a battle-tested ecosystem of third-party plugins / UI kits — Courvux is small enough that the ecosystem is still you.
- The framework choice is a contractual / regulatory blocker — pre-1.0 software, even when stable, may not meet that bar.
What we measure for "stable"
Every release runs unit tests + SSR / SSG self-tests + Playwright E2E on Chromium and Firefox + a real-device manual smoke on Safari iOS and Samsung Internet (see RELEASE_CHECKLIST.md). Bugs that affect supported browsers are first-priority fixes (the 0.4.4 / 0.4.5 / 0.4.7 patches are how this works in practice).
If you're shipping something with Courvux, open a PR adding it here — keeps the section honest and helps other evaluators see it's not just a toy.
- Courvux docs site — the site you're reading. Built with
courvux/plugin/ssg, deployed to GitHub Pages.
| Item | Description |
|---|---|
CSP / new Function |
Expression evaluation and inline event handlers use new Function. Falls back to a safe evaluator (property access + literals only) under strict CSP — complex JS expressions in templates won't work. |
cv-for without :key |
Without a :key, any change to a tracked array/object destroys and recreates all list nodes. Use :key="item.id" to enable keyed reconciliation — only changed/added/removed nodes are touched. |
cv-for array mutation |
Courvux detects array reassignment (items = newArray) and does a full keyed diff. Direct array mutations like items.push(x) or items[0].name = 'x' also trigger reactively via the deep Proxy, but the diff still runs over the full list. Intercepting specific mutations (push/splice) for O(1) DOM ops is not yet implemented. |
| Self-closing custom elements | <my-comp /> is not valid for custom elements — HTML5 parser ignores the trailing /, leaving the element open and swallowing its siblings. Always use explicit closing tags: <my-comp></my-comp>. |
| SSR scope | Basic SSR + hydration is supported via renderToString, and route pre-rendering is shipped via courvux/plugin/ssg. Neither is yet optimized for high-throughput SSR servers — the use case targeted today is SSG / static export, not per-request server rendering. |
Everything exported from 'courvux' (v0.7.1):
App & lifecycle:
createApp, defineComponent, defineAsyncComponent, createPlugin, autoInit, nextTick, html
Router & store:
createRouter, createStore
Reactivity:
batchUpdate, markRaw, toRaw, readonly
Composables:
defineComposable, useComposables, cvStorage, cvFetch, cvDebounce, cvThrottle, cvMediaQuery, cvListener
Event bus:
createEventBus
DevTools:
setupDevTools, mountDevOverlay
SSR / SSG:
renderToString, renderPage, renderHeadToString, SSR_ATTR
SEO:
useHead
Subpath exports:
| Path | Purpose |
|---|---|
'courvux' |
Main runtime |
'courvux/test-utils' |
Vitest helpers (mount) |
'courvux/plugin' |
Vite plugin for templateUrl inlining |
'courvux/plugin/ssg' |
Vite plugin for static site generation |
'courvux/plugin/precompile' |
Vite plugin for build-time expression precompile (drops script-src 'unsafe-eval') — see /csp |
The repo ships a Claude Code skill at skills/courvux/SKILL.md. Drop it into ~/.claude/skills/courvux/ (or any agent that supports skill files) and your assistant gets a condensed reference to every public surface — directives, components, router, store, useHead, SSG plugin, composables, devtools, gotchas, and the project layout.
mkdir -p ~/.claude/skills/courvux
cp skills/courvux/SKILL.md ~/.claude/skills/courvux/The skill is kept in sync with the framework's public API on every release.
Self-contained example projects in examples/:
| # | Example | What it shows |
|---|---|---|
| 01 | TodoMVC | Components, computed, watchers, deep persistence, keyed cv-for, dynamic :cv-ref |
| 02 | Counter | Smallest possible Courvux app — drop into Tauri / Electron / mobile webview |
| 03 | SSG blog | useHead, courvux/plugin/ssg, history mode, sitemap, dynamic-route prerender |
| 04 | Island mode | autoInit() upgrading cv-data islands inside server-rendered HTML |
See BENCHMARKS.md for bundle-size comparisons and the methodology for cross-framework runtime benchmarks.
Type exports (import type):
AppConfig, ComponentConfig, RouteConfig, Router, RouteMatch, RouteActivation, NavigationGuard, ScrollBehavior, WatcherEntry, WatcherOptions, DirectiveBinding, DirectiveDef, DirectiveShorthand, LazyComponent, ComputedDef, EventBus, FetchState, FetchOptions, DevToolsHook, DevToolsComponentInstance, DevToolsStoreEntry, StoreConfig, HeadConfig, HeadMeta, HeadLink, HeadScript, RenderedPage
