SniceSnice

@element

Register a custom element with configurable open/closed shadow or light-DOM rendering.

@element('my-card') class MyCard extends HTMLElement { @property() title = ''; @property({ type: Number }) count = 0; @render() template() { return html`<h1>${this.title}</h1>`; } } @element('light-card', { renderRoot: 'light' }) class LightCard extends SniceElement { /* ... */ } @element('closed-card', { shadow: 'closed', delegatesFocus: true }) class ClosedCard extends SniceElement { /* ... */ }

@page

Routable page component with route parameters, guards, and lifecycle hooks.

// Guard receives context + route params const auth: Guard<AppContext> = (ctx, params) => ctx.isAuthenticated(); @page({ tag: 'user-page', routes: ['/users/:id'], guards: [auth] }) class UserPage extends HTMLElement { // Route param :id auto-binds to this property @property() id = ''; @property() user = null; @ready() async load() { // this.id is already set from URL this.user = await fetch(`/api/users/${this.id}`).then(r => r.json()); } }

@controller

Hot-swap logic on any element — keep your UI, change what it does.

@controller('weather') class WeatherData { async attach(el) { const data = await fetch('/api/weather').then(r => r.json()); this.city = data.city; el.icon = 'cloud'; el.label = data.city; el.value = `${data.temp}°`; } @on('click') viewDetails() { navigate(`/weather/${this.city}`); } } // In a template — bind the class directly (preferred) html`<stat-card controller=${WeatherData}></stat-card>` // Raw HTML — registry name <stat-card controller="weather"></stat-card>

@layout

Page wrapper for the routing system. Layouts persist across route changes and wrap page content.

@layout('app-shell') class AppShell extends HTMLElement { @render() template() { return html` <nav><a href="/">Home</a></nav> <main> <slot></slot> </main> <footer>© 2025</footer> `; } } // Pages reference the layout @page({ tag: 'home-page', routes: ['/'], layout: 'app-shell' }) class HomePage extends HTMLElement { }

@daemon

Mark an explicitly constructed, app-owned state object for request/response and event communication. No singleton or implicit construction.

@daemon class SessionDaemon { @respond('get-session') getSession() { return this.session; } @dispatch('session-changed') changed() { return this.session; } } const session = new SessionDaemon(); const release = provideContext(appRoot, { daemons: { session } }); // Elements/controllers use the context address, not the class @request('get-session', { daemon: 'session' }) async *load(): Response<Session | null> { return yield {}; } @on('session-changed', { daemon: 'session' }) changed(event: CustomEvent<Session | null>) { /* ... */ }

@property, @state & @watch

Separate public attribute/property input from internal state, optionally observe nested collections, and watch changes.

// Public property: attribute input + reflection @property() name = ''; @property({ type: Number }) count = 0; @property({ type: Boolean, reflect: false }) active = false; // Internal state: never an attribute @state() open = false; @state({ deep: true }) model = { rows: [], selected: new Set() }; // Type conversion is only for string attributes. // Direct JS property assignments preserve type and identity. // @watch: fires once on init as (undefined, value), then on every change @watch('count') onCountChange(oldVal, newVal) { console.log(`Count: ${oldVal} → ${newVal}`); } // { immediate: false }: change-only, skip the init call // (use for watchers that dispatch events, so they don't fire on mount) @watch('open', { immediate: false }) onOpenChange(oldVal, newVal) { this.dispatchEvent(new CustomEvent('toggle')); }

@render & @styles

Template method with differential rendering. Scoped CSS styles.

// @render: template method, returns html`...` @render() template() { return html` <div class="card"> <h3>${this.title}</h3> <if ${this.expanded}> <p>${this.content}</p> </if> </div> `; } // Optional convention-driven authoring class UserCard extends SniceElement { static styles = css`:host { display: block; }`; render() { return html`<article class:selected=${this.selected}>...</article>`; } } // @styles: scoped CSS @styles() componentStyles() { return css` :host { display: block; } .card { padding: 1rem; border-radius: 8px; } `; }

@ready, @reconnect & @dispose

Lifecycle hooks. @ready fires once after the first render. @dispose fires on every disconnect. @reconnect fires on every connect AFTER the first — use it for components that wire long-lived global subscriptions in @ready and need to re-attach them on reconnect.

