nuclo
v0.2.13
Published
Lightweight reactive DOM library with global tag builders, SSR, hydration, and atomic CSS — no virtual DOM, no compiler.
Maintainers
Readme
nuclo
A lightweight, imperative DOM framework with explicit updates.
Build interactive UIs with plain functions, mutable JavaScript objects, and explicit update() calls. Nuclo is imperative: nothing re-renders until you call update(). No virtual DOM, no proxies, and no hidden state tracking.
import 'nuclo';
let count = 0;
const counter = div(
h1(() => `Count: ${count}`),
button('Increment', on('click', () => {
count++;
update();
}))
);
render(counter, document.body);Why nuclo?
- Explicit and Predictable – You control when updates happen with a simple
update()call - Direct DOM Manipulation – Work directly with the DOM, no virtual layer in between
- Tiny Footprint – ~10 KB gzipped, zero dependencies
- Global Tag Builders – Natural API with global functions for all HTML and SVG elements
- TypeScript-First – Full type definitions for all 175 HTML and SVG builders
- Targeted DOM Updates –
update()re-runs dynamic bindings and only touches DOM where values changed - Atomic Styling – Built-in
css()/createCss()with TypeScript autocomplete, theming, and SSR CSS collection - Server-Side Rendering –
renderToString()+hydrate()with a lightweight DOM polyfill
Installation
npm install nucloUsage
Simply import once to register all global functions:
import 'nuclo';
// Now use div(), update(), on(), list(), when(), render(), etc. globally
let count = 0;
const app = div(
h1(() => `Count: ${count}`),
button('Click', on('click', () => { count++; update(); }))
);
render(app, document.body);TypeScript Setup
Add to your tsconfig.json:
{
"compilerOptions": {
"types": ["nuclo/types"]
}
}Or in your vite-env.d.ts:
/// <reference types="nuclo/types" />Quick Examples
Counter
import 'nuclo';
let count = 0;
const app = div(
h1(() => `Count: ${count}`),
button('Increment', on('click', () => { count++; update(); })),
button('Reset', on('click', () => { count = 0; update(); }))
);
render(app, document.body);Todo List
import 'nuclo';
type Todo = { id: number; text: string; done: boolean };
let todos: Todo[] = [];
let nextId = 1;
let inputValue = '';
function addTodo() {
if (!inputValue.trim()) return;
todos.push({ id: nextId++, text: inputValue, done: false });
inputValue = '';
update();
}
const app = div(
{ className: 'todo-app' },
// Input
div(
input({ value: () => inputValue },
on('input', e => { inputValue = e.target.value; update(); }),
on('keydown', e => e.key === 'Enter' && addTodo())
),
button('Add', on('click', addTodo))
),
// List
when(() => todos.length > 0,
list(() => todos, (todo) =>
div(
{ className: () => todo.done ? 'done' : '' },
input({ type: 'checkbox', checked: () => todo.done },
on('change', () => { todo.done = !todo.done; update(); })
),
span(() => todo.text),
button('×', on('click', () => {
todos = todos.filter(t => t.id !== todo.id);
update();
}))
)
)
).else(
p('No todos yet!')
)
);
render(app, document.body);Real-time Search Filter
import 'nuclo';
const users = [
{ id: 1, name: 'Alice Johnson', email: '[email protected]' },
{ id: 2, name: 'Bob Smith', email: '[email protected]' },
{ id: 3, name: 'Charlie Brown', email: '[email protected]' }
];
let searchQuery = '';
function filteredUsers() {
const q = searchQuery.toLowerCase();
return users.filter(u =>
u.name.toLowerCase().includes(q) ||
u.email.toLowerCase().includes(q)
);
}
const app = div(
h1('User Directory'),
input(
{
type: 'search',
placeholder: 'Search users...',
value: () => searchQuery
},
on('input', e => {
searchQuery = e.target.value;
update();
})
),
when(() => filteredUsers().length > 0,
list(() => filteredUsers(), user =>
div(
{ className: 'user-card' },
h3(user.name),
p(user.email)
)
)
).else(
p(() => `No users found for "${searchQuery}"`)
)
);
render(app, document.body);Loading States & Async
import 'nuclo';
type Product = { id: number; title: string; category: string };
type State = { status: 'idle' | 'loading' | 'error'; products: Product[]; error?: string };
const state: State = { status: 'idle', products: [] };
let searchQuery = 'phone';
async function fetchProducts() {
if (!searchQuery.trim()) return;
state.status = 'loading';
update();
try {
const response = await fetch(`https://dummyjson.com/products/search?q=${searchQuery}`);
const data = await response.json();
state.products = data.products;
state.status = 'idle';
} catch (err) {
state.status = 'error';
state.error = err.message;
}
update();
}
const app = div(
div(
input(
{
type: 'search',
placeholder: 'Search products...',
value: () => searchQuery
},
on('input', e => {
searchQuery = e.target.value;
update();
}),
on('keydown', e => e.key === 'Enter' && fetchProducts())
),
button('Search', on('click', fetchProducts))
),
when(() => state.status === 'loading',
div('Loading...')
).when(() => state.status === 'error',
div({ className: 'error' }, () => `Error: ${state.error}`)
).when(() => state.products.length > 0,
list(() => state.products, product =>
div(
{ className: 'product-card' },
h3(product.title),
p(() => `Category: ${product.category}`)
)
)
).else(
div('Click search to load products')
)
);
render(app, document.body);Core Concepts
1. Explicit Updates
nuclo doesn't auto-detect changes. You call update() when ready:
let name = 'World';
// Mutate freely
name = 'Alice';
name = name.toUpperCase();
// Update once when ready
update();Advantages of explicit update():
- Performance: Batch multiple mutations into a single update cycle
- Control: You decide exactly when the UI should refresh
- Predictability: Zero surprise re-renders, explicit update flow
- Simplicity: No proxies, no dependency graphs, just objects and functions
- Debugging: Set a breakpoint at
update()to trace all state changes
// Example: Batch updates for better performance
items.push(item1);
items.push(item2);
items.sort();
user.name = 'Alice';
update(); // One update for all changes
// vs. automatic tracking (hypothetical)
items.push(item1); // triggers update
items.push(item2); // triggers update
items.sort(); // triggers update
user.name = 'Alice'; // triggers update
// 4 updates instead of 1!2. Dynamic Functions
Zero-arg functions become dynamic bindings that re-run when you call update():
let count = 0;
div(
() => `Count: ${count}`, // Updates when update() is called
{ title: () => `Current: ${count}` } // Attributes too
)3. Conditional Rendering with when
First matching condition wins:
when(() => user.isAdmin,
div('Admin Panel')
).when(() => user.isLoggedIn,
div('User Dashboard')
).else(
div('Please log in')
)DOM is preserved if the active branch doesn't change.
4. List Synchronization
Lists use object identity (not keys) to track items:
list(() => items, (item, index) =>
div(() => `${index}: ${item.name}`)
)Mutate the array (push, splice, reverse), then call update(). Elements are reused if the item reference is the same.
API Reference
Core Functions
update()
Runs one synchronous update pass across every dynamic binding. Call this after mutating state:
count++;
items.push(newItem);
update();list(provider, renderer)
Synchronizes an array to DOM elements:
list(
() => items, // Provider function
(item, index) => div( // Renderer
() => `${index}: ${item.name}`
)
)Items are tracked by object identity. Mutate the array and call update() to sync.
when(condition, ...content)
Conditional rendering with chaining:
when(() => count > 10,
div('High')
).when(() => count > 0,
div('Low')
).else(
div('Zero')
)First matching condition wins. DOM is preserved if the active branch doesn't change.
on(event, handler, options?)
Attach event listeners:
button('Click me',
on('click', () => console.log('clicked')),
on('mouseenter', handleHover, { passive: true })
)scope(...ids)
Registers an element as a named update root, so update("id") re-runs only the dynamic bindings contained within it instead of the whole page:
div(
scope('cart'),
span(() => `Items: ${cartItems.length}`)
)
cartItems.push(nextItem);
update('cart'); // only updates runtimes inside the "cart" scopeTag Builders
All HTML and SVG tags are available globally:
div(), span(), button(), input(), h1(), p(), ul(), li()
svgSvg(), circleSvg(), pathSvg(), rectSvg(), gSvg()
// ... 175 HTML and SVG builders totalAttributes
Pass attributes as objects:
div('Hello', {
className: 'container',
id: 'main',
'data-test': 'value',
style: { color: 'red', fontSize: '16px' }
})Dynamic attributes use functions:
div({
className: () => isActive ? 'active' : '',
disabled: () => !isValid,
style: () => ({ opacity: isVisible ? 1 : 0 })
})Styling
Nuclo includes an atomic CSS-in-TS engine. css() is available globally after import 'nuclo'. For theming and responsive breakpoints use createCss():
import 'nuclo';
const { css } = createCss({
colors: { primary: '#14b8a6' },
screens: { md: '(min-width: 768px)' },
});
const card = css({
p: 16,
rounded: 8,
md: { p: 24 },
hover: { borderColor: 'primary' },
});
const el = div(card, 'Hello');css() returns an object with a className key that can be passed directly to any tag builder. Global styles and keyframe animations use globalStyle() and keyframes().
Composing with cx()
cx() composes styles with exact, last-wins conflict resolution — because the engine knows which declaration every class it minted represents, a later color atom drops the earlier one (no tailwind-merge guessing). It accepts results, raw class strings, falsy values, and nested arrays:
cx(base, isActive && activeStyle); // conditional
cx([base, isActive && activeStyle], extra); // arrays are flattenedTyped variants with variants()
variants() turns a base style plus named variant groups into a strongly-typed recipe. Variant names and values are inferred, so selecting an unknown one is a compile error, and true/false value groups are selected with real booleans. Every variant compiles to atomic classes once at definition time; a call is a cached lookup plus a cx() merge.
import 'nuclo';
const { variants } = createCss({
colors: { primary: '#6366f1', danger: '#ef4444' },
});
const button = variants({
base: { rounded: 8, weight: 600, cursor: 'pointer' },
variants: {
intent: {
primary: { bg: 'primary', color: '#fff' },
danger: { bg: 'danger', color: '#fff' },
},
size: {
sm: { px: 10, py: 6, text: 14 },
lg: { px: 18, py: 12, text: 16 },
},
block: { true: { display: 'block', w: '100%' } },
},
defaultVariants: { intent: 'primary', size: 'sm' },
compoundVariants: [
{ intent: 'danger', size: 'lg', css: { weight: 700 } },
],
});
div(button({ intent: 'danger', size: 'lg' }), 'Delete'); // StyleResult — drop-in attributes
div(button(), 'Save'); // uses defaultVariants
button({ intent: 'ghost' }); // ✗ compile error: "ghost" is not a defined intentThe result is a StyleResult, so it drops straight into any tag builder or composes further with cx(button({ size: 'lg' }), extraClass).
Server-Side Rendering
Import nuclo/polyfill before nuclo in your Node.js server entry, then use renderToString() and getCssText() from nuclo/ssr:
import 'nuclo/polyfill';
import 'nuclo';
import { renderToString, getCssText } from 'nuclo/ssr';
import { App } from './app.ts';
const html = renderToString(App());
const styles = getCssText();
const page = `<!doctype html><html><head><style>${styles}</style></head>
<body><div id="app">${html}</div></body></html>`;On the client, call hydrate() instead of render() to attach Nuclo runtimes to the existing markup without re-creating DOM nodes.
nuclo/ssr also exports renderManyToString(inputs) for rendering a batch of trees at once, and renderToStringWithContainer(input, containerTag?, containerAttrs?) to wrap the output in a container element without a second serialization pass.
Best Practices
Batch Updates
Make multiple changes, then update once:
// Efficient: One update for all changes
items.push(item1);
items.push(item2);
items.sort();
update();
// Works but inefficient: Multiple updates
items.push(item1);
update();
items.push(item2);
update();Object Identity for Lists
Lists track items by reference. Mutate objects in place:
// Good: Mutate the object
todos[0].done = true;
update();
// Avoid: Creates new object, DOM element recreated
todos[0] = { ...todos[0], done: true };
update();Use .else() for Clarity
Even if not initially needed:
when(() => isLoading,
div('Loading...')
).else(
div('Ready') // Clear intent
)Advanced Patterns
Nested Structures
Combine when and list:
when(() => user.isLoggedIn,
div(
h1(() => `Welcome, ${user.name}`),
list(() => user.notifications, n =>
div(n.message, { className: () => n.read ? 'read' : 'unread' })
)
)
).else(
div('Please log in')
)Component-like Functions
function UserCard(user: User) {
return div(
{ className: 'user-card' },
img({ src: user.avatar }),
h3(user.name),
p(user.bio)
);
}
list(() => users, user => UserCard(user))Computed Values
function activeCount() {
return todos.filter(t => !t.done).length;
}
div(
() => `${activeCount()} remaining`
)Performance
- No virtual DOM diffing – Direct DOM manipulation for maximum efficiency
- Fine-grained updates – Only updates what changed, nothing more
- Element reuse – Lists intelligently reuse DOM elements when items move
- Branch preservation – Conditional branches persist until conditions change
For high-frequency updates (animations, game loops), batch mutations before calling update().
Debugging
Inspect Markers
Open DevTools to see comment markers that help you understand the structure:
<!-- when-start-1 -->
<div>Content</div>
<!-- when-end -->
<!-- list-start-2 -->
<div>Item 1</div>
<div>Item 2</div>
<!-- list-end -->These markers identify conditional and list boundaries in the DOM.
Common Issues
Content not updating?
- Ensure you're calling
update()after state changes - Verify your dynamic functions are returning the expected values — functions that return
null/undefinedrender as empty text and fill in on the nextupdate()
List items not reusing elements?
- Keep object references stable (mutate instead of replacing)
- Avoid creating new objects when updating properties
Gotchas
Parameter count decides what a function modifier means.
A modifier with zero declared parameters is treated as reactive text (or a reactive cn() className) and is called with no arguments. A modifier with one or more declared parameters is a node-modifier function receiving (element, index). Default and rest parameters don't count as declared parameters (fn.length is 0), so these are all treated as reactive text, not node modifiers:
div((el = fallback) => el.id); // reactive text — el is undefined!
div((...args) => args[0]); // reactive text — args is empty!
div((el) => el.id = "x"); // node modifier — el is the element ✓If a modifier needs the element, declare it as a plain required parameter.
Detached nodes lose reactivity.
Reactive registrations (text, attributes, list(), when()) are pruned for any node that is disconnected from the DOM when update() runs. Re-attaching the node later does not restore them. Keep-alive and portal-style patterns should re-render content after re-attaching instead of moving live subtrees between updates.
Documentation
Full documentation is available at https://nuclo.dev/
Author
Created by Danilo Celestino de Castro
License
MIT License - see LICENSE.md for details.
This library is free and open source. When using nuclo, please include attribution in your documentation or application.
TL;DR: Use it freely, give credit where it's due!
