npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

nuclo

v0.2.13

Published

Lightweight reactive DOM library with global tag builders, SSR, hydration, and atomic CSS — no virtual DOM, no compiler.

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 Updatesupdate() 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 RenderingrenderToString() + hydrate() with a lightweight DOM polyfill

Installation

npm install nuclo

Usage

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" scope

Tag 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 total

Attributes

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 flattened

Typed 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 intent

The 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/undefined render as empty text and fill in on the next update()

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!