// @ready: fires once after first render @ready() onMount() { this.interval = setInterval(() => this.tick(), 1000); document.addEventListener('click', this.handler); } // @reconnect: every connect AFTER the first @reconnect() onReconnect() { document.addEventListener('click', this.handler); } // @dispose: every disconnect @dispose() cleanup() { clearInterval(this.interval); document.removeEventListener('click', this.handler); }

@moved & @adopted

Element moved in DOM, or adopted into a different document.

// @moved: reparented in the DOM @moved() onMoved() { this.recalculateLayout(); } // @adopted: moved into a new document (e.g. iframe) @adopted() onAdopted() { this.reattachStyles(); }

@query & @queryAll

DOM element references within shadow DOM.

// @query: single element @query('input') inputEl; @ready() setup() { this.inputEl.focus(); } // @queryAll: NodeList @queryAll('.item') items; @ready() count() { console.log(`Found ${this.items.length} items`); }

@on & @dispatch

Event delegation and custom event emission.

// @on: delegated event listener @on('click', '.entry') editEntry(e) { this.selected = e.target.dataset.id; this.editing = true; } // @dispatch: return value becomes event.detail @dispatch('status-changed') updateStatus(status) { this.status = status; return { status }; } // Stacked: DOM event triggers a custom event @on('click', '.item') @dispatch('item-selected') handleItemClick(e) { return { id: e.target.dataset.id }; } // scope option: redirect listener / dispatch target // 'global' = document, selector = closest ancestor, // EventTarget = direct, function = resolver @on('bus:save', { scope: 'global' }) onSave(e) { /* document-wide bus */ } @on('bus:cart-added', { scope: 'cart-shell' }) onAdded(e) { /* nearest cart-shell ancestor */ } @dispatch('bus:cart-added', { scope: 'global' }) add(id) { return { id }; }

@context

Receive router navigation context updates.

// Method receives Context on route changes @context() handleContext(ctx: Context) { this.user = ctx.application.user; this.route = ctx.navigation.route; }

@observe

Watch for intersection, resize, media query, or DOM mutation changes.

// Fires when element enters/exits viewport @observe('self', 'intersection', { threshold: 0.5 }) onVisible(entry: IntersectionObserverEntry) { this.visible = entry.isIntersecting; } // Fires when element is resized @observe('self', 'resize') onResize(entry: ResizeObserverEntry) { this.compact = entry.contentRect.width < 400; } // Fires when media query matches/unmatches @observe('self', 'media:(prefers-color-scheme: dark)') onDarkMode(matches: boolean) { this.darkMode = matches; } // Fires on child DOM mutations @observe('self', 'mutation:childList,attributes') onMutation(records: MutationRecord[]) { this.updateCount(); }

@request & @respond

Async generator pattern for parent-child communication.

// @request: async generator yields payload, awaits response @request('fetch-user') async *fetchUser(id: string) { const user = await (yield { id }); return user; } // Usage: const user = await this.fetchUser('123'); // @respond: receives payload, returns response (in parent / controller) @respond('fetch-user') async handleFetchUser({ id }) { return await fetch(`/api/users/${id}`).then(r => r.json()); }

@debounce & @throttle

Rate-limit method execution. Debounce waits for quiet; throttle limits frequency.

// @debounce: wait 300ms after last call @debounce(300) onSearchInput(query: string) { this.results = await this.search(query); } // @throttle: at most once per 100ms @throttle(100) onScroll(e: Event) { this.scrollY = window.scrollY; } // Stacked with @on for event-driven debounce @on('input', '.search') @debounce(250) handleSearch(e: Event) { this.query = e.target.value; }

@once & @memoize

Execute a method only once, or cache return values for repeated calls.

// @once: runs once across all instances, subsequent calls are no-ops @once() initialize() { this.setupWebSocket(); this.loadConfig(); } // @once(true): each instance runs once @once(true) onFirstRender() { analytics.track('component-viewed'); } // @memoize: cache results — same arguments return cached value @memoize() computeExpensiveValue(input: string) { return heavyComputation(input); }

Template Syntax

Property binding, conditionals, loops, and event shortcuts.

// Property binding html`<input .value=${this.text}>` // Conditionals html`<if ${this.loading}><snice-spinner></snice-spinner></if>` html`<if ${this.error}><span>${this.error}</span></if>` // Loops (use .map()) html`${this.items.map(item => html`<li key=${item.id}>${item.name}</li>`)}` // Keyboard shortcuts html`<input @keydown:ctrl+s=${this.save}>` html`<div @keydown:escape=${this.close}>`