# Vani Framework
Vani is a small, dependency-free UI runtime built around the principle that rendering should be explicit, local, and predictable. Unlike Virtual DOM frameworks, Vani gives developers direct control over when and what updates through explicit `handle.update()` calls, anchor-delimited DOM subtrees, and opt-in fine-grained reactivity via signals.
The framework supports SPA, SSR, and SSG patterns through a unified API. Components own DOM ranges delimited by comment anchors (`` and ``), ensuring updates affect only the targeted subtree. Vani is JS-first and transpiler-free, with an optional JSX adapter for those who prefer JSX syntax.
---
## component(fn)
Creates a component factory from a setup function. The setup function receives props and a Handle for lifecycle management, and returns a render function that produces the component's DOM output.
```typescript
import { component, div, button, renderToDOM, type Handle } from '@vanijs/vani'
// Basic component with props and explicit updates
const Counter = component<{ initialValue: number }>((props, handle: Handle) => {
let count = props.initialValue
// Setup-phase side effect with cleanup
handle.onBeforeMount(() => {
console.log('Counter mounting with initial:', count)
return () => console.log('Counter disposing')
})
// Access rendered DOM after first render
handle.onMount((getNodes, parent) => {
const nodes = getNodes()
console.log('Mounted', nodes.length, 'nodes in', parent)
})
return () =>
div(
{ className: 'counter' },
`Count: ${count}`,
button(
{
onclick: () => {
count += 1
handle.update() // Explicit re-render
},
},
'Increment',
),
)
})
// Mount to DOM
const root = document.getElementById('app')!
const [handle] = renderToDOM(Counter({ initialValue: 0 }), root)
// Later: programmatic update or disposal
// handle.update()
// handle.dispose()
```
---
## renderToDOM(components, root)
Mounts one or more components to a DOM element, scheduling the initial render on the next microtask. Returns an array of Handle objects for programmatic control.
```typescript
import { component, div, renderToDOM, type Handle } from '@vanijs/vani'
const Header = component(() => () => div({ className: 'header' }, 'App Header'))
const Content = component(() => () => div({ className: 'content' }, 'Main Content'))
const Footer = component(() => () => div({ className: 'footer' }, 'Footer'))
// Mount single component
const root = document.getElementById('app')!
const [headerHandle] = renderToDOM(Header(), root)
// Mount multiple components
const handles = renderToDOM([Header(), Content(), Footer()], root)
// Access individual handles
const [h1, h2, h3] = handles
h2.update() // Update only Content
```
---
## hydrateToDOM(components, root)
Binds component handles to existing server-rendered DOM without re-rendering. Call `handle.update()` to activate interactivity after hydration.
```typescript
import { component, div, button, hydrateToDOM, type Handle, type ComponentRef } from '@vanijs/vani'
const Interactive = component((_, handle: Handle) => {
let clicks = 0
return () =>
div(
button(
{
onclick: () => {
clicks += 1
handle.update()
},
},
`Clicks: ${clicks}`,
),
)
})
// Server HTML already contains:
const root = document.getElementById('app')!
// Hydrate and activate
const ref: ComponentRef = { current: null }
const [handle] = hydrateToDOM(Interactive({ ref }), root)
handle.update() // Activate event handlers
// Selective hydration: hydrate but don't activate all components
const handles = hydrateToDOM([Header(), Content(), Footer()], root)
handles[0].update() // Only activate Header
```
---
## renderToString(components)
Server-side renders components to an HTML string with anchor comments for hydration. Async components are awaited automatically.
```typescript
import { component, div, h1, p, renderToString } from '@vanijs/vani'
const Page = component<{ title: string }>((props) => {
return () =>
div(
{ className: 'page' },
h1(props.title),
p('Server-rendered content'),
)
})
// Async component example
const AsyncData = component(async () => {
const data = await fetch('/api/data').then((r) => r.json())
return () => div(`Data: ${JSON.stringify(data)}`)
})
// Render to string (works in Node.js, Deno, Bun)
const html = await renderToString([
Page({ title: 'My App' }),
AsyncData(),
])
console.log(html)
// Output:
My App
Server-rendered content
Data: {...}
```
---
## signal(value), derive(fn), effect(fn)
Opt-in fine-grained reactivity primitives. Signals hold values, derive creates computed getters, and effects run side effects when dependencies change.
```typescript
import { component, div, button, signal, derive, effect, text, attr } from '@vanijs/vani'
const ReactiveCounter = component(() => {
// Create reactive signal
const [count, setCount] = signal(0)
const [multiplier, setMultiplier] = signal(2)
// Derived value (recomputes when dependencies change)
const doubled = derive(() => count() * multiplier())
// Side effect (runs when count changes)
effect(() => {
console.log('Count changed to:', count())
return () => console.log('Cleanup previous effect')
})
return () =>
div(
// text() binds signal to text node (fine-grained update)
text(() => `Count: ${count()}`),
div(text(() => `Doubled: ${doubled()}`)),
button({ onclick: () => setCount((n) => n + 1) }, 'Inc'),
button({ onclick: () => setMultiplier((m) => m + 1) }, 'Inc Multiplier'),
)
})
// attr() binds signal to element attribute
const DynamicClass = component(() => {
const [active, setActive] = signal(false)
return () => {
const el = div({ className: 'base' }, 'Toggle me')
attr(el as Element, 'className', () => (active() ? 'base active' : 'base'))
return div(
el,
button({ onclick: () => setActive((a) => !a) }, 'Toggle'),
)
}
})
```
---
## Element Helpers (div, span, button, input, etc.)
Vani exports helper functions for all standard HTML and SVG elements. Pass props as the first argument (optional) followed by children.
```typescript
import {
div, span, button, input, label, form, ul, li, a, img,
h1, h2, p, table, thead, tbody, tr, th, td,
svg, path, circle, rect,
type HtmlProps, type DomRef,
} from '@vanijs/vani'
// Basic elements with props and children
const card = div(
{ className: 'card', dataTestId: 'my-card' },
h2('Card Title'),
p('Card content goes here'),
button({ onclick: () => alert('Clicked!') }, 'Action'),
)
// Form with refs
const inputRef: DomRef = { current: null }
const loginForm = form(
{ onsubmit: (e) => e.preventDefault() },
label({ htmlFor: 'email' }, 'Email'),
input({ ref: inputRef, id: 'email', type: 'email', placeholder: 'you@example.com' }),
button({ type: 'submit' }, 'Submit'),
)
// After render, access DOM element
// inputRef.current?.focus()
// Lists
const todoList = ul(
{ className: 'todo-list' },
li('Item 1'),
li('Item 2'),
li({ className: 'completed' }, 'Item 3'),
)
// Tables
const dataTable = table(
thead(tr(th('Name'), th('Age'))),
tbody(
tr(td('Alice'), td('30')),
tr(td('Bob'), td('25')),
),
)
// SVG
const icon = svg(
{ width: '24', height: '24', viewBox: '0 0 24 24' },
circle({ cx: '12', cy: '12', r: '10', fill: 'blue' }),
path({ d: 'M12 6v6l4 2', stroke: 'white', 'stroke-width': '2' }),
)
```
---
## renderKeyedChildren(parent, children)
Efficiently updates a list of keyed components with minimal DOM operations. Use keys to preserve component identity across reorders.
```typescript
import {
component, div, ul, li, button, input,
renderKeyedChildren,
type Handle, type ComponentRef, type DomRef,
} from '@vanijs/vani'
type Todo = { id: string; text: string; done: boolean }
const TodoItem = component<{
id: string
getItem: (id: string) => Todo | undefined
onToggle: (id: string) => void
}>((props, handle) => {
return () => {
const item = props.getItem(props.id)
if (!item) return null
return li(
{ className: item.done ? 'done' : '' },
input({
type: 'checkbox',
checked: item.done,
onchange: () => props.onToggle(item.id),
}),
item.text,
)
}
})
const TodoList = component((_, handle: Handle) => {
let order = ['1', '2', '3']
const items = new Map([
['1', { id: '1', text: 'Learn Vani', done: false }],
['2', { id: '2', text: 'Build app', done: false }],
['3', { id: '3', text: 'Ship it', done: false }],
])
const refs = new Map()
const listRef: DomRef = { current: null }
const getRef = (id: string) => {
let ref = refs.get(id)
if (!ref) {
ref = { current: null }
refs.set(id, ref)
}
return ref
}
const renderRows = () => {
if (!listRef.current) return
renderKeyedChildren(
listRef.current,
order.map((id) =>
TodoItem({
key: id, // Required for keyed diffing
ref: getRef(id),
id,
getItem: (id) => items.get(id),
onToggle: (id) => {
const item = items.get(id)
if (item) {
items.set(id, { ...item, done: !item.done })
refs.get(id)?.current?.update() // Update single row
}
},
}),
),
)
}
handle.onBeforeMount(() => {
queueMicrotask(renderRows)
})
const addItem = () => {
const id = String(Date.now())
items.set(id, { id, text: `New item ${id}`, done: false })
order = [...order, id]
renderRows()
}
const reverse = () => {
order = [...order].reverse()
renderRows()
}
return () =>
div(
ul({ ref: listRef }),
button({ onclick: addItem }, 'Add'),
button({ onclick: reverse }, 'Reverse'),
)
})
```
---
## startTransition(fn), batch(fn)
Schedule non-urgent updates with `startTransition` to keep the UI responsive. Use `batch` to coalesce multiple updates into a single flush.
```typescript
import { component, div, button, input, startTransition, batch, type Handle } from '@vanijs/vani'
const SearchableList = component((_, handle: Handle) => {
let query = ''
let items = Array.from({ length: 10000 }, (_, i) => `Item ${i}`)
let filtered = items
const filterItems = () => {
filtered = query ? items.filter((item) => item.toLowerCase().includes(query.toLowerCase())) : items
}
return () =>
div(
input({
type: 'search',
placeholder: 'Search...',
oninput: (e) => {
query = (e.target as HTMLInputElement).value
// Non-urgent: defer expensive filtering
startTransition(() => {
filterItems()
handle.update()
})
},
}),
div(`Showing ${filtered.length} items`),
// ... render filtered items
)
})
// Batch multiple state changes
const MultiUpdate = component((_, handle: Handle) => {
let a = 0
let b = 0
let c = 0
const updateAll = () => {
batch(() => {
a += 1
b += 2
c += 3
handle.update() // Only one flush scheduled
})
}
return () =>
div(
div(`a: ${a}, b: ${b}, c: ${c}`),
button({ onclick: updateAll }, 'Update All'),
)
})
```
---
## classNames(...classes)
Utility for composing CSS class names from strings, arrays, and objects with boolean conditions.
```typescript
import { component, div, button, classNames } from '@vanijs/vani'
const StyledButton = component<{
variant: 'primary' | 'secondary'
disabled?: boolean
size?: 'sm' | 'md' | 'lg'
}>((props) => {
return () =>
button(
{
className: classNames(
'btn',
`btn-${props.variant}`,
props.size && `btn-${props.size}`,
{ 'btn-disabled': props.disabled },
['rounded', 'shadow'],
),
disabled: props.disabled,
},
'Click me',
)
})
// Usage examples
const examples = div(
StyledButton({ variant: 'primary' }),
StyledButton({ variant: 'secondary', disabled: true }),
StyledButton({ variant: 'primary', size: 'lg' }),
)
// classNames output examples:
classNames('a', 'b') // 'a b'
classNames('a', null, 'b') // 'a b'
classNames('a', { active: true, hidden: false }) // 'a active'
classNames(['a', 'b'], 'c') // 'a b c'
```
---
## Async Components with Fallbacks
Components can return a Promise of a render function. Use `fallback` to show loading states and `clientOnly` to skip SSR.
```typescript
import { component, div, renderToDOM } from '@vanijs/vani'
// Async component with data fetching
const UserProfile = component<{ userId: string }>(async (props) => {
const response = await fetch(`/api/users/${props.userId}`)
const user = await response.json()
return () =>
div(
{ className: 'profile' },
div({ className: 'name' }, user.name),
div({ className: 'email' }, user.email),
)
})
// Component that uses async component with fallback
const App = component(() => {
return () =>
div(
UserProfile({
userId: '123',
fallback: () => div({ className: 'loading' }, 'Loading profile...'),
}),
)
})
// Client-only component (skipped during SSR)
const BrowserOnlyWidget = component(() => {
return () => div(`Window width: ${window.innerWidth}px`)
})
const Page = component(() => {
return () =>
div(
div('Server-rendered content'),
BrowserOnlyWidget({
clientOnly: true,
fallback: () => div('Loading widget...'),
}),
)
})
```
---
## JSX Mode
Vani supports JSX with the optional JSX runtime. Configure TypeScript to use `@vanijs/vani` as the JSX import source.
```typescript
// tsconfig.json
// {
// "compilerOptions": {
// "jsx": "react-jsx",
// "jsxImportSource": "@vanijs/vani"
// }
// }
import { component, renderToDOM, type Handle } from '@vanijs/vani'
// JSX component
const Counter = component((_, handle: Handle) => {
let count = 0
return () => (
Count: {count}
)
})
// Mixed JSX and JS-first
import * as h from '@vanijs/vani/html'
const MixedComponent = component(() => {
return () =>
h.div(
'Using JS-first helpers with',
JSX children,
)
})
renderToDOM(, document.getElementById('app')!)
```
---
## Refs (DomRef and ComponentRef)
Access DOM elements with `DomRef` and component handles with `ComponentRef` for imperative operations.
```typescript
import {
component, div, input, button,
type DomRef, type ComponentRef, type Handle,
} from '@vanijs/vani'
const FocusableInput = component((_, handle: Handle) => {
const inputRef: DomRef = { current: null }
const focus = () => inputRef.current?.focus()
const clear = () => {
if (inputRef.current) {
inputRef.current.value = ''
inputRef.current.focus()
}
}
return () =>
div(
input({ ref: inputRef, type: 'text', placeholder: 'Type here...' }),
button({ onclick: focus }, 'Focus'),
button({ onclick: clear }, 'Clear'),
)
})
// Component ref for parent-child communication
const Child = component<{ label: string }>((props, handle) => {
let value = 0
return () =>
div(
`${props.label}: ${value}`,
button({
onclick: () => {
value += 1
handle.update()
},
}, '+'),
)
})
const Parent = component(() => {
const childRef: ComponentRef = { current: null }
const refreshChild = () => {
// Update child without re-rendering parent
childRef.current?.update()
}
const disposeChild = () => {
childRef.current?.dispose()
}
return () =>
div(
Child({ ref: childRef, label: 'Counter' }),
button({ onclick: refreshChild }, 'Refresh Child'),
button({ onclick: disposeChild }, 'Remove Child'),
)
})
```
---
## renderSvgString(svg, options)
Renders an SVG string to a VNode with optional size and class overrides. Useful for integrating SVG icons.
```typescript
import { component, div, renderSvgString } from '@vanijs/vani'
// Raw SVG string (e.g., from lucide-static or other icon libraries)
const heartSvg = ``
const IconDemo = component(() => {
return () =>
div(
{ className: 'icons' },
// Basic usage
renderSvgString(heartSvg),
// With size override
renderSvgString(heartSvg, { size: 32 }),
// With className
renderSvgString(heartSvg, { className: 'text-red-500' }),
// With custom attributes
renderSvgString(heartSvg, {
size: 24,
className: 'icon-heart',
attributes: {
'aria-hidden': true,
fill: 'red',
},
}),
)
})
// With Vite plugin (vite-plugin-vani-svg)
// import HeartIcon from 'lucide-static/icons/heart.svg?vani'
// const icon = HeartIcon({ size: 16, className: 'h-4 w-4' })
```
---
## fragment(...children)
Creates a fragment node for returning multiple siblings without a wrapper element.
```typescript
import { component, div, fragment, h1, p, renderToDOM } from '@vanijs/vani'
const MultipleElements = component(() => {
return () =>
fragment(
h1('Title'),
p('First paragraph'),
p('Second paragraph'),
)
})
const ConditionalFragment = component<{ showExtra: boolean }>((props) => {
return () =>
div(
'Always shown',
props.showExtra
? fragment(
div('Extra content 1'),
div('Extra content 2'),
)
: null,
)
})
```
---
## mount(component, props)
Low-level helper for embedding raw component functions directly in the render tree without using the component factory.
```typescript
import { component, mount, div, type Component } from '@vanijs/vani'
// Raw component function (not wrapped with component())
const RawFooter: Component<{ year: number }> = (props) => {
return () => div({ className: 'footer' }, `Copyright ${props.year}`)
}
const Page = component(() => {
return () =>
div(
div('Page content'),
// Embed raw component
mount(RawFooter, { year: 2024 }),
)
})
```
---
Vani excels at building performance-critical UIs, dashboard widgets, micro-frontends, and embeddable components where explicit control over rendering is essential. Its small runtime and lack of dependencies make it ideal for lightweight SPAs, server-rendered sites, and scenarios where you need to integrate with existing frameworks by mounting Vani components as islands within React, Vue, or server-rendered pages.
The framework's explicit update model eliminates hidden re-renders and makes performance predictable at any scale. By combining anchor-based DOM ownership with opt-in fine-grained reactivity, Vani provides the control of vanilla JS with the ergonomics of a modern component model. For data-heavy applications, use keyed lists with `renderKeyedChildren` and per-row refs to achieve O(1) updates on individual items while maintaining efficient list reconciliation.