SniceSnice

ESM + Bundler

Install the package and use with Vite, esbuild, or Rollup. Full framework with decorators, routing, and controllers.

# Install npm install snice # Or scaffold a full project npx snice create-app my-app import { element, property, render, html } from 'snice'; @element('my-counter') class MyCounter extends HTMLElement { @property({ type: Number }) count = 0; @render() template() { return html` <button @click=${() => this.count++}> ${this.count} </button> `; } }

Best for: Full applications, SPAs, projects using a bundler.

CDN

Load the Snice runtime once, then add CDN component builds. No bundler needed — just <script> tags.

<!-- Load runtime once --> <script src="snice-runtime.min.js"></script> <!-- Add components --> <script src="snice-button.min.js"></script> <script src="snice-input.min.js"></script> <script src="snice-modal.min.js"></script>

The runtime is ~34KB gzipped. Each component adds ~1–123KB. No duplication regardless of how many components you use.

Best for: CMS pages, WordPress, static sites, landing pages, dashboards.

React Wrappers

First-class React adapters with prop mapping, event callbacks, ref forwarding, and form integration. React 17, 18, and 19.

import { Button, Input, Modal } from 'snice/react'; function App() { const [email, setEmail] = useState(''); return ( <> <Input label="Email" value={email} onChange={(e) => setEmail(e.detail.value)} /> <Button variant="primary" onClick={handleSubmit}> Submit </Button> </> ); }

Best for: React applications that want Snice UI components with native React DX.

The bare minimum

An element needs a tag name and a template. That's it.

import { element, render, html } from 'snice'; @element('task-item') class TaskItem extends HTMLElement { @render() template() { return html`<span>Buy groceries</span>`; } }

<task-item></task-item>

Styles

Styles are applied once to the managed render root. Shadow-root styles are encapsulated; light-root styles follow normal CSS scoping.

@styles() css() { return css` :host { display: flex; align-items: center; padding: 0.5rem; } :host([done]) span { text-decoration: line-through; opacity: 0.5; } `; }

Use :host to style the element itself. :host([done]) targets when the done attribute is present.

Properties

Public properties accept HTML attributes, reflect writes by default, and trigger differential re-renders.

@element('task-item') class TaskItem extends HTMLElement { @property() label = ''; @property({ type: Boolean }) done = false; @render() template() { return html` <span>${this.label}</span> `; } } @element('task-item') class TaskItem extends HTMLElement { @property() label = ''; @property({ type: Boolean }) done = false; @query('.label') $label!: HTMLElement; @render({ once: true }) template() { return html` <span class="label">${this.label}</span> `; } @watch('label') labelChanged() { if (this.$label) this.$label.textContent = this.label; } }

<task-item label="Buy groceries"></task-item>

type converts strings arriving from attributes. Direct JavaScript assignments keep their original type and object identity. Use reflect: false for attribute input without property-to-attribute output, or attribute: false for a JavaScript-only public property.

Internal state

@state() declares reactive implementation data. It triggers rendering but never creates or reflects an HTML attribute.

@state() editing = false; @state() selectedId = null; @render() template() { return html` <span class="name">${this.label}</span> <button>${this.editing ? 'Done' : 'Edit'}</button> `; } this.editing = true; this.selectedId = task.id; @state() editing = false; @state() selectedId = null; @query('.name') $name!: HTMLElement; @query('button') $button!: HTMLButtonElement; @render({ once: true }) template() { return html` <span class="name">${this.label}</span> <button>${this.editing ? 'Done' : 'Edit'}</button> `; } @watch('editing') editingChanged() { if (!this.$button) return; this.$button.textContent = this.editing ? 'Done' : 'Edit'; } this.editing = true; this.selectedId = task.id;

Use @property() for public input and @state() for state owned entirely by the component. Multiple writes in one turn are batched into one render.

Under { once: true } the template still renders once with correct values, then @watch fires synchronously on each write instead of a re-render.

Deep state

Add deep: true when nested object and collection mutations should invalidate the view without replacing the top-level field.

@state({ deep: true }) model = { tasks: [], selected: new Set(), flags: new Map() }; // Each write is observed and batched into one render. this.model.tasks.push(task); this.model.selected.add(task.id);

Deep state uses native Proxy and Reflect, including cycles, arrays, Map, and Set. It targets modern browsers; ordinary state does not require deep proxies.

