Skip to content

Components for the HTML your server already sends.

Nemesia finds components in your markup, checks the elements and options they need, and cleans up after them. No virtual DOM, no templates, no build step required.

npm install nemesia

~5 KB gzipNo dependenciesTypeScript

html
<div data-nemesia="counter" data-option-initial="10">
	<button data-ref="button">+</button>
	<span data-ref="value"></span>
</div>
ts
export class Counter extends Component('counter') {
	button = this.ref.button('button')
	value = this.ref.element('value')
	count = this.option.number('initial')

	onMount() {
		this.render()
		this.on(this.button, 'click', () => {
			this.count += 1
			this.render()
		})
	}

	render() {
		this.value.textContent = String(this.count)
	}
}
Result

The problem

The tabs you've written a hundred times.

vanilla.js

js
document.addEventListener('DOMContentLoaded', () => {
	const buttons = document.querySelectorAll('.tab')
	const panels = document.querySelectorAll('.panel')

	buttons.forEach((button, i) => {
		button.addEventListener('click', () => {
			buttons.forEach(b => b.classList.remove('is-active'))
			panels.forEach(p => p.classList.remove('is-active'))
			button.classList.add('is-active')
			panels[i].classList.add('is-active')
		})
	})
})

// More tabs arrived via AJAX? Copy this into initTabs()
// and call it again… hoping the listeners don't double.

tabs.ts

ts
class Tabs extends Component('tabs') {
	tabs = this.ref.many.button('tab')
	panels = this.ref.many.element('panel')

	onMount() {
		this.on(this.tabs, 'click', (_event, _tab, index) => {
			this.show(index)
		})
	}

	show(index: number) {
		this.tabs.forEach((tab, i) => {
			tab.classList.toggle('is-active', i === index)
		})
		this.panels.forEach((panel, i) => {
			panel.classList.toggle('is-active', i === index)
		})
	}
}

Features

Structure, without a framework.

Behavior lives in the class

Components are found by data-nemesia. The elements and options they need are defined in the class.

Broken markup can't break the page

A missing, duplicated or mistyped element skips only that instance, with a warning pointing at the element.

Auto-typed

this.ref.button('save') is an HTMLButtonElement. this.option.number('delay') is a number.

Dynamic DOM

With observe: true, markup added by AJAX, htmx or a CMS mounts itself, and removed markup cleans up.

Automatic cleanup

Listeners registered with this.on are removed when a component is destroyed.

Components that work together

Build a page from small parts that know about each other, instead of one script that knows everything.

Try it

A little flower shop.

Plain server HTML, four small components. Add a few flowers, then load more from the “server”.

Lilac Dream

$4.50

1

Sunset Orange

$3.90

1

Snow Veil

$4.10

1

Midnight Blue

$5.20

