Behavior lives in the class
Components are found by data-nemesia. The elements and options they need are defined in the class.
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<div data-nemesia="counter" data-option-initial="10">
<button data-ref="button">+</button>
<span data-ref="value"></span>
</div>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)
}
}The problem
vanilla.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
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
Components are found by data-nemesia. The elements and options they need are defined in the class.
A missing, duplicated or mistyped element skips only that instance, with a warning pointing at the element.
this.ref.button('save') is an HTMLButtonElement. this.option.number('delay') is a number.
With observe: true, markup added by AJAX, htmx or a CMS mounts itself, and removed markup cleans up.
Listeners registered with this.on are removed when a component is destroyed.
Build a page from small parts that know about each other, instead of one script that knows everything.
Try it
Plain server HTML, four small components. Add a few flowers, then load more from the “server”.
$4.50
$3.90
$4.10
$5.20
<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>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()
}
}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)
}
}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)
}
}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
Published browser builds, minified and gzipped.
FAQ
<script> tag.