Binding channels

Choose the channel that matches what the browser consumes: text, an attribute, a JavaScript property, boolean presence, a listener, one class, or one CSS property.

return html` <button title=${this.label} data-label="task: ${this.label}" .task=${this.task} ?disabled=${this.saving} @click=${this.save} class:selected=${this.selected} style:--task-accent=${this.accent} > ${this.label} </button> `;

Use .property for objects, arrays, functions, native form state, and any value whose JavaScript type or identity matters. Use ?attribute when only attribute presence matters.

Conditionals

Virtual control-flow tags add no wrappers and preserve each branch's DOM identity while it is inactive.

@render() template() { return html` <if ${this.loading}> <snice-spinner></snice-spinner> <else-if ${this.error}> <p role="alert">${this.error.message}</p> </else-if> <else> <task-list .items=${this.items}></task-list> </else> </if> <case ${this.status}> <when value="idle">Idle</when> <when ${OFFLINE_STATE}>Offline</when> <default>Working</default> </case> `; }

Static <when value="..."> matches strings. A dynamic <when ${value}> uses Object.is for typed values and object identity.

Keyed lists

repeat() makes identity explicit, moves existing DOM on reorder, rejects duplicate keys, and renders an empty state without a wrapper.

@element('task-list') class TaskList extends HTMLElement { @property({ type: Array }) items = []; @render() template() { return html` ${repeat(this.items, { key: item => item.id, render: item => html` <task-item label=${item.label} ?done=${item.done} @status-changed=${(e) => this.update(e.detail)} ></task-item> `, empty: () => html`<p>No tasks</p>` })} `; } }

Async content

Promises and async iterables render directly in node expressions. Replacing a source ignores stale results; disconnecting stops active iterator consumption.

userView = fetch(this.url) .then(response => response.json()) .then(user => html`<user-card .user=${user}></user-card>`) .catch(error => html`<p role="alert">${error.message}</p>`); return html`${this.userView}`;

Promise cancellation remains explicit: own an AbortController in the component lifecycle when a fetch must be aborted.

Named spreads

Prefer direct bindings when the keys are known. Use a named spread when a wrapper, plugin, or generated view receives a dynamic bag that must be forwarded through one explicit DOM channel.

@property({ attribute: false }) forwardedProps = {}; @property({ attribute: false }) accessibility = {}; @property({ attribute: false }) forwardedListeners = {}; return html` <input ...props=${this.forwardedProps} ...attrs=${this.accessibility} ...events=${this.forwardedListeners} > `;

...props preserves JavaScript values, ...attrs manages attributes, and ...events manages listeners. Keys omitted from the next bag are removed or reset.

Form controls

Keep both directions explicit: bind state to the native property and handle the browser event that updates state.