1
    html
    <div class="shop" data-nemesia="shop">
    	<div class="shop__catalog">
    		<div class="shop__grid" data-ref="grid">
    			<article
    				class="flower"
    				data-nemesia="product-card"
    				data-option-name="Lilac Dream"
    				data-option-price="4.5"
    				style="--petal: #8e7cf0"
    			>
    				<svg class="flower__art" viewBox="0 0 32 32" aria-hidden="true"><use href="#nemesia-flower" /></svg>
    				<h3 class="flower__name">Lilac Dream</h3>
    				<p class="flower__price">$4.50</p>
    				<div class="flower__stepper">
    					<button type="button" data-ref="decrease" aria-label="Fewer">−</button>
    					<span data-ref="quantity">1</span>
    					<button type="button" data-ref="increase" aria-label="More">+</button>
    				</div>
    				<button type="button" class="flower__add" data-ref="add">Add to cart</button>
    			</article>
    			<article
    				class="flower"
    				data-nemesia="product-card"
    				data-option-name="Sunset Orange"
    				data-option-price="3.9"
    				style="--petal: #f0874a"
    			>
    				<svg class="flower__art" viewBox="0 0 32 32" aria-hidden="true"><use href="#nemesia-flower" /></svg>
    				<h3 class="flower__name">Sunset Orange</h3>
    				<p class="flower__price">$3.90</p>
    				<div class="flower__stepper">
    					<button type="button" data-ref="decrease" aria-label="Fewer">−</button>
    					<span data-ref="quantity">1</span>
    					<button type="button" data-ref="increase" aria-label="More">+</button>
    				</div>
    				<button type="button" class="flower__add" data-ref="add">Add to cart</button>
    			</article>
    			<article
    				class="flower"
    				data-nemesia="product-card"
    				data-option-name="Snow Veil"
    				data-option-price="4.1"
    				style="--petal: #c3b9ec"
    			>
    				<svg class="flower__art" viewBox="0 0 32 32" aria-hidden="true"><use href="#nemesia-flower" /></svg>
    				<h3 class="flower__name">Snow Veil</h3>
    				<p class="flower__price">$4.10</p>
    				<div class="flower__stepper">
    					<button type="button" data-ref="decrease" aria-label="Fewer">−</button>
    					<span data-ref="quantity">1</span>
    					<button type="button" data-ref="increase" aria-label="More">+</button>
    				</div>
    				<button type="button" class="flower__add" data-ref="add">Add to cart</button>
    			</article>
    			<article
    				class="flower"
    				data-nemesia="product-card"
    				data-option-name="Midnight Blue"
    				data-option-price="5.2"
    				style="--petal: #3a4bc4"
    			>
    				<svg class="flower__art" viewBox="0 0 32 32" aria-hidden="true"><use href="#nemesia-flower" /></svg>
    				<h3 class="flower__name">Midnight Blue</h3>
    				<p class="flower__price">$5.20</p>
    				<div class="flower__stepper">
    					<button type="button" data-ref="decrease" aria-label="Fewer">−</button>
    					<span data-ref="quantity">1</span>
    					<button type="button" data-ref="increase" aria-label="More">+</button>
    				</div>
    				<button type="button" class="flower__add" data-ref="add">Add to cart</button>
    			</article>
    		</div>
    		<button type="button" class="shop__more" data-ref="more">Load more</button>
    	</div>
    	<aside class="cart" data-nemesia="cart" data-empty>
    		<h3 class="cart__title">Cart</h3>
    		<ul class="cart__lines" data-ref="lines"></ul>
    		<p class="cart__empty">No flowers yet.</p>
    		<p class="cart__total">Total <span data-ref="total">$0.00</span></p>
    		<button type="button" class="cart__clear" data-ref="clear">Clear</button>
    	</aside>
    	<ul class="shop__log" data-ref="log" aria-live="polite"></ul>
    </div>
    ts
    import { Component } from 'nemesia'
    import { Cart } from './cart'
    import { ProductCard, type CartLine } from './product-card'
    import { fetchFlowers } from './server'
    import { Toast } from './toast'
    
    export class Shop extends Component('shop') {
    	grid = this.ref.element('grid')
    	more = this.ref.button('more')
    	log = this.ref.element('log')
    
    	private page = 0
    
    	onMount() {
    		this.write(`mounted ${this.findAll(ProductCard).length} product cards`)
    
    		this.on(this.root, 'product:add', (event: CustomEvent<CartLine>) => {
    			const { name, count } = event.detail
    			this.find(Cart)?.add(event.detail)
    			this.find(Toast, document.body)?.show(`${count} × ${name} added to cart`)
    			this.write(`product:add → cart.add(${name} × ${count})`)
    		})
    
    		this.on(this.more, 'click', () => this.loadMore())
    	}
    
    	private async loadMore() {
    		this.more.disabled = true
    		this.write('loading more flowers from the server…')
    
    		const html = await fetchFlowers(++this.page)
    		this.grid.insertAdjacentHTML('beforeend', html)
    		this.write('new cards arrived · mounted by the observer')
    
    		this.more.disabled = this.page >= 2
    	}
    
    	private write(line: string) {
    		const entry = document.createElement('li')
    		entry.textContent = line
    		this.log.append(entry)
    		while (this.log.children.length > 5) this.log.firstElementChild?.remove()
    	}
    }
    ts
    import { Component } from 'nemesia'
    
    export interface CartLine {
    	name: string
    	price: number
    	count: number
    }
    
    export class ProductCard extends Component('product-card') {
    	name = this.option.string('name')
    	price = this.option.number('price', { min: 0 })
    	quantity = this.ref.element('quantity')
    	decrease = this.ref.button('decrease')
    	increase = this.ref.button('increase')
    	add = this.ref.button('add')
    
    	private count = 1
    
    	onMount() {
    		this.on(this.decrease, 'click', () => this.setCount(this.count - 1))
    		this.on(this.increase, 'click', () => this.setCount(this.count + 1))
    		this.on(this.add, 'click', () => {
    			const detail: CartLine = { name: this.name, price: this.price, count: this.count }
    			this.root.dispatchEvent(new CustomEvent('product:add', { bubbles: true, detail }))
    		})
    	}
    
    	private setCount(count: number) {
    		this.count = Math.min(9, Math.max(1, count))
    		this.quantity.textContent = String(this.count)
    	}
    }
    ts
    import { Component } from 'nemesia'
    import type { CartLine } from './product-card'
    
    export class Cart extends Component('cart') {
    	lines = this.ref.element('lines')
    	total = this.ref.element('total')
    	clear = this.ref.button('clear')
    
    	private items = new Map<string, CartLine>()
    
    	onMount() {
    		this.on(this.clear, 'click', () => {
    			this.items.clear()
    			this.render()
    		})
    	}
    
    	add(line: CartLine) {
    		const count = (this.items.get(line.name)?.count ?? 0) + line.count
    		this.items.set(line.name, { ...line, count })
    		this.render()
    	}
    
    	private render() {
    		const rows = [...this.items.values()].map(line => {
    			const row = document.createElement('li')
    			row.textContent = `${line.count} × ${line.name}`
    			return row
    		})
    		const sum = [...this.items.values()].reduce((total, line) => total + line.price * line.count, 0)
    
    		this.lines.replaceChildren(...rows)
    		this.total.textContent = `$${sum.toFixed(2)}`
    		this.root.toggleAttribute('data-empty', this.items.size === 0)
    	}
    }
    ts
    import { DistributedComponent } from 'nemesia'
    
    export class Toast extends DistributedComponent('toast') {
    	private element = document.createElement('div')
    	private timer: number | undefined
    
    	onMount() {
    		this.element.className = 'toast'
    		this.element.setAttribute('role', 'status')
    		this.scope.append(this.element)
    	}
    
    	show(message: string) {
    		this.element.textContent = message
    		this.element.classList.add('is-visible')
    		window.clearTimeout(this.timer)
    		this.timer = window.setTimeout(() => this.element.classList.remove('is-visible'), 1800)
    	}
    
    	onDestroy() {
    		window.clearTimeout(this.timer)
    		this.element.remove()
    	}
    }

    Size

    Small enough to forget about.

    nemesia5.4 KB
    petite-vue7.3 KB
    Stimulus11.3 KB
    Alpine19.9 KB

    Published browser builds, minified and gzipped.

    FAQ

    Why not just…?

    Vue or React?
    They render the page. Nemesia does not: your server already did. If you need client-side templates, routing or reactive state, use them instead.
    Alpine?
    Alpine puts behavior into HTML attributes. Nemesia keeps it in JavaScript; your markup only defines the structure.
    Stimulus?
    The closest in spirit. Nemesia checks refs and options on mount, infers their types without extra declarations, and weighs about half as much.
    jQuery?
    It belongs to another era of the web: selectors, but no structure, no lifecycle, no cleanup.
    When is Nemesia the wrong choice?
    Single-page apps, or interfaces where most of the DOM is rendered on the client.
    Does it need a build step?
    No. A bundler works, and so does a single <script> tag.