@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 { /* ... */ }
Elements documentation →
@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());
}
}
Full documentation →
@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>
Full documentation →
@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 { }
Full documentation →
@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>) { /* ... */ }
Full documentation →
@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 }; }
Full documentation →
@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;
}
Full documentation →
@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();
}
Full documentation →
@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());
}
Full documentation →
@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}>`