@state() query = ''; @state() accepted = false; return html` <input .value=${this.query} @input=${this.updateQuery}> <input type="checkbox" .checked=${this.accepted} @change=${this.updateAccepted}> `; @state() query = ''; @state() accepted = false; @query('.query') $query!: HTMLInputElement; @query('.accepted') $accepted!: HTMLInputElement; @render({ once: true }) template() { return html` <input class="query" .value=${this.query}> <input class="accepted" type="checkbox" .checked=${this.accepted}> `; } @on('input', '.query') updateQuery(e) { this.query = e.target.value; } @on('change', '.accepted') updateAccepted(e) { this.accepted = e.target.checked; } @watch('query') queryChanged() { // Only write back when something other than typing changed it if (this.$query && this.$query.value !== this.query) { this.$query.value = this.query; } }

Use input for text as it changes and change for committed choices such as checkboxes, selects, and files. Parsing, validation, and IME behavior stay visible in the handler.

The declarative binding re-asserts the property on every render; the imperative version guards the write so it never fights the caret while typing.

Inline handlers

Bind the handler in the template, or attach it with @on and drive the DOM yourself. Both stay on the framework — neither reaches for addEventListener.

@render() template() { return html` <input type="checkbox" .checked=${this.done} @change=${this.toggle} /> <span>${this.label}</span> `; } toggle() { this.done = !this.done; } @query('input') $checkbox!: HTMLInputElement; @query('span') $label!: HTMLElement; @render({ once: true }) template() { return html` <input type="checkbox" .checked=${this.done} /> <span>${this.label}</span> `; } @on('change', 'input') toggle() { this.done = !this.done; } @watch('done') doneChanged() { if (this.$checkbox) this.$checkbox.checked = this.done; }

.checked=${val} sets the property (not attribute). ?disabled=${bool} toggles an attribute.

@on delegates from the host and is cleaned up on disconnect, so the imperative path never manages listeners by hand.

Emitting events

Use @dispatch to fire custom events. The return value becomes event.detail.

@dispatch('status-changed') toggle() { this.done = !this.done; return { label: this.label, done: this.done }; }

Parents listen with @status-changed=${handler} in templates, @on('status-changed') as a decorator, or addEventListener('status-changed', ...).

Event delegation

Handle events declaratively with the @on decorator instead of inline handlers.

@on('click', '.delete') handleDelete(e) { const id = e.target.dataset.id; this.items = this.items.filter(i => i.id !== id); } @on('input', 'input.search', { debounce: 300 }) handleSearch(e) { this.filter = e.target.value; }

Delegation works on shadow DOM elements. Built-in debounce and throttle options.

Keyboard shortcuts

Filter events by key or key combo directly in the template.

@render() template() { return html` <input placeholder="New task..." @keydown:Enter|prevent=${this.addTask} @keydown:Escape|stop=${this.clear} /> `; }

Combos: @keydown:ctrl+s, @keydown:shift+Enter. Compose |prevent, |stop, |immediate, |once, |capture, |passive, and |self.

Ready

@ready() runs after the element is connected and its first render has committed, so queries can access the rendered DOM.

@ready() onMount() { this.checkbox.focus(); this.startMeasurement(); }

Watch changes

Run logic when a specific property changes.

@watch('done') onDoneChanged(oldVal, newVal) { console.log(`Task ${this.label}: ${newVal ? 'completed' : 'reopened'}`); }

Query the DOM

Declare typed access to one or every matching element in the managed render root.

@query('input') checkbox!: HTMLInputElement; @queryAll('[data-row]') rows!: HTMLElement[]; focusCheckbox() { this.checkbox.focus(); }

Snice components export their interfaces from .types.ts, so you can type queries:

import type { SniceInput } from 'snice/components/input'; @query('snice-input') input!: SniceInput;

Dispose

@dispose() runs when the element disconnects. Release resources that are not already managed by Snice lifecycle decorators.

@dispose() onUnmount() { clearInterval(this.interval); this.subscription.unsubscribe(); }

Controllers

Swap behavior on any element without changing its code. A controller attaches logic from the outside.

Visual behavior belongs in elements. Application behavior specific to a set of elements belongs in a controller. Element orchestration belongs in pages. Do not attach another controller to a page host. A host-free reusable function may stay a plain module wherever the project keeps it.

import { controller, on } from 'snice'; import { navigate } from '../router'; @controller('weather') class WeatherController { element = null; attach(el) { this.element = el; } @on('click') viewDetails() { navigate('/weather'); } detach() {} }

Attach the class directly — import it, bind it, done.

@element('weather-dashboard') class WeatherDashboard extends HTMLElement { @render() template() { return html` <stat-card controller=${WeatherController}></stat-card> <!-- swap behavior: same card, different logic --> <stat-card controller=${StocksController}></stat-card> <!-- works on plain HTML elements too --> <div controller=${WeatherController}></div> `; } }

Binding the same class again is a no-op; binding a different one detaches the old controller first.

The decorator name is reflected in the DOM as controller="weather" for DevTools. The class reference still owns the attachment; this is a diagnostic marker and cannot double-attach through the registry.

In raw HTML, attach by the registered name instead: <stat-card controller="weather"></stat-card>

Daemons

A daemon is an ordinary app-owned object with state and a lifecycle. Construct it yourself, provide the instance through app context, and let elements/controllers communicate with its address instead of importing its class.

@daemon class SessionDaemon { session = null; @respond('get-session') getSession() { return this.session; } @on('set-session') setSession(event) { this.session = event.detail; this.changed(); } @dispatch('session-changed') changed() { return this.session; } } const session = new SessionDaemon(); const release = provideContext(appRoot, { daemons: { session } }); // Consumer: the string is a context address, not a class import @request('get-session', { daemon: 'session' }) async *loadSession(): Response<Session | null> { return yield {}; } @dispatch('set-session', { daemon: 'session' }) setSession(session) { return session; } @on('session-changed', { daemon: 'session' }) sessionChanged(event) { this.session = event.detail; }

@request/@respond handles one reply; @dispatch/@on handles notifications. There is no singleton, implicit construction, or global registry. Provide context before elements connect or controllers attach, and call release() during teardown.

Request / Respond

The element says what it needs. The controller decides how to get it. Neither imports the other — they meet on a named channel.

// Element: ask for data, wait for it, render it @element('product-card') class ProductCard extends HTMLElement { @property() productId = ''; @property() name = ''; @property() price = ''; @request('fetch-product') async *loadProduct() { const product = await (yield { id: this.productId }); this.name = product.name; this.price = product.price; } @render() template() { return html` <h3>${this.name || 'Loading…'}</h3> <p>${this.price}</p> <button @click=${this.loadProduct}>Refresh</button> `; } } // Controller: answer the channel @controller('product-api') class ProductApi { async attach() {} async detach() {} @respond('fetch-product') async fetchProduct({ id }) { return fetch(`/api/products/${id}`).then(r => r.json()); } }

One yield per call: it dispatches the payload, the responder returns data, and await resolves with it.

Any controller that responds to 'fetch-product' works — a real API in production, a fixture in tests. The element never changes.

Observers

Watch the DOM and the viewport without wiring observers by hand. Setup and teardown are automatic.

@element('media-panel') class MediaPanel extends HTMLElement { @state() columns = 1; @state() visible = false; // Fires when slotted children are added or removed @observe('mutation:childList', '.items') itemsChanged(records) { this.count = this.querySelectorAll('.item').length; } // Fires when the element scrolls into view @observe('intersection') onVisible(entries) { this.visible = entries[0].isIntersecting; } // Fires when the element is resized @observe('resize') onResize(entries) { this.columns = entries[0].contentRect.width > 600 ? 2 : 1; } // Fires when the media query flips @observe('media:(prefers-color-scheme: dark)') onScheme(event) { this.dark = event.matches; } }

Works the same inside a controller, where the observer applies to the attached element.

Pages and router

A page is an element bound to a route. Create the router once, then use the page decorator it returns.

// router.ts — create it once, export the pieces import { Router } from 'snice'; export const { page, navigate, initialize } = Router({ target: '#app', type: 'hash' // 'hash' or 'pushstate' — required }); // pages/product.ts — `page` comes from router.ts, not from 'snice' import { page } from '../router'; @page({ tag: 'product-page', routes: ['/products/:id?tab=:tab', '/products/:id'] }) class ProductPage extends HTMLElement { @property() id = ''; // route params arrive as properties @property() tab = ''; // query params do too; no URLSearchParams needed @render() template() { return html`<h1>Product ${this.id}</h1>`; } }

Pages own element orchestration: they compose elements, pass properties, handle events, bind controllers, and coordinate the screen. Routing is one page concern, not the definition of the role. Declare path and query parameters in routes; do not attach a controller to the page host or build a URL-parsing controller for it.

Routes use specificity first and declaration order for ties, so keep the query-bearing string before its bare fallback. Plain strings are the normal form. Optional { path, order } entries provide an explicit cross-registration tie-break; lower order values match first.

// main.ts — import pages for their side effects, then start import './pages/product'; import { initialize, navigate } from './router'; initialize(); navigate('/products/42');

Guards and layouts

A guard decides whether a route may render. A layout wraps whatever does.

// A guard receives the router context and the route params const isAuthenticated = (ctx, params) => ctx.user !== null; const hasRole = (role) => (ctx, params) => ctx.user?.role === role; @page({ tag: 'admin-page', routes: ['/admin'], guards: [isAuthenticated, hasRole('admin')] // AND, short-circuits }) class AdminPage extends HTMLElement { @render() template() { return html`<h1>Admin</h1>`; } }

Guards return a boolean or a promise of one. Async guards are awaited before the page mounts.

// A layout is an element with a slot for the page @layout('app-shell') class AppShell extends HTMLElement { @render() template() { return html` <nav>…</nav> <main><slot name="page"></slot></main> `; } } // Apply to every route… Router({ target: '#app', type: 'hash', layout: 'app-shell' }); // …or opt a single page out @page({ tag: 'login-page', routes: ['/login'], layout: false }) class LoginPage extends HTMLElement { @render() template() { return html`<h1>Sign in</h1>`; } }

Render roots

Open shadow DOM is the default. Choose closed shadow DOM, light DOM, focus delegation, or a custom root without changing template syntax.

@element('open-card') class OpenCard extends SniceElement { /* ... */ } @element('closed-card', { shadow: 'closed', delegatesFocus: true }) class ClosedCard extends SniceElement { /* ... */ } @element('light-card', { renderRoot: 'light' }) class LightCard extends SniceElement { /* ... */ }

SniceElement is optional. It adds a conventional render(), static styles, kebab-case implicit attributes, invalidate(), and renderNow().

Extending elements

Extend any element — including built-in Snice components. The child inherits everything, then adds or overrides what it needs.

import { SniceInput } from 'snice/components/input/snice-input'; // Inherits: label, value, placeholder, disabled, size, variant, // error-text, clearable, all events, all styles... @element('currency-input') class CurrencyInput extends SniceInput { @property() currency = 'USD'; connectedCallback() { super.connectedCallback(); this.updatePrefix(); } @watch('currency') updatePrefix() { const symbols = { USD: '$', EUR: '€', GBP: '£', JPY: '¥' }; this.prefixIcon = symbols[this.currency] || this.currency; } @on('input', 'input') restrictNumeric(e) { e.target.value = e.target.value.replace(/[^\d.]/g, ''); this.value = e.target.value; } @on('blur', 'input') formatValue() { const n = parseFloat(this.value); if (!isNaN(n)) this.value = n.toFixed(2); } @styles() currencyStyles() { return css`:host { --input-text-align: right; }`; } }

Inherits: properties, @watch, @on, @ready, @dispose, formAssociated
Replaces: @render (inherits parent's if not declared)
Concatenates: @styles (parent first, child wins via cascade)

Full example

A convention-driven component with public input, internal deep state, explicit form events, declarative styling, control flow, and differential updates.

import { SniceElement, css, dispatch, element, html, property, state, watch } from 'snice'; @element('task-item') class TaskItem extends SniceElement { static styles = css` :host { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem; } .done { text-decoration: line-through; opacity: 0.5; } small { color: green; } `; @property() label = ''; @property({ type: Boolean }) done = false; @state({ deep: true }) meta = { edits: 0, tags: new Set() }; render() { return html` <input type="checkbox" .checked=${this.done} @change=${this.updateStatus} /> <span class:done=${this.done}>${this.label}</span> <if ${this.done}> <small>Done</small> <else><small>Open</small></else> </if> `; } updateStatus(event) { this.done = event.currentTarget.checked; this.emitStatus(); } @dispatch('status-changed') emitStatus() { return { label: this.label, done: this.done }; } @watch('done') onDone(oldVal, newVal) { this.meta.edits++; console.log(this.label, newVal ? 'completed' : 'reopened'); } }

AI assistance

Snice ships its own AI tooling: a version-matched skill for coding agents, a project doctor, and a source analyzer.

Install the skill per project from npm, or per harness straight from the repository.

# Install the skill matched to this project's Snice version npx snice init-ai # Writes: # .agents/skills/snice/ the skill itself # AGENTS.md, CLAUDE.md pointers to it # Overwrite an existing install npx snice init-ai --force # Claude Code /plugin marketplace add https://gitlab.com/Hedzer/snice /plugin install snice@snice # Antigravity agy plugin install https://gitlab.com/Hedzer/snice # Gemini CLI gemini extensions install https://gitlab.com/Hedzer/snice # Kimi Code /plugins install https://gitlab.com/Hedzer/snice # Factory Droid droid plugin marketplace add https://gitlab.com/Hedzer/snice droid plugin install snice@snice

Use init-ai when you work on one project and want the skill pinned to its Snice version — it reads node_modules/snice/docs/ai/, so the agent sees the docs for the version you actually have. Use the repository install when you move between Snice projects and want the skill always available.

# Diagnose configuration, imports, dependencies, and AI setup npx snice doctor # Run the source analyzer on its own npx snice validate # Everything above in one pass npx snice check

validate catches the mistakes agents make most: an @element class that never extends HTMLElement, or an invented deep import like snice/decorators that was never a released package path.

Token-efficient copies of every reference page live in docs/ai/, mirroring these docs without the prose. The skill loads only the pages a task needs.