Elements
Defining custom elements, choosing a render root, and extending existing elements.
| Topic | Documented in |
|---|---|
| Public input, state, attribute conversion | Properties |
Connection, readiness, teardown, @watch | Lifecycle |
@query / @queryAll | Queries |
@styles, host styling, icons | Styling |
@render, templates, control flow | Declarative Rendering |
Template events, @on, @dispatch | Events |
Basic Usage
Creating an Element
For convention-based authoring, extend the optional SniceElement base and implement render() directly:
Plain HTMLElement subclasses and decorated render/style methods remain fully supported.
Element Decorator Options
The @element decorator accepts:
tagName: string- The custom element tag name (must contain a hyphen)options?: ElementOptions- Optional configurationformAssociated?: boolean- Enable form association (default: false)renderRoot?: 'shadow' | 'light'- Select shadow or light DOM renderingshadow?: 'open' | 'closed' | false- Shadow mode, or light-DOM shorthanddelegatesFocus?: boolean- Forwarded toattachShadow()
Render Roots and Shadow DOM
Elements use an open shadow root by default. Open/closed shadow roots and light DOM share the same differential renderer, lifecycle, event binding, styles, and query decorators.
shadow: false is shorthand for renderRoot: 'light'. Framework-managed queries continue to work with a closed root. A createRenderRoot() override may return the host element or a ShadowRoot for a custom policy.
See Queries for resolving elements inside the render root.
Native autofocus
Use the platform autofocus attribute or property; no focus controller is needed. Snice applies autofocus after the element is ready and the browser has had a frame to paint, which makes it reliable for late-upgraded custom elements and controls rendered inside open or closed shadow roots.
For a decorated host without its own effective focus() implementation, host-level autofocus targets the first native focusable control in its render root. Snice does not add tabindex. As with native autofocus, the first candidate wins; focus deliberately established by application code (including an @ready handler) is preserved. Assigning element.autofocus = true after initialization is also supported. The pass also covers a host that first appears in a LATER render (e.g. inside a conditional branch), not only elements present at initial mount.
Under jsdom the autofocus IDL property is not implemented, so element.autofocus = true is a no-op in tests — Snice's pass reads hasAttribute('autofocus'), which makes the attribute form (?autofocus=${...} in templates) the testable one.
Extending Elements
Elements can extend other elements — including Snice's built-in components. The child inherits the parent's properties, watchers, event handlers, lifecycle hooks, and styles, then adds or overrides its own.
Example: Currency Input
Extend snice-input to create an input that prefixes a currency symbol, restricts to numeric entry, and formats the value on blur:
The currency-input inherits everything from snice-input — label rendering, variants, sizes, validation, focus/blur events, clearable, keyboard handling — without re-implementing any of it. It adds a currency symbol prefix, numeric restriction, and formatting.
What inherits
| Feature | Behavior |
|---|---|
@property | Child gets all parent properties. Child can override defaults or type. |
@watch | Both parent and child watchers fire. |
@on | Both parent and child handlers fire. |
@ready, @reconnect, @dispose | All three fire on parent and child. |
@dispatch | Inherited via prototype. |
@render | Child replaces parent's render. If child doesn't declare @render, parent's is used. |
@styles | Concatenated — parent styles first, child second (child wins via cascade). |
formAssociated | Inherited. |
Each child class needs its own @element('tag-name') with a unique tag name.
Full Example
Elements handle visual behavior — they render the form and emit events. Business logic (API calls, validation) belongs in controllers:
Properties
Public inputs, internal state, attribute conversion, and reflection. Elements themselves are covered in Elements; template binding channels in Binding Channels.
Basic Properties
Properties automatically sync with DOM attributes and trigger re-renders:
Usage:
Property Options
Property Behavior
All properties automatically:
- Read from DOM attributes when present
- Reflect property setter changes to corresponding attributes unless
reflect: false - Convert between string attributes and typed properties
- Trigger re-renders when changed
Attribute conversion is intentionally one-way at the HTML boundary. A direct JavaScript assignment is already typed and is stored exactly as assigned:
This preserves dates, union values, services, objects, and collection identity. type and converter.fromAttribute process strings arriving from attributes; converter.toAttribute serializes reflection.
Use reflect: false for input-only attributes, attribute: false for JavaScript-only public properties, and @state() for internal reactive fields:
@state() never observes or writes an attribute. deep: true tracks nested plain objects, arrays, Map, and Set using native Proxy and Reflect; class instances and DOM objects remain intact. Deep observation targets modern evergreen browsers and is not available in Internet Explorer.
Controlled form properties
When rendering a controlled native or Snice form control, bind its JavaScript
property. Use live() when an owner render must restore state even though the
bound value equals the last value Snice committed:
live() compares with the current DOM property, but it does not watch the DOM
or schedule a render. See Live property values
for the modal-reseed example and exact timing.
Note: Initial field values (defaults like name = 'Anonymous') are NOT reflected to attributes. Only changes made via the property setter are reflected. Set attribute: false to disable attribute sync entirely.
Boolean Properties:
<element>or<element enabled="">→true<element enabled="true">→true<element enabled="false">→false- No attribute →
false
Custom Converters
SimpleArray Type
The SimpleArray type enables safe reflection of arrays containing basic types:
Usage:
- Uses full-width comma (,) as separator
- Supports string, number, and boolean types
- Type-safe serialization
Lifecycle
Connection, readiness, teardown, and reacting to change.
Lifecycle Decorators
@ready() - Called after styles are applied and event handlers are set up. The initial render may still be completing in a microtask — use @query (which re-queries each access) to safely access rendered DOM:
@reconnect() - Called every time the element is connected AFTER the first connect. @ready only fires once; @dispose fires on every disconnect. The gap between is "what should run on a reconnect?" — for most components nothing extra is needed because framework-managed handlers (@on, @observe, @respond, @context) are re-established automatically. Use @reconnect only when the component wires its own long-lived global subscription in @ready (e.g. document.addEventListener for outside-click) and tears it down in @dispose:
@dispose() - Called when element is removed from DOM:
@moved() and @adopted()
Fire when the element is moved between documents (adoptedCallback).
@adopted() is an alias of @moved(); both accept the same debounce/throttle
options as the other lifecycle decorators.
Waiting for elements
Awaiting definition or readiness from outside a component — useful in tests and
in code that hands work to an element it did not create:
Each takes an optional warningTimeout (ms) and warns when an element takes
longer than expected rather than hanging silently. Silence those warnings with
setDisableElementReadyWarnings(true).
Inside a component, prefer await el.ready — see Testing.
ready Promise
Every element has a ready promise that resolves when fully initialized:
If an @ready() handler throws or returns a rejected promise, ready rejects
with that failure after the handlers finish. Snice also logs the handler name.
Await ready during tests and application setup so initialization failures
cannot look like successfully rendered, half-initialized elements.
Watch Decorator
Use @watch to react to property changes. Handlers receive three arguments: (oldValue, newValue, propertyName).
Initial values. By default a watcher fires once during initialization with the element's starting value — from markup (<reactive-component user-name="Ada">) or the field default — with oldValue undefined, then again on every later change:
Pass { immediate: false } as the last argument for a change-only watcher — one that must not run on mount, such as a watcher that dispatches an event:
The options object always comes after the property names, so it works with multiple watched properties too: @watch('width', 'height', { immediate: false }).
@context() Decorator
Receive router context updates on pages, descendant elements, and attached
controllers. The decorated method is called whenever the router context
changes (navigation, app context update, etc.). A controller's managed
decorators activate after attach(), so context-dependent startup belongs in
this handler:
Context Options:
Order for a routed page: @context() fires first (before the first
render), then the first render commits, then @ready() runs with the rendered
DOM already queryable. A page can therefore normalize or correct an incoming
route parameter in @context() before the first render commits a value onto a
child element.
The Context object provides:
ctx.application— App context (theme, auth, config, etc.)ctx.navigation.route— Current route pathctx.navigation.params— Route parametersctx.navigation.placards— All registered page placardsctx.fetch— Fetch with middleware support (see Fetcher docs)
Queries
Resolve elements inside a render root with @query and @queryAll instead of reaching for querySelector.
Single Element Query
Multiple Elements Query
Query Options
Control where queries search using light and shadow options:
Accessing Shadow DOM Elements
Use @query instead of manual shadowRoot.querySelector:
Declarative Rendering
Snice templates are tagged template literals that update only the dynamic parts of the DOM. Template structure is parsed once and DOM nodes are retained across updates.
SniceElement is optional. Plain HTMLElement subclasses continue to support @render() and @styles(). The base class adds a conventional render() method, static styles, kebab-case implicit attribute names, and typed invalidate() / renderNow() methods.
Template values
Node expressions accept text, numbers, templates, iterables, DOM nodes, promises, async iterables, and nothing.
Dynamic text is escaped. unsafeHTML(value) is the explicit opt-in for trusted raw HTML. Use svg for an SVG fragment that does not include its own outer <svg>` element.
Escaping prevents markup injection; it does not make an untrusted navigation URL safe. Validate URL sinks with isSafeUrl():
By default, relative references and absolute http:, https:, mailto:, and tel: URLs are accepted. Network-path references must resolve to an allowed protocol. Malformed URLs, raw ASCII control characters, and every other explicit scheme are rejected. Passing allowed replaces the absolute-protocol list but does not disable relative references. snice-button applies this policy automatically to its href property.
Authoring diagnostics
Malformed declarative syntax fails when its TemplateResult is prepared for rendering. When the template belongs to a Snice element, the error identifies the owning host by its authoritative registered tag and, when safely available, its JavaScript class, then includes a nearby static-template excerpt where possible:
Minified CDN builds commonly strip constructor names, so a tag-only identity such as <user-editor> is normal and intentional. Identity is recorded only after a successful Snice @element, @layout, or Router page registration (or when that exact constructor is already registered). It belongs to that exact constructor and immediate prototype: an undecorated subclass stays generic, while an instance adopted into another document keeps its original registered identity. This context follows nested templates, keyed/iterable templates, promises, and async iterables, including templates rendered into open or closed shadow roots and light DOM. A template prepared without a component render host keeps the generic authoring error and nearby excerpt; Snice does not invent a component, source filename, or callsite it cannot know at runtime. Contextual errors retain the original error as cause, so its stack remains available for debugging.
Bindings
This section is the quick syntax overview. See Binding Channels for the complete value, cleanup, event, spread, sentinel, and form-control semantics.
| Syntax | Effect |
|---|---|
${value} | Node content |
name=${value} | Attribute |
.name=${value} | JavaScript property |
?name=${value} | Boolean attribute presence |
@event=${handler} | Event listener |
Use property bindings for objects, arrays, functions, element state, and native form values. Property bindings preserve the JavaScript value and its identity.
Class and style toggles
Toggle one class or one CSS property without rebuilding a complete string:
Falsy class:name values remove the class. nothing, null, or false removes an individual style:name property. classMap() and styleMap() remain available when an object-to-string mapping is more convenient.
Event modifiers and keyboard filters
Event modifiers compose after the event name with |:
Supported modifiers are prevent, stop, immediate, once, capture, passive, and self. Long aliases such as preventDefault, stopPropagation, and stopImmediatePropagation are accepted. passive and prevent cannot be combined.
Keyboard filters support dot or colon notation. Exact filters such as @keydown.ctrl+s reject extra modifiers; ~ allows any modifier combination, as in @keydown.~enter.
Handlers can be functions or EventListenerObject values. Template functions run with this bound to the component that owns the render tree.
Named spreads
Prefer direct bindings when the set of keys is known; they are easier to read. Named spreads are for dynamic or forwarded bags from wrappers, plugins, and generated views. They make the target channel explicit and remove stale keys on later renders:
...propsassigns JavaScript properties. A key removed during a live update is reset toundefined....attrswrites attributes.nothing,null, andfalseremove a key;truewrites an empty attribute....eventsmanages listeners. Names may beclickor@click.
Event-spread values may be functions, EventListenerObject values, or nullish/false to remove a listener. Empty or duplicate normalized names (for example both click and @click) are rejected. Listeners detach while a conditional branch is parked and reattach when it returns. Removing a host from the document retains the listeners on its retained render tree, matching native DOM behavior. Consumed |once and EventListenerObject.once listeners stay consumed in either lifecycle.
Form controls
Keep both directions visible: bind component state to the native property, then handle the browser event that updates component state.
Use input for text as it changes and change for committed choices such as checkboxes, selects, and files. Explicit handlers also make parsing, validation, and IME policy visible at the point where the view updates the model.
Control flow
Snice control-flow tags are virtual: they do not remain in the DOM.
If / else-if / else
<else-if> and <else> must be direct children of <if>, and <else> must be last. Branch DOM is parked and restored, so input state and element identity survive branch switches.
Case / when / default
Static value matching compares string representations. A bare expression uses Object.is, so symbols, objects, numbers, and other typed values can be matched by identity.
Keyed repeat
Use repeat() when list identity matters:
repeat() accepts any iterable, adds no wrapper, moves existing DOM when order changes, and rejects duplicate keys before reconciliation. A normal mapped array and key=${value} remain supported; repeat() is the explicit API when keyed identity and an empty state are required together.
Async content
A Promise or AsyncIterable can be rendered directly in a node expression. A promise replaces the pending empty range when it settles. An async iterable commits each emission. Changing the source ignores stale results, and disconnecting the owning tree stops active consumption.
Promise cancellation remains the caller's responsibility; use AbortController in the component lifecycle when a fetch must be aborted. Async iterators receive a best-effort return() call when replaced or disconnected.
Reactive authoring
Properties and state
@property()observes an attribute by default and reflects property writes back by default.reflect: falsekeeps attribute-to-property input but stops property-to-attribute output.attribute: falsedisables both attribute observation and reflection.@state()is reactive internal state and never participates in attributes.typeandconverter.fromAttributeconvert only the string attribute boundary. Direct JavaScript property assignments preserve their exact value and identity.converter.toAttributecontrols reflection serialization.hasChanged(value, oldValue)customizes assignment change detection.
Plain HTMLElement subclasses retain legacy lowercase implicit attribute names. SniceElement converts implicit camelCase names to kebab-case. An explicit attribute name works the same on either base.
Deep state
deep: true observes nested writes in plain objects, arrays, Map, and Set:
Mutations are batched into the normal render scheduler. Cycles are supported, proxies are stable, collection iteration returns reactive nested values, and mutating an old graph after replacing the property does not invalidate the component.
Deep observation uses native Proxy and Reflect. It is intended for modern evergreen browsers and is not available in Internet Explorer. Regular @property() / @state() fields do not require deep proxies. Class instances such as Date, DOM nodes, and application service objects are deliberately left intact.
SniceElement conventions
invalidate() schedules the conventional render and returns the current rendered promise. renderNow() commits immediately. Decorated @render() / @styles() methods remain available and can be used on either base class.
Render roots
Render-root selection (shadow, closed, light) is an element-definition concern; see Elements.
Editor metadata
Published packages include:
custom-elements.jsonfor the Custom Elements Manifest ecosystem.vscode.html-custom-data.jsonfor HTML tag, attribute, and event completion.snice/components/custom-elementsTypeScript declarations for tag-name maps and component element types.
Regenerate metadata with npm run generate:metadata; CI or local checks can use npm run check:metadata.
@render() Decorator
Returns a template using the html tagged template. Automatically re-renders when properties change due to differential rendering.
Auto-Rendering:
- Template automatically re-renders when
@property()decorated properties change - Only changed parts of the DOM update (differential rendering)
- No manual re-render calls needed
Render Options:
The @render() decorator accepts an optional configuration object:
Differential Rendering:
By default, Snice uses differential rendering which only updates changed parts of the DOM. To disable this and re-render from scratch each time:
When to use differential: false:
- Component has complex dynamic structure that changes between renders
- Template structure changes based on data (e.g., empty state vs. populated)
- Avoiding differential rendering issues with dynamic attributes
- Simple components where full re-render is acceptable
Note: When differential: false, the render method must return a string (not html\...\`). Declarative bindings and virtual control flow (<if>, <case>`, and related tags) are not available in the raw-string mode.
Imperative Rendering
Use @render({ once: true }) with @watch and @query to render the template once, then update the DOM directly. Property changes fire watchers instead of triggering re-renders.
How it works:
@render({ once: true })renders the template on first connect, then blocks all subsequent auto-renders. The initial render uses current property values via normal interpolation, so the DOM starts correct.@queryprovides getters that re-query the shadow DOM on each access — no stale references.@watchfires synchronously in the property setter, beforerequestRenderis called. Sinceonce: trueblocks the render anyway, only the watcher runs.
Timing on property change:
- Property setter runs
- Value reflected to attribute (if applicable)
@watchmethods fire synchronouslyrequestRender()called but immediately returns (blocked byonce)
When to use imperative rendering:
- The template structure never changes — only content within fixed elements updates
- Updates are expensive (e.g., syntax highlighting, canvas operations) and you want precise control over what changes
- You need to coordinate async operations (fetching data, animations) without re-renders interfering
- Performance-critical components where differential rendering overhead matters
Compared to declarative rendering:
Declarative (@render()) | Imperative (@render({ once: true })) | |
|---|---|---|
| Template re-renders | Automatic on property change | Never (after first render) |
| DOM updates | Differential (only changed parts) | Manual via @watch + @query |
| Boilerplate | Less — just use interpolation | More — explicit update methods |
| Control | Framework manages updates | You manage updates |
Conditional Rendering
The virtual control-flow tags add no wrapper elements and retain each branch's DOM identity. See Declarative Rendering for typed <when> branches, repeat(), and direct async values. See Binding Channels for exact property, attribute, event, spread, sentinel, and form semantics.
Binding Channels
Every expression in an html template writes through a specific browser channel. The channel decides whether a value becomes text, an attribute string, a JavaScript property, attribute presence, a listener, a class, or a CSS declaration. Choosing the channel is part of the component's public contract.
This reference covers every binding channel supported by the differential renderer. For control flow, keyed repeat(), async node values, render roots, and reactive authoring, see Declarative Rendering.
Channel chooser
| Syntax | Writes to | Use it for |
|---|---|---|
${value} | A DOM range between marker comments | Text, nested templates, nodes, lists, and async content |
name=${value} | An HTML attribute string | Labels, IDs, ARIA, URLs, and serialized data |
name="a ${value} b" | One interpolated attribute string | Attributes assembled from static and dynamic text |
.name=${value} | A JavaScript property | Objects, arrays, functions, element APIs, and native form state |
?name=${value} | Attribute presence | Native boolean attributes and presence-based selectors |
controller=${value} | A controller attachment | Controller classes (preferred) or registry names |
@event=${handler} | An event listener | DOM and custom events |
class:name=${value} | One class token | Independent conditional classes |
style:name=${value} | One CSS declaration | Independent styles and CSS custom properties |
...props=${bag} | Multiple JavaScript properties | Dynamic or forwarded property bags |
...attrs=${bag} | Multiple attributes | Dynamic or forwarded attribute bags |
...events=${bag} | Multiple event listeners | Dynamic or forwarded listener bags |
key=${value} | An attribute and list identity | Identity for mapped template arrays |
<!-- ${value} --> | HTML comment data | Inspectable diagnostics or generated metadata |
Use direct bindings when names are known. Named spreads are deliberately explicit because property, attribute, and event bags have different value and cleanup rules.
Shared rules
Snice parses the static template structure once and updates only the expression-backed parts. A binding keeps its DOM node or listener when the same template is rendered again.
All channels except ordinary attributes and HTML comments take exactly one expression. Static text or additional expressions in a property, boolean, event, class, style, or named-spread binding are ignored with a warning:
Expressions cannot appear loose inside an opening tag. Use an explicit binding name:
The bare expressions on <if>, <else-if>, <case>, and <when> are the only opening-tag exception; they belong to Snice's virtual control-flow grammar. Bindings are unavailable when @render({ differential: false }) returns a raw string.
Node content
A node expression owns a range in the document. It accepts:
- Strings, numbers, booleans, bigints, and symbols as escaped text
- Nested
htmlorsvgtemplate results - DOM
Nodevalues - Arrays and other synchronous iterables
repeat()results- Promises and async iterables
unsafeHTML()for explicitly trusted markup
Dynamic strings are text, not markup. unsafeHTML() is the explicit trusted-HTML boundary.
nothing, null, undefined, and the empty string clear the owned range. Other primitives are visible text, including false, 0, and NaN. A plain object that is not a supported template, node, or iterable falls back to String(value).
Promises start with an empty range and commit their result when settled. Async iterables commit each emitted value. Replacing a source prevents stale results from winning; disconnecting its owning tree stops active iteration. Promise cancellation remains the caller's responsibility.
Attributes
An ordinary attribute binding writes text through setAttribute():
Use attributes when the consumer is HTML, CSS, accessibility tooling, serialization, or a custom element's attribute API.
For a single-expression attribute:
nothingremoves the attribute.nullandundefinedwrite an empty attribute value.trueandfalsebecome the strings"true"and"false".- Other values use
String(value).
An interpolated attribute may contain any number of expressions. null and undefined contribute empty text. If any slot is nothing, Snice removes the whole attribute. noChange preserves only that slot's previous value.
An attribute is always a string boundary. Do not use it to pass object identity to a child component; use a property binding.
Properties
A leading dot assigns directly to the element's JavaScript property:
Property bindings preserve type and identity. They are the correct channel for arrays, objects, functions, class instances, custom-element APIs, and native state such as input.value, input.checked, or select.value.
null, undefined, and false are assigned unchanged. nothing assigns undefined. Re-rendering the same bound value skips the property write.
Live property values
Native controls and stateful custom elements can mutate their own properties after Snice writes them. Normally, if the bound value has not changed, a later render leaves that user- or element-edited DOM value alone. Wrap a property value in live() when the owner state must be reasserted even if the bound value itself is unchanged:
live() compares against the element's current property rather than the last value Snice committed. It is only for property bindings. It is useful for controlled fields, normalization, and resetting DOM state after validation; omit it when in-progress browser state should survive unrelated renders.
live() does not observe the DOM and does not schedule rendering. It reasserts
the value only when the template that owns the binding renders for some other
reason. This is especially useful when reopening a modal should restore the
same seed value after the user changed only the DOM control:
Without live(), reopening with the same seededName can leave the user's old
DOM edit in place because the binding remembers that it already committed that
seed. If no owner state changes, call the element's documented invalidation
path; live() by itself cannot notice drift.
Boolean attributes
A leading question mark controls presence, not a string value:
A truthy value adds the attribute with an empty value. A falsy value or nothing removes it. This includes false, 0, the empty string, null, undefined, and NaN.
Use this channel only when presence has meaning. If a consumer expects the text "false", use an ordinary attribute. If it expects a JavaScript boolean property, use .name.
Controllers
A bare controller=${value} binding attaches a controller. Bind the decorated class directly — the preferred channel — or a registry name string:
Class values skip the registry and are deduped by reference: re-rendering with the same class is a no-op; a different class (or null) detaches the previous controller first. The @controller('name') decorator is still required on the class. While a class is bound it owns the element. Snice reflects its decorator name as a diagnostic controller="name" attribute for DevTools, but that marker never resolves the registry or creates another attachment; treat it as read-only. Custom elements that have not upgraded yet hold the class until their connectedCallback runs.
String values delegate to the attribute channel and behave exactly like a static controller="name" attribute. Interpolated forms (controller="user-${kind}") are ordinary attribute interpolation, not this channel.
Controllers detach while their subtree is parked or disconnected and re-attach on reconnection, mirroring element lifecycle.
Class and style
class:name independently toggles one class without rebuilding the static class attribute:
A truthy value adds the class. A falsy value or nothing removes it. Static classes and other class:name bindings remain untouched.
style:name writes one declaration with CSSStyleDeclaration.setProperty():
CSS property names are passed through as written, so use CSS spelling such as background-color and --custom-token. nothing, null, undefined, and false remove the declaration. Every other value is stringified, including 0 and the empty string. Static style declarations and other style:name bindings remain untouched.
Use classMap() or styleMap() when producing a complete class or style attribute from an object is clearer. Use the channel bindings when each token has an independent condition or lifecycle.
Events
An event binding owns one native listener:
A handler may be a function or an EventListenerObject with handleEvent(event). nothing, null, undefined, or false removes the listener. Any other value throws a TypeError.
Function handlers run with this set to the custom element that owns the render tree. Listener objects follow native DOM behavior: this inside handleEvent is the listener object itself. Replacing a handler updates the native listener; rendering the same handler leaves it attached.
Event modifiers
Append modifiers with a vertical bar:
Supported modifiers:
preventcallspreventDefault()before the handler.stopcallsstopPropagation().immediatecallsstopImmediatePropagation().onceconsumes the listener after its first matching event.captureattaches in the capture phase.passiverequests a passive listener.selfruns only whenevent.targetis the bound element.
The long aliases preventDefault, stopPropagation, and stopImmediatePropagation are also accepted. passive cannot be combined with prevent.
Modifiers use the vertical-bar form. A dot on a non-keyboard event is part of the actual event name, so @app.ready listens for the custom event app.ready; it does not apply a modifier.
Keyboard filters
keydown, keyup, and keypress support a key filter after a dot or colon:
Filters are exact by default: unspecified Ctrl, Alt, Shift, and Meta keys must be up. Prefix the key specification with ~ to ignore modifier state. Combine required modifiers with +; accepted names include ctrl or control, alt, shift, and meta, cmd, or command.
Common key aliases are normalized, including esc, return, space, arrow directions, del, backspace, tab, home, end, pageup, and pagedown.
Custom event names
Slash, colon, and dot characters may be part of a custom event name. If the actual event name starts with @, escape the template prefix by doubling it:
The last binding listens for the actual event name @snice/updated.
Listener objects and lifecycle
A listener object can carry native capture, passive, once, and signal options alongside handleEvent:
Listeners inside an inactive <if> or <case> branch detach while the branch is parked and reattach when it returns. A listener already consumed with once stays consumed. Removing and reconnecting a host retains listeners on its retained render tree, matching native DOM behavior.
Named spreads
Named spreads are for forwarding a bag whose keys are only known at runtime. They do not guess a destination:
The spread value must be a non-array object. nothing, null, or undefined means an empty bag, which cleans up every previously committed key. noChange as the entire spread value preserves the current bag. false, primitives, and arrays throw. noChange is not an entry-level sentinel inside a bag. An omitted key is cleaned up according to its destination channel.
Property spreads
...props assigns each entry as a JavaScript property and preserves the entry's type and identity. An entry equal to nothing assigns undefined. A key omitted from the next bag is reset to undefined during that live update.
...properties is an accepted long alias. Prefer the shorter ...props spelling in new templates.
Attribute spreads
...attrs writes each entry as an attribute. nothing, null, undefined, and false remove that key; true writes an empty attribute; every other value is stringified. A key omitted from the next bag is removed.
...attributes is an accepted long alias. Prefer the shorter ...attrs spelling in new templates.
Event spreads
...events manages a listener per entry. Keys may be written as click or @click; the optional leading @ is removed. The two spellings cannot both appear for the same event in one bag, and empty names throw.
Values may be functions, listener objects, nothing, null, undefined, or false. Removing a key removes its listener. Listener objects support native capture, passive, once, and signal options. Function handlers use the render host as this.
Event-spread keys are literal event names. Direct-binding modifiers and keyboard-filter syntax are not parsed in spread keys; use a listener object for native options or a direct @event binding for filters and modifiers.
Keyed identity
In a normal mapped array, put key=${value} on the root element of every item template when DOM identity must follow the item instead of its array position:
Keys compare by JavaScript identity. Every item must be keyed and keys must be unique. Duplicate keys throw. Mixing keyed and unkeyed item templates warns and falls back to position-based reconciliation. The key binding is also an ordinary attribute on the rendered root.
Prefer repeat(items, { key, render, empty }) when keyed identity is intentional. It supplies keys directly to the reconciler, adds no wrapper, and does not require a key attribute in the rendered template.
Comment interpolation
Expressions inside an authored HTML comment update the comment's data:
nothing, null, and undefined contribute empty text. Other values are stringified. noChange preserves an individual slot. A result containing -- or ending with - throws because it is not valid HTML comment data.
Comments are invisible in the rendered page but visible to DOM inspection and serialized markup. Do not place secrets in them.
Sentinel matrix
The same JavaScript value can mean something different in each channel:
| Channel | null or undefined | false | nothing | noChange |
|---|---|---|---|---|
| Node content | Clear the range | Render false text | Clear the range | Keep the current range |
| Attribute | Write an empty value | Write "false" | Remove the attribute | Keep the current value |
| Property | Assign as-is | Assign false | Assign undefined | Keep the current value |
| Boolean attribute | Remove | Remove | Remove | Keep current presence |
| Controller | Detach | Detach | Detach | Keep the current controller |
| Class token | Remove | Remove | Remove | Keep current presence |
| Style property | Remove | Remove | Remove | Keep the current declaration |
| Event listener | Remove | Remove | Remove | Keep the current listener |
| Whole named spread | Clear the bag | Throw | Clear the bag | Keep the current bag |
| Comment slot | Write empty text | Write false text | Write empty text | Keep the current slot |
For an interpolated attribute or comment, noChange preserves only its expression slot. On first render there is no earlier slot value, so it contributes empty text.
nothing is a public rendering value. noChange is an optimization and control signal: it means “do not commit this part during this render,” not “remove this part.”
Form data flow
DOM-to-state updates remain explicit. Bind state to a native property, then handle the browser event that updates component state:
This keeps parsing, validation, IME policy, and the event that changes state visible. Use input for text as it changes and change for committed choices such as checkboxes, selects, and files.
Property-to-attribute reflection from @property() is a custom-element API concern; it does not replace a form control's browser-to-state event. See Properties for property and reflection semantics.
Invalid placements and diagnostics
Snice rejects or warns about ambiguous channel authoring:
- A loose expression in an opening tag throws. Choose a named binding or named spread.
- An empty property, boolean, event, class, or style name throws.
- An unknown named spread throws; supported destinations are properties, attributes, and events.
- Static text or multiple expressions on a single-expression channel warn and are ignored after the first expression.
- A non-callable event value throws instead of becoming an inert attribute.
- A non-object spread, duplicate normalized event name, or empty event name throws.
- Invalid event modifiers and the
passivepluspreventcombination throw. - Invalid comment data throws.
Enable setStrictRenderErrors(true) in tests and development when render failures should rethrow instead of being logged while the previous DOM remains in place.
Events API Documentation
Event handling in Snice provides two powerful approaches: template event syntax and the @on decorator. The @on decorator works in both elements AND controllers with full event delegation, keyboard modifiers, debounce/throttle, and more. Additionally, the @dispatch decorator enables automatic custom event dispatching.
Template Event Syntax (Preferred for Elements)
The recommended way to handle events in elements is using template event syntax with @event=${handler}:
Basic Usage
Event Object Access
Multiple Event Types
Keyboard Shortcuts in Templates
Template event syntax supports keyboard shortcuts using dot notation:
Keyboard Shortcut Syntax:
@keydown.enter- Plain Enter (no modifiers)@keydown.ctrl+s- Ctrl+S combination@keydown.ctrl+shift+s- Multiple modifiers@keydown.~enter- Enter with any modifiers@keydown.escape,@keydown.down, etc. - Named keys
Template Event Modifiers
Append DOM listener and propagation behavior with |:
Supported modifiers:
preventcallspreventDefault()(preventDefaultalias).stopcallsstopPropagation()(stopPropagationalias).immediatecallsstopImmediatePropagation()(stopImmediatePropagationalias).once,capture, andpassiveconfigure DOM listener behavior.selfinvokes the handler only whenevent.targetis the bound element.
passive and prevent are contradictory and produce a render error. Modifiers compose with dot or colon keyboard filters. Exact keyboard combinations reject extra modifiers; prefix the key filter with ~ to accept any modifier combination.
Handlers may also be EventListenerObject values. Their handleEvent() method receives the object as this, and capture, once, or passive fields are honored. Function handlers receive the component that owns the render tree as this.
Arrow Functions in Templates
Use arrow functions for inline event handling or passing parameters:
@on Decorator
The @on decorator works in both elements AND controllers. It provides powerful event delegation, keyboard modifiers, debounce/throttle, and automatic event handling features.
Use @on when you need:
- Event delegation with CSS selectors
- Keyboard modifier matching (
Enter,ctrl+s, etc.) - Debounce or throttle
- Multiple events on one handler (accepts
string[]:@on(['mouseenter', 'focus'])) - Automatic preventDefault or stopPropagation
Basic Controller Usage
Event Delegation with Selector
Three delegation rules worth knowing:
currentTargetis the listener's host, not the matched element. Inside
@on('click', '.delete-button') the handler receives the raw event, so
derive the match with event.target.closest('.delete-button') when you need
the element itself (as above).
- Delegation matches in both trees by default. The selector matches
elements in the component's shadow tree and its light-DOM children. A click
on content slotted into a matching shadow wrapper matches that wrapper.
Narrow the search with the light/shadow options — the same tree toggles
@query uses (see @on Options).
- Shadow DOM retargeting changes what the selector matches. An event
crossing a shadow boundary is retargeted to the shadow host, so
@on('row-clicked', 'my-row') stops matching when rows move into a list
component — from outside, the event's target is the list host. A selector
never matches a child component's internals. Listen on the container and
carry the row identity in the event detail instead.
Keyboard Events with @on
@on Options
Numeric intervals remain supported. A resolver can instead read a value from
the decorated element or controller instance. For @on, it runs when the
managed listener is set up and again when it is set up after reconnect. The
resolved value must be a finite, non-negative number; 0 disables that timing
option, while invalid, negative, and NaN values throw TypeError.
light / shadow — choosing the tree
light and shadow are the same tree toggles @query uses. Both default to
true, so @on hears events from the shadow tree and the light DOM alike.
Set one to false to narrow the listener:
For direct handlers the flags choose where the listener attaches: shadow
controls the shadow-root listener, light controls the host listener. For
delegated handlers they choose which tree(s) the selector matches in. Setting
both to false warns and skips the listener, and the flags are ignored (with
a warning) when scope or daemon is set — those own the listener target
outright.
scope — controlling the listener target
By default, @on attaches the listener to the host element. The scope option redirects
that attachment to another target, which is how Snice expresses cross-cutting events.
scope value | Listener attaches to | |
|---|---|---|
| omitted | host element (default) | |
'global' | document | |
| selector string | host.closest(selector) — nearest matching ancestor | |
Element / EventTarget | that node directly | |
| `(this) => EventTarget \ | null` | called at connect; null skips |
The resolver function is called with the host element as this and re-resolves each time
the component reconnects to the DOM, so listeners follow the host when it moves.
If scope cannot resolve (selector matches no ancestor, resolver returns null or throws),
the listener is not attached and a console.warn is emitted. The component still
mounts; only the listener is skipped.
Disconnect removes the listener from whichever target it was attached to. Reconnect
re-resolves and re-attaches, so resolver-based scopes track DOM moves correctly.
scope is compatible with the delegation selector — the listener attaches on the
scoped target and still matches the selector when an event fires within it.
daemon is a separate, non-DOM target. It cannot be combined with scope and does
not support selector delegation. See Daemons.
Throttling
Debouncing
Per-instance interval:
Use method syntax (or a normal function), not an arrow, when reading this.
Controllers receive the controller instance as this; elements receive the
element instance.
Using @on in Elements (Alternative)
While template syntax is preferred, @on can also be used in elements:
Note: For new element code, prefer template event syntax for better readability and type safety.
@dispatch Decorator
Auto-dispatch custom events after method execution:
Basic Usage
Usage:
Event Options
Events dispatched by @dispatch default to bubbles: true and composed: true (crosses shadow DOM boundaries). Override if needed:
DispatchOptions
EventTiming is the same number | ((this: any) => number) type used by
@on. @dispatch resolves it against the decorated instance on every method
invocation, so an element or controller can change its interval at runtime.
The same finite, non-negative validation applies. Debounced async methods still
dispatch only after the method resolves. Disconnect synchronously drops timed
dispatches already queued and decorated async invocations that started before
disconnect. Lifecycle hooks always run with the real element as this; an
@dispatch method called by teardown code after that cancellation is a new,
ordinary invocation (and, after reconnect, participates in the current timing
state).
Each invocation supersedes pending timed work for that decorated method. A
resolved 0 therefore cancels an older debounce/throttle timer and dispatches
the new result without delay. For throttle, a suppressed invocation replaces
the trailing detail and recalculates its deadline as the last actual dispatch
plus the newly resolved interval.
scope — controlling the dispatch target
By default, @dispatch originates from the element or a controller's host; on a
daemon it uses that instance's private target. The scope option redirects a DOM dispatch so the
event behaves as if it originated there. Use this with @on({ scope }) to express
cross-cutting events without going through bubbling.
scope value | Event dispatched on | |
|---|---|---|
| omitted | host element (default) | |
'global' | document | |
| selector string | host.closest(selector) — nearest matching ancestor | |
Element / EventTarget | that node directly | |
| `(this) => EventTarget \ | null` | called per dispatch; null skips |
If scope cannot resolve (selector matches no ancestor, resolver returns null),
the event is not dispatched and a console.warn is emitted. The method's return
value still flows through dispatchOnUndefined / debounce / throttle semantics
before the scope check.
Use { daemon: 'session' } to dispatch on an explicitly provided daemon's private
communication target. daemon and scope are mutually exclusive. See
Debounce/Throttle
Async Methods
@dispatch works with async methods — the event dispatches after the promise resolves:
Multiple Events
Event Bus
Snice has no bus object and no singleton. A bus is @dispatch publishing upward and
@on subscribing at a chosen ancestor -- the scope is a DOM node, so it is created and
torn down with the DOM.
There are two ways to publish, and the choice decides who can hear it.
Bubble up from the host. @dispatch is bubbles: true, composed: true by default, so
the event crosses shadow boundaries and passes every ancestor on its way to document.
Use this when the event is about this element and ancestors may care.
Dispatch directly on a scope. @dispatch takes the same scope option as @on, which
fires the event on that target instead of bubbling from the host. Use this when the
subscriber is not an ancestor -- a sibling subtree, or a host that may be detached.
Subscribe at the scope you want to share.
Choosing a scope:
| Reach | scope | Use when |
|---|---|---|
| Whole document | 'global' | Genuinely app-wide: auth expiry, theme change, save shortcut |
| A feature subtree | selector string | The event belongs to one shell and must not leak to a sibling instance |
| A specific node | EventTarget / resolver | You already hold the node, or the target moves and must re-resolve |
Prefer the narrowest scope that works. 'global' means every instance on the page hears
every message, which is what makes singleton buses hard to reason about.
Match the two halves. A bubbling publish only reaches subscribers that sit on the host's
ancestor chain; if the subscriber lives in a sibling subtree, scope the dispatch too so
both meet on the same node. See
Name events so the routing is visible at the call site -- the bus: prefix above is a
convention, not a framework feature. Any string works.
Teardown is automatic: the listener is removed on disconnect from whichever target it
resolved to, and re-resolved on reconnect, so subscribers follow the host when it moves.
If a selector scope matches no ancestor the listener is skipped with a console.warn
rather than silently binding to the wrong node. See
scope for the full resolution table.
For a request that needs an answer rather than a broadcast, use
@request / @respond instead.
For app-owned state with an explicit lifecycle, provide an @daemon instance and
use { daemon: 'name' } on both publishers and subscribers. This keeps consumers
decoupled from the implementation class without introducing a global singleton.
See Daemons.
Custom Events
Prefer @dispatch for a static custom event emitted from a Snice element or a
controller's host. The decorator supplies the standard bubbling and composed
behavior, uses the method return value as detail, and keeps debounce,
throttle, and scope declarative.
Manual dispatch remains valid when code needs direct access to the Event
object, a dynamic event name, the cancellation boolean returned by
dispatchEvent(), or a target other than the Snice host.
Cancelable Events (Suppressible Default Actions)
cancelable is an EventInit field, so `@dispatch('my-event', { cancelable:
true })` really does produce a cancelable event and a listener really can call
preventDefault() on it.
What the decorator cannot give you is the answer. @dispatch returns the
method's own return value — the event detail — not the boolean
dispatchEvent() produces, so the component has no way to find out that a
listener cancelled the event. A component whose event carries a **default
action it must be able to suppress** therefore dispatches by hand:
An application then chooses between the two behaviors with one line:
Components in the library that use this pattern: snice-link's navigate,
snice-stepper's step-change, and snice-calendar's calendar-more-click.
Manual Escape Hatch
Listening to Custom Events
Event Delegation
Controller Event Delegation
Template Event Delegation
For dynamic content, use controllers with @on for event delegation, or handle events on a parent element:
Keyboard Shortcuts
Template Syntax (Preferred)
@on Decorator Syntax
Controllers API Documentation
Controllers hold application behavior specific to a set of elements, including
their data fetching, business rules, and server communication. They can be
attached to any HTML element, including native elements.
Visual behavior belongs in elements, application behavior specific to a set of
elements belongs in a controller, and element orchestration belongs in pages.
Do not attach a controller to the page host. A host-free reusable function may
stay a plain module wherever the project keeps it. URL/query parsing belongs in
@page({ routes }), not in a controller.
Basic Usage
Creating a Controller
Attaching Controllers
Bind the controller class directly in a template — this is the preferred way:
Class bindings skip the registry lookup: the imported class is attached as-is.
The @controller('name') decorator is still required — it registers the class,
marks it, and flushes pending attachments. Re-binding the same class reference
is a no-op; binding a different class (or null) detaches the old controller
first. While a class is bound, the class binding owns the element: controller
attribute writes cannot switch it until the class is unbound.
For inspection in DevTools, Snice reflects the decorator name as a
controller="name" attribute while the class is attached. This marker is
diagnostic only: it does not resolve the registry or create a second
attachment. Treat it as read-only; the class reference remains authoritative.
Snice removes the marker when the class detaches or replaces it when another
controller is bound.
An element hosts at most one controller. Attaching a different controller
always detaches the current one; this is an architectural 1:1 relationship,
not only a template rebinding detail. Put independent controller behaviors on
separate host elements (or intentionally compose them inside one controller).
Imperative equivalents:
Attaching by Name (strings)
Every controller is also registered under its decorator name, and attaching by
string remains fully supported. It is the only channel available in raw HTML
markup, where attributes are all you have:
Strings also work in template bindings — controller=${'user-controller'} and
interpolated forms like controller="user-${kind}" behave exactly like the
static attribute.
IController Interface
Controller Lifecycle
One controller per element. An element hosts at most ONE controller at a
time — binding a different class or null detaches the current one first.
Combined with the rule that a page host may not carry a controller, this means
a resource with several endpoints cannot be split one-controller-per-endpoint
on a single host. The workable pattern: attach each mutation controller to its
own trigger element (a button, a form, a region) and let them share state
through the element's reactive properties or events.
Attachment Flow
- Controller instance is created
elementproperty is set- Router application context is passed (if available)
- Channel/response handlers (
@respond) are set up - Element's
readypromise is awaited attach()method is called@contexthandlers are registered and caught up with the current Router context- Observers are set up
- Event handlers are set up
controller-attachedevent is dispatched
The step-5 wait has one safe exception: when an element calls
await attachController(this, ControllerClass) from its own @ready handler,
Snice attaches immediately. Initial rendering has already completed at that
point, and waiting for ready would otherwise create a self-deadlock because
ready cannot settle until the current handler returns. Attaching to any
other element still awaits that element's ready promise.
This runtime safeguard does not make attaching a controller to a routed page a
good architecture; pages should orchestrate directly.
Responders Are Reachable Before the Host Is Ready
@respond handlers are installed at step 4, before the wait — the same
ordering elements use, where @respond is installed on connection rather than
at ready. This matters for the common shape where a host's @ready waits on
a child that makes a request as it mounts:
With <report-host controller="report-controller"> answering report-data, the
child's request reaches the controller while the host's ready is still
pending: discovery resolves immediately, the request releases the step-5 wait,
and the responder runs once attach() and the remaining setup have completed.
If the responder were only installed after ready, the two sides would wait on
each other until a timeout fired and the attachment would fail outright.
The responder body still never runs before attach(), so anything the
controller builds in attach() is safe to use inside a @respond method.
@respond('name', { daemon: 'x' }) has one extra condition. A daemon target is
resolved through the host's app context, which is unreachable while the host is
still disconnected — the shape where attachController(el, Controller) runs
before el is appended. When the context already resolves, the daemon-scoped
responder registers at step 4 with every other responder; when it does not, it
registers after attach() instead of being dropped, exactly where it registered
before.
Detachment Flow
detach()method is calledelementproperty is set to null- Observers are cleaned up
- Channel/response handlers are cleaned up
- Event handlers are cleaned up
@contexthandlers are cleaned up- Controller scope is cleaned up
controller-detachedevent is dispatched
Example with Lifecycle Logging
Native Element Controllers
Native element controllers are enabled automatically when Snice loads in a browser environment. No setup is required.
You can attach controllers to any HTML element:
Example: Table Controller
Controllers provide specific behaviors (data fetching, sorting, filtering) to generic visual components. The component handles rendering — the controller handles data:
Resource Cleanup
The framework auto-cleans @on, @observe, @respond, and @context handlers. Clean up your own resources (WebSockets, timers, manual listeners) in detach:
Event Handling in Controllers
Controllers can use the @on decorator to handle events from their attached element:
Query Selectors in Controllers
Controllers can use @query and @queryAll to access elements. Important: By default, @query searches the shadow DOM. When attached to native elements (no shadow root), use { light: true }:
Advanced Patterns
Data Fetching Controller
A data-fetching controller is appropriate when the fetch is application behavior
specific to the elements it controls. Pass state through the element's public
API and dispatch outcome events — do not manipulate its rendered DOM. A
production controller should also prevent an older response from overwriting a
newer one:
@context() works on controllers as well as elements. It receives the same
long-lived Context instance, including application, navigation state, and
the Router's middleware-aware fetch. Managed decorators are activated after
attach(), so start context-dependent work in the @context() handler (or in
an event handled later), not in attach().
@context() is a subscription, not a one-shot: the handler fires on every
context update for as long as the controller is attached. The initial "caught
up" delivery is only the first one. Guard first delivery (as the example
does), pass { once: true }, or diff the update — but never start unguarded
work in the handler body.
Observing a host property
A controller has no host-property watcher: @observe covers
Intersection/Resize/Media/Mutation only, and attachController installs none.
The working pattern is for the owning element to announce the change with
@dispatch and the controller to listen with a plain @on (direct handlers
already listen on the host element):
Carry every synchronously-written value in the event detail. A .prop
binding commits a microtask later, so reading this.element.filter inside the
handler would read the PREVIOUS value.
The element owns presentation for every state:
This separates responsibilities cleanly: the controller owns transport,
cancellation, stale-response protection, and outcome events; the element owns
loading/error/empty/data rendering. A retry button can request reload() via a
small event handled by the controller, or application code can retrieve the
controller and call its public method.
Theme Controller
WebSocket Controller
Accessing Controllers
Via Event
Listen for attachment on the element itself (the event does not bubble):
Auto-Cleanup
The framework automatically cleans up @on handlers, observers, and @respond handlers during detach. Manual cleanup in detach() is only needed for resources you manage yourself (WebSockets, intervals, manual event listeners).
Daemons
Daemons are ordinary, explicitly constructed objects with state and an
application-owned lifecycle. The @daemon decorator gives each instance a
private communication target so elements, controllers, and daemons can use
Snice's two communication models:
@request/@respondfor one request and one asynchronous response.@dispatch/@onfor ephemeral notifications with zero or more listeners.
@daemon does not construct, cache, globally register, start, or stop
anything.
Define and provide a daemon
The context key is the daemon's address. The class decorator takes no name,
avoiding two sources of truth.
Application contexts expose only this daemon surface to Snice:
They may still contain application-specific state such as user, theme, or
configuration. Extend AppContext with those fields to give application code
their concrete types.
Router integration
Router provides its context beneath its target before rendering a page.
No separate provideContext() call is needed:
provideContext(root, context) is the same public mechanism used internally
by Router. Use it for applications without Router and for isolated tests. It
returns an idempotent cleanup function.
getContext(elementOrController) returns the raw application context visible
to that participant. It is separate from the method-form @context(), which
receives Router navigation updates.
Communicate from elements and controllers
Consumers use the context address and never import the daemon implementation:
Controllers resolve the context through their attached host element, so the
same syntax works in controller methods.
The reverse direction is also supported. A daemon's @request dispatches on
its own communication target, while an element or controller may install a
responder there:
As with DOM-scoped request channels, only one responder should own a daemon
request channel.
Resolution and lifecycle
Resolution has one path:
There is no global fallback, implicit construction, registry scan, or delayed
registration.
- Construct every daemon explicitly with
new. - Provide the context before connecting elements or attaching controllers.
@on and @respond install listeners at those lifecycle boundaries.
@requestand@dispatchresolve their daemon when invoked.- Disconnecting an element or detaching a controller removes its daemon
listeners automatically.
- Releasing the context deactivates its daemon communication. A later provision
starts with a fresh event target.
- The same daemon class may have any number of independently provided
instances.
- The same name may resolve to different instances under different context
roots.
Missing contexts, missing daemon names, undecorated values, and inactive
daemon instances produce explicit errors or setup warnings. daemon and DOM
scope options are mutually exclusive. Selector delegation is not available
on a daemon target because it is not a DOM tree.
Testing
Each test owns its instance, context root, and cleanup:
No shared singleton state or framework reset API is involved.
Routing
Creating a router, defining pages, and moving between them.
| Topic | Documented in |
|---|---|
| Protecting routes, wrapping pages, page transitions | Guards and Layouts |
| Page metadata for navigation | Placards |
| Context-aware fetch | Fetcher |
Router Setup
Creating a Router
Router Options
Router Context
The context object provides shared state across all pages and layouts:
Router provides this application context beneath its target before it connects a
page. That includes explicitly constructed daemon instances:
This uses the same provideContext(root, context) mechanism available to apps
without Router. Descendant elements and attached controllers address the instance
as { daemon: 'session' }; they do not import SessionDaemon. See
That provider boundary supplies the full Router Context to @context()
methods on descendant elements and attached controllers. They use ctx.fetch
with the configured request/response middleware without importing the router
or adding a reserved field to the application context. getContextFetch() is
the lower-level transport-only lookup for explicit non-router providers.
Page Components
Basic Page
Page with Context
The @context() decorator is a method decorator that receives context
updates from the router on pages, descendant elements, and attached
controllers. The method is called whenever navigation occurs, with a Context
object containing application state and navigation data.
Context Options
The @context() decorator accepts optional timing and behavior controls:
Context Object Structure
The Context object passed to @context() methods has the following structure:
Example:
Triggering Context Updates
When you modify the application context, call update() to signal all subscribers:
Note: The router automatically signals @context() subscribers during navigation. Only call update() manually when changing application state outside of navigation (login, logout, theme changes, etc.).
Route Configuration
@page Decorator Options
Multiple Routes
Routes are sorted by specificity first. If specificity ties, registration
order wins, including the order of plain strings in one routes array. Keep
that compact syntax for normal pages:
Object notation is optional. Use it only when a route needs an explicit
tie-break across registrations; lower order values match first. Equal or
omitted values still preserve registration order.
Route with Parameters
Multiple Parameters
Navigation
Hash Navigation
Pushstate Navigation
Back/Forward Navigation
Route Parameters
Accessing Parameters
Route parameters are automatically mapped to element properties — a :param
segment or named splat such as *path (including an optional splat) normally
binds to a plain @property() of the same name. A field declared
@property({ attribute: false }) is opted OUT of route-param binding: the
Router never sets it and it silently keeps its initializer. (The Router sets
params through attributes; attribute: false fields have none.)
The parameter spelling must reach the property's observed attribute. On an
HTMLElement page, :articleId reaches a plain articleId property through
the lowercased articleid attribute. A SniceElement page uses kebab-case
implicit attributes, so its plain articleId property needs :article-id (or
an explicit @property({ attribute: 'articleId' })). Explicit aliases follow
the same rule: @property({ attribute: 'article-id' }) articleId binds from
:article-id, not :articleId.
A reflected native HTMLElement attribute such as id is already reachable
when no Snice property overrides it. An explicit
@property({ attribute: false }) id or differently aliased id overrides that
native channel, so :id no longer populates the property. A statically declared
observedAttributes entry paired with attributeChangedCallback can also
consume a route attribute.
Inheritance follows Snice's decorator transformation: a subclass @state()
member disables an inherited @property() channel of the same name, while a
plain field initializer or authored accessor still uses the inherited
transformed property accessor and remains bindable.
Multiple Parameters
Query Parameters
Define query parameters directly in the route pattern — they are extracted as route params automatically:
Advanced Patterns
Lazy Loading Pages
Nested Routing
Route-Based Data Loading
Breadcrumb Navigation
Error Page (404)
Protected Route Pattern
Router API Reference
Router()
navigate()
Navigates to the specified path. Uses hash (#) or pushstate depending on router type.
Do not combine @page with @element on the same class. @page already
registers the custom element and applies Snice element behavior.
initialize()
Initializes the router and starts listening for route changes. Must be called after all pages are defined.
register()
Manually register a route without using the @page decorator.
Guards and Layouts
Deciding whether a route may render, what wraps it, and how it animates in.
The router itself is covered in Routing.
Route Guards
Guards protect routes and can redirect unauthorized access:
Basic Guard
Multiple Guards
Guard with Redirect
When a guard returns false, the router renders the /403 page (if registered) or a default 403 message. Guards can also trigger side effects like redirects:
Permission Guard
Guards are synchronous — pre-load permissions into context before navigating:
Layouts
Layouts wrap pages with shared UI like headers, footers, and navigation:
Creating a Layout
Using a Layout
Layout Interface
Conditional Layout
Different pages can use different layouts or no layout:
Page Transitions
Global Transitions
Page-Specific Transitions
Built-in Transitions
Custom Transitions
Transitions use inline CSS property strings for out (leaving) and in (entering) states:
The out string is the end state of the leaving page. The in string is the end state of the entering page (which starts invisible and transitions to this state).
Request/Response API Documentation
Request/Response provides request/response communication between elements,
controllers, and explicitly provided daemons using async generators.
Why Request/Response?
Components are generic. A <product-card> renders a card — it doesn't know or care whether its data comes from a REST API, a GraphQL endpoint, a WebSocket, or a test fixture. Controllers are specific. They wire a particular data source, API, or business rule to a generic component.
The @request/@respond pattern keeps this separation clean:
- The element says what it needs (e.g., "I need product data for this ID") without knowing how to get it.
- The controller decides how — makes the API call, applies business logic, caches results, whatever is needed.
- Swapping controllers changes behavior without touching the component. Attach a mock controller for tests, a real API controller in production, or a WebSocket controller for live updates — the element is the same.
This makes components reusable across projects and testable in isolation. An element with @request('fetch-product') works with any controller that @responds to 'fetch-product' — no imports, no interfaces, no coupling.
Basic Concept
Request/Response enables a single request/response round-trip between elements and their controllers:
- Element yields a request payload — "here's what I need"
- Controller receives the payload and returns a response — "here's the data"
- Element receives the response and updates its visual state
This pattern is implemented using async generators and custom events. Each @request method supports one yield per invocation — the generator yields a request payload, the controller responds, and the generator receives the response.
Request/Response Decorators
Signature
TypeScript cannot model a method decorator changing an async generator into a
promise-returning method. Response<T> is the pragmatic annotation for that
boundary: it keeps strict projects usable while documenting the response value
for readers and tooling. At runtime, calling the decorated method returns a
promise for T.
Response Debounce/Throttle
Response handlers can be debounced or throttled:
Element-Side Requests
Elements use async generators to make requests. The element stays visual — it yields data up to the controller and renders the response:
How it works:
yield { id: this.productId }dispatches a bubbling custom event with the payload- A
@respond('fetch-product')handler (typically in a controller) catches it and returns data await (yield ...)resolves with the response- The element updates its properties, triggering a re-render
Controller-Side Responses
Controllers handle requests — this is where business logic, API calls, and data management belong:
Architecture: Elements stay visual and yield requests upward. A controller
owns application behavior specific to the elements it controls; the page still
owns element orchestration.
Request/Response Options
Timeout Behavior
The timeout system has two separate timeouts:
- Discovery timeout (
discoveryTimeout): 50ms default — finds a handler quickly - Response timeout (
timeout): 2 minutes default — total time for the response
Debounce/Throttle
Error Handling
Element-Side
Controller-Side
Advanced Patterns
Cached Responses
Subscription Pattern
Use @request for one-time fetches and @dispatch + @on for ongoing updates:
Using Without Decorators
For vanilla JS or React code that needs to respond to @request channels without using the decorator system.
Vanilla JS: createRequestHandler
Options:
| Option | Type | Default | Description |
|---|---|---|---|
passive | boolean | false | When true, doesn't stop event propagation (allows multiple handlers) |
React: useRequestHandler
For full documentation, examples, options, and global handler patterns, see the React Integration guide.
Route callbacks are ref-stable — no useCallback needed. Listeners re-attach only when channel names change. Cleanup is automatic on unmount.
Observe API Documentation
The @observe decorator provides lifecycle-managed observation of external changes like viewport intersection, element resize, media queries, and DOM mutations.
Overview
The @observe decorator automatically manages browser observers with proper cleanup, preventing memory leaks and simplifying complex observation patterns.
Array Syntax
You can observe multiple types with a single handler using array syntax:
Intersection Observer
Detect when elements enter or leave the viewport. Perfect for lazy loading, infinite scroll, and animations.
Basic Usage
Options
threshold: Number or array (0-1) defining visibility percentage to triggerrootMargin: Margin around root to expand/shrink observation arearoot: Element to use as viewport (defaults to browser viewport)
Stopping Observation
Return false from the handler to stop observing that specific element:
Resize Observer
Monitor element size changes for responsive components.
Basic Usage
Options
box:'content-box'or'border-box'(which box model to observe)throttle: Milliseconds to throttle resize callbacks
Media Query Observer
Respond to viewport and user preference changes.
Basic Usage
Important Notes
- Handler is called immediately with current state when observer is set up
- Media query strings use standard CSS media query syntax
- Media queries are cached globally for efficiency
Mutation Observer
Watch for DOM changes like added/removed nodes or attribute modifications.
Basic Usage
Mutation Types
mutation:childList- Observe added/removed child nodesmutation:attributes- Observe all attribute changesmutation:attributes:name- Observe specific attribute changes
Options
subtree: Also observe descendants (use with caution for performance)throttle: Milliseconds to throttle mutation callbacks
Safety Features
subtree: trueis not enabled by default to prevent performance issues- Character data mutations are not supported (too granular)
- Always be specific about what you're observing
Using with Controllers
Controllers can also use @observe for separation of concerns. When used in controllers, observers operate on the attached element:
Options
All observer types share a single options interface. Pass only the fields relevant to the observer type you're using:
Best Practices
1. Be Specific
2. Use Throttling for High-Frequency Events
3. Stop Observing When Done
4. Avoid Deep Subtree Observation
5. Use Media Queries for Responsive Design
Lifecycle and Cleanup
All observers are automatically:
- Set up when element connects to DOM
- Cleaned up when element disconnects
- Re-established if element is moved in DOM
Disconnecting stops more than the observer itself. A throttled callback holds
a timer for its trailing edge, and that timer is cancelled on disconnect too —
so a callback queued moments before removal never runs against the disposed
element. A disconnected element schedules nothing.
No manual cleanup is required - the framework handles everything:
Performance Considerations
- Browser Support: Observers check for API availability and warn if unsupported
- Shared Observers: Media queries are cached globally
- Automatic Throttling: Built-in throttle option prevents callback flooding
- Memory Management: Proper cleanup prevents memory leaks
- Error Isolation: Errors in one observer don't affect others
Examples
Virtual Scrolling
Responsive Dashboard
Dynamic Form Fields
Fetch Middleware
Snice provides a context-aware fetch implementation with middleware support, allowing you to intercept and modify HTTP requests and responses globally across your application.
Overview
The ContextAwareFetcher class enables you to:
- Add authentication headers automatically to all requests
- Handle errors consistently across your application
- Log HTTP requests and responses
- Transform requests or responses
- Implement retry logic
- Add request/response timing metrics
- Access application and navigation state in middleware
Basic Usage
Pages, descendant elements, and attached controllers receive the Router's
long-lived Context through @context(); its ctx.fetch is the bound,
middleware-aware function. getContextFetch(this) remains the lower-level
lookup for code using an explicit non-router
provideContext(root, appContext, { fetch }) boundary.
Using Context.fetch in Pages, Elements, and Controllers
Once configured, ctx.fetch is available in all pages and components that use the @context decorator:
Controllers use the same decorator. Managed decorators are activated after
attach(), so start context-dependent work from the handler:
Middleware Types
Request Middleware
Request middleware runs before the actual fetch call. It receives the Request object and can modify it before the request is sent.
Signature:
Common use cases:
- Adding authentication headers
- Modifying request URLs (e.g., adding base URL)
- Logging outgoing requests
- Adding custom headers (CSRF tokens, API keys, etc.)
- Request validation
Example - JWT Bearer Token:
this in middleware is the Context instance. Store auth state in application (your AppContext):
Example - Request Logging:
Response Middleware
Response middleware runs after the fetch call completes. It receives the Response object and can inspect or transform it.
Signature:
Common use cases:
- Error handling based on status codes
- Response transformation
- Logging responses
- Caching
- Performance metrics
- Retry logic
Example - Error Handling:
Example - Response Logging:
Example - Performance Metrics:
Accessing Context
Middleware functions have this bound to the Context instance, giving you access to:
this.application- Application-wide state (user, config, theme, etc.)this.navigation- Navigation state (current route, route params, placards)this.id- Unique context instance ID
Example - Context-Aware Error Handling:
Middleware Execution Order
Middleware executes in the order it's registered:
- Request middleware runs in registration order (first registered = first executed)
- Actual
fetch()call happens - Response middleware runs in registration order
Example:
Complete Example
Here's a complete example with authentication, error handling, and logging:
Important Notes
Context is Long-Lived
The Context instance is created once per Router and persists for the entire application lifetime. This means:
- Middleware is configured once at application startup
ctx.fetchis initialized once and reused- Middleware can safely reference
this.applicationandthis.navigationas they update in place
Modifying Request Headers
The Request object's headers property is mutable — you can call request.headers.set() directly in middleware:
No Fetcher Means Native Fetch
If no fetcher is provided to the Router, ctx.fetch defaults to the native fetch function bound to the Context instance:
API Reference
ContextAwareFetcher
Constructor:
Methods:
use(type: 'request', middleware: RequestMiddleware): void
Add request middleware that runs before the fetch call.
use(type: 'response', middleware: ResponseMiddleware): void
Add response middleware that runs after the fetch call.
create(ctx: Context): typeof globalThis.fetch
Create a fetch function bound to the given Context instance. This is called internally by the Router.
Type Definitions
Notes
- Configure middleware at startup — don't add middleware inside pages, as it would duplicate on each navigation.
- Clone responses before reading — streams can only be read once:
```typescript
fetcher.use('response', async function(response, next) {
const clone = response.clone();
const text = await clone.text();
console.log('Response body:', text);
return next(); // Original response still has readable body
});
```
- Always call
next()— every middleware must call and returnnext()to continue the chain.
Placards API
Placards provide rich metadata about pages that layouts can consume to dynamically build navigation, breadcrumbs, help systems, and other UI elements. This enables layouts to be populated with data instead of having hardcoded content.
Overview
The Placard system allows pages to declare metadata that describes their purpose, structure, and behavior. Layouts can then query this metadata to automatically build:
- Dynamic navigation menus
- Hierarchical breadcrumbs
- Context-sensitive help
- Search functionality
- Keyboard shortcuts
Basic Usage
Define a placard for your page using the placard option in the @page decorator:
Placard Interface
Field Reference
Identification
name (required)
- Unique identifier for this placard
- Used for referencing in breadcrumbs and parent-child relationships
- Should be kebab-case, e.g., 'user-settings', 'admin-dashboard'
Core Display
title (required)
- Display name shown in navigation and breadcrumbs
- Should be concise and descriptive
href (optional)
- URL used as the anchor
hrefwhen this placard renders in nav or breadcrumbs - Consumer controls routing mode — use
#/pathfor hash routing,/pathfor pushstate, full URLs for external links - If omitted, links render with empty href
description (optional)
- Longer description of the page's purpose
- Used in tooltips, search results, or help text
icon (optional)
- Visual icon representing the page
- Can be emoji, icon font class, or SVG path
Help & Discovery
tooltip (optional)
- Brief help text shown on hover
- Explains what the page does or when to use it
searchTerms (optional)
- Additional keywords for search functionality
- Helps users discover pages through alternate terms
hotkeys (optional)
- Keyboard shortcuts to navigate to this page
- Uses standard key notation
helpUrl (optional)
- Link to detailed documentation or help for this page
Navigation Structure
group (optional)
- Logical grouping for navigation organization
- Pages with the same group are displayed together
parent (optional)
- References another placard's
nameto create hierarchy - Used for nested navigation and breadcrumb construction
order (optional)
- Numeric sort order within the group or parent
- Lower numbers appear first
show (optional)
- Whether to display this page in navigation menus
- Defaults to
trueif not specified
Dynamic Visibility
visibleOn (optional)
- Guard functions that determine if the placard appears in nav
- Can return
booleanorPromise<boolean> - Sync guards: evaluated on every render
- Async guards: placard is hidden until the promise resolves
true; silently hidden onfalseor rejection - Multiple guards must all pass (AND)
Extensibility
attributes (optional)
- Arbitrary metadata for custom layout needs
- Domain-specific or framework-specific data
Hierarchical Navigation Example
Breadcrumb Resolution
Breadcrumbs can be automatically resolved using the parent hierarchy or explicitly defined:
Layout Integration
Layouts can access placard data to build dynamic UI. The exact mechanism depends on your router implementation, but typically involves:
- Router Context - Placards available through router context
- Navigation Builder - Helper functions to build nav from placards
- Event System - Layouts listen for route changes and update UI
Building Navigation from Placards
Building Breadcrumbs
Styling
Scoped CSS, host styling, dynamic styles, and icons. Theme tokens are documented in Theming.
@styles() Decorator
Returns CSS using the css tagged template, scoped to the element's shadow DOM.
Note: Only one @styles() method is supported per element. If multiple are declared, only the last one is used. Combine all styles in a single method:
Scoped Styles
Styles are automatically scoped to the component's shadow DOM:
Dynamic Styles
@styles() is called once during initialization and does not update on property changes. For dynamic styling, use CSS custom properties set in the template:
Host Styling
Icons
Many Snice components accept an icon property (or prefix-icon / suffix-icon for inputs). The icon value is auto-detected:
| Value | Rendered As |
|---|---|
"search", "check_circle" | Ligature icon with icon font |
"🔍", "$" | Text/emoji as-is |
"https://example.com/icon.svg" | <img> element |
"logo.png", "icon.svg" | <img> element |
"img://url" | Explicit <img> |
"text://content" | Explicit <span> |
Changing the Icon Font
By default, ligature icons (lowercase words like search, home, check_circle) use Material Symbols Outlined. To use a different icon font like Font Awesome, set the --snice-icon-font CSS custom property:
Make sure to load the corresponding font in your HTML:
Icon Slots
For full control over icon rendering (e.g., using a specific icon library class), use named slots instead of the icon attribute:
Theming
Every Snice component is styled entirely through CSS custom properties. Override a token and every component that uses it follows — no component code changes, no build step.
Load the stylesheet once:
Component-level styling — @styles(), host styling, and icons — is covered in
Styling. The full token table lives in the
Color Format
Colors are stored as HSL channels, space separated: hue saturation% lightness%. Tokens hold the raw channels so they can be reused with any alpha:
Use hsl(0 0% 0% / 0.15), not the legacy hsla(0, 0%, 0%, 0.15).
Primitives and Semantic Tokens
There are two layers, and you almost always want the second.
Primitives are raw scales — gray, blue, green, red, and yellow, each running 50 (lightest) through 950 (darkest). They do not change between light and dark.
Semantic tokens describe a role, and they do change with the theme:
Override semantic tokens to rebrand; override primitives only to change the underlying palette.
The complete token list
Every token — colors, surfaces, borders, interaction overlays, motion, shadow
glows, state-aware focus rings, density, texture, print, spacing, typography,
radius, and layers — is tabulated in the
theme reference. That page is the exhaustive list;
this one explains how the system fits together.
Dark Mode
Set data-theme on the root element. Semantic tokens switch; primitives do not.
Because components reference semantic tokens only, dark mode requires no component changes. Always check both themes when you override tokens — a colour that passes contrast on a light surface often fails on a dark one.
Native form controls are corrected with a filter token:
It is applied to ::-webkit-calendar-picker-indicator at 0.7 opacity, rising to 1.0 on hover.
Overriding Tokens
Scope an override wherever you need it — globally, or on a subtree:
Every component inside .checkout picks up the local value, because tokens inherit through the shadow boundary.
CLI
Snice ships a command line tool for scaffolding projects, diagnosing setup, validating source, and serving documentation to AI agents.
Commands
| Command | Purpose |
|---|---|
create-app <path> | Create a complete vanilla or React application |
init-ai [path] | Install the version-matched Snice skill and agent pointers |
check [path] | Run all package, configuration, and source checks |
doctor [path] | Diagnose configuration, imports, dependencies, and AI setup |
validate [path] | Run the source analyzer only |
generate-component <name> | Print a current element scaffold |
build-component <name> | Build a CDN bundle from a Snice source checkout |
Creating a Project
The default template is vanilla Snice with routing and a build already configured. --template=react scaffolds the React adapter setup described in React Integration.
Checking a Project
check is the broad gate — it runs the package and configuration checks and the source analyzer together.
Run the halves individually when you want a narrower signal:
doctor reports on the things that silently break a Snice project: decorator configuration in tsconfig.json, a bundler that cannot handle decorators, missing peer dependencies, and whether the Snice skill is installed for your coding agent.
validate runs the analyzer over your source. It catches mistakes that compile but never work, including:
- an
@element-decorated class that does not extendHTMLElement(or a Snice element subclass) — Snice registers and renders only element subclasses - deep imports that were never released package paths, such as
snice/decorators - a Router without
target,type, or a project-wideinitialize()call - a routed class combining
@pagewith redundant@element - a path/query
:paramor named*splatwhose page has no reachable attribute
target (snice/route-param-has-no-binding-target, warning), including
attribute: false and mismatched explicit aliases
- a controller
@context()handler that starts load/reload/refresh/fetch work
without { once: true }, a first-delivery guard, or an update diff
(snice/unguarded-context-load, warning)
The route-param check follows proven local declarations, direct relative
re-exports, and named or namespace Snice imports, so an inherited bindable
property satisfies it. It deliberately defers a missing-target warning for an
unresolved/ambiguous base or dynamic route/attribute contract; locally visible
disabled or mismatched properties are still diagnosed. Native reflected IDL
attributes such as an unmodified id, and statically known custom
observedAttributes handlers, also satisfy the attribute target.
The route-param rule is non-blocking because its dependency-free static lexer
cannot model every valid JavaScript/TypeScript grammar edge. Treat the warning
as a strong prompt to verify the Router attribute channel; validate and
check fail only when another error-level diagnostic is present.
It also gives non-blocking architecture suggestions: keep @page, @element,
@controller, and @daemon classes under src/pages, src/components,
src/controllers, and src/daemons; keep visual behavior in elements,
application behavior specific to a set of elements in controllers, and element
orchestration in pages. A host-free reusable function may remain a plain module
wherever the project convention places it.
Both accept --json for CI.
Diagnostic codes and .sniceignore
Every error, warning, and suggestion has a stable code in human and JSON
output. When a project intentionally accepts a finding, create .sniceignore
in the project root. Prefer the narrowest entry:
code path:line is also accepted. Paths are project-relative and entries are
exact rather than glob patterns. Suppressed diagnostics are omitted from both
text and JSON output and do not affect the exit status. The file applies to
check, doctor, and validate, including doctor codes such as
snice-skill.
AI Setup
This installs a skill matched to the Snice version in your project, so an agent reads the documentation for the version you actually have rather than whatever it remembers. See AI Assistance below.
AI Assistance
There are two ways to give a coding agent the Snice skill. Both install the same
skill; they differ in what it is scoped to.
Per project, from npm
Installs into the current project, matched to the Snice version that project has:
This writes .agents/skills/snice/ plus AGENTS.md and CLAUDE.md pointing at
it. Use --force to overwrite an existing install. Because the skill reads
node_modules/snice/docs/ai/, the agent always sees documentation for the
version actually installed — not whatever it remembers.
Per harness, from the repository
Installs globally for your agent, straight from the repo. Snice ships the plugin
manifests these harnesses expect, so no clone or build step is needed.
Claude Code
Antigravity
Gemini CLI
Kimi Code
Factory Droid
Prefer init-ai when you work on one Snice project and want the skill pinned to
its version. Prefer the repository install when you move between Snice projects
and want the skill always available.
Token-efficient copies of every reference page live in docs/ai/, mirroring these documents without the prose. Agents should read those instead of the human pages.
Generating a Component
Prints a scaffold using the current decorator conventions, so a new element
starts from correct code rather than a half-remembered example:
| Option | Meaning |
|---|---|
--props=name:type,… | Declared @property() fields. Types: string, number, boolean, array, object (default string) |
--events=name,… | A @dispatch() method per event |
--no-styles | Omit the @styles() block |
--out=<path> | Write to a file instead of stdout. Never overwrites |
Output goes to stdout by default, so you can review or pipe it. --out is the
explicit opt-in to writing, and refuses to clobber an existing file.
Building CDN Bundles
build-component is for building CDN bundles from a Snice source checkout; application projects do not need it.
| Option | Default | Meaning |
|---|---|---|
--output=<dir> | ./dist/cdn | Output directory |
--format=iife,es | iife | Output formats (table defaults to iife,es) |
--no-minify | off | Disable minification |
--with-theme | off | Include theme.css |
See CDN for how the resulting bundles are loaded.
Testing
Snice elements are custom elements, so they test in any DOM-capable runner. The only Snice-specific part is knowing when an element is ready to assert against.
The Two Promises
Rendering is asynchronous and batched, so assertions must wait.
| Promise | Await it | Resolves when |
|---|---|---|
el.ready | after mounting, before the first assertion | the first render is done and every @ready() handler has finished |
el.rendered | after writing a reactive property | the render queued by that write has been applied |
Awaiting ready on an element that never connects will hang; append it to the document first.
ready rejects when any @ready() handler throws or returns a rejected
promise. Always await it: the rejection points at the initialization failure
instead of allowing later assertions to fail against a half-initialized
element. Snice form controls also retain their native-input/proxy fallback in
DOM runners such as jsdom that expose only the ARIA subset of
ElementInternals.
Strict Render Errors
Rendering logs failures by default and retains the previous DOM. Tests that need a synchronous render failure to fail the assertion directly can temporarily enable strict mode:
Template parse and authoring errors identify the owning component by its authoritative registered tag and, when safely available, its class, and include a nearby static-template excerpt. Minified CDN constructors may have no class name, so tests should accept tag-only attribution. Attribution requires the exact constructor and immediate prototype successfully registered by @element, @layout, or Router (or that exact constructor already present in the registry): undecorated subclasses stay generic, and document adoption does not change an instance's captured registration identity. The original error is retained as cause, so assertions and debugging can inspect its stack. Snice cannot recover a source filename from a runtime tagged-template value and does not fabricate one. A template prepared outside a component render therefore keeps the generic nearby-template diagnostic.
Promise and async-iterable values settle after the synchronous render call has returned, so their failures are reported through console.error even while strict mode is enabled. Spy on console.error, wait for the deferred value to settle, and assert against the Error argument; it carries the same owning-component context.
Partial DOM Compatibility
In a simulated DOM, opt into Snice's standards compatibility layer from your
test setup file:
The module capability-tests the current DOM and fills only missing IDL
behavior. For example, jsdom and some happy-dom versions omit the browser's
reflective HTMLElement.autofocus property for generic custom-element hosts;
the adapter adds that property without replacing an implementation already
provided by the runner. Application tests can then use
element.autofocus = true exactly as browser code does.
The adapter does not simulate layout, paint, or real focus navigation. Keep a
browser-test lane for behavior that depends on those capabilities. If a runner
creates its DOM after module evaluation, import and call
installDOMTestingCompatibility(scope) explicitly.
Reading the DOM
Use the element's render root directly:
For components whose render root may be closed or light, use @query inside the component and assert against the property, or select from el rather than el.shadowRoot. See Queries.
Events
Snice events carry their payload in detail, so type handlers as CustomEvent:
Assert on e.detail, not e.target.value.
Controllers
A controller attaches asynchronously, so wait for the element before asserting on its effects:
Swapping a controller is the supported way to test an element in isolation: attach a fixture controller that responds on the same channel the production controller uses, and the element itself never changes. See Request / Response and Controllers.
When a controller calls getContext(this), install the same application
context on its host (or an ancestor) before attachment, then release it during
cleanup:
Assigning the host's own @context field does not create a provider;
getContext() resolves the nearest provideContext() boundary.
When production code uses getContextFetch(), pass the test fetch function in
the same provider's third argument as shown above. Router supplies its
middleware-bound fetch function there in an application.
If the controller uses @context(), test it beneath a real Router target.
provideContext() intentionally provides application state and transport, not
navigation notifications — a provideContext() boundary can NEVER satisfy a
@context() handler. The handler simply never fires, with no error, so a
harness built on provideContext() alone shows a silently empty page and a
green-looking suite. Using Router exercises the same registration, initial
catch-up, middleware-aware ctx.fetch, updates, and detach cleanup as
production:
To re-deliver a Context to an already-attached controller — exactly what
testing a first-delivery guard requires — capture the Context on the fixture
page and call its public update(), which re-notifies every registered
handler:
Two traps: invoking the @context() method directly does not work (the
registered handler is a wrapped method), and detachController() nulls
element before any late delivery, so a post-detach assertion must be set up
before detaching.
Validating Source
Beyond unit tests, the analyzer catches Snice-specific mistakes that still compile:
See CLI.
Utilities
Small helpers exported from snice alongside the decorators.
Method Decorators
Rate-limit or cache a method without writing the plumbing:
| Decorator | Options |
|---|---|
@debounce(wait?, options?) | leading, trailing, maxWait |
@throttle(wait?, options?) | leading, trailing |
@once(perInstance?) | perInstance — once per instance rather than per class |
@memoize(options?) | keyGenerator, maxSize, ttl |
Pending timers are cleared automatically when the element disconnects. Clear
them yourself — in a test, say — with clearDebounceTimers(instance),
clearThrottleTimers(instance), clearMemoizeCache(instance), and
resetOnce(instance).
Escaping
Template bindings escape for you; reach for these only when assembling markup
by hand — see Binding Channels.
Durations
Parses a CSS-style duration string, so a component can accept "200ms" from an
attribute and get a number back.
Scroll Locking
Used by overlay components (modal, drawer) to stop the page scrolling behind
them. The lock is reference counted, so nested overlays release correctly:
Always pair a lock with an unlock — usually @ready / @dispose.
Controllers
Native elements pick up controller="name" automatically in the browser;
useNativeElementControllers() exists to enable that manually in environments
where it is not auto-started. See Controllers.
Debugging
CDN / Standalone Usage
Use any Snice component on a plain HTML page — no bundler, no build step. Each component is a standalone .min.js bundle that shares one small runtime.
Load Order
Load the runtime once, first, then one bundle per component. Components load in any order after the runtime:
snice-runtime.min.js is shared by every component. Load it before any snice-<name>.min.js — without it the custom elements never register and the page renders blank.
Bundle Families
One bundle registers its whole element family — you don't load sub-elements separately:
| Bundle | Registers |
|---|---|
snice-tabs.min.js | <snice-tabs>, <snice-tab>, <snice-tab-panel> |
snice-select.min.js | <snice-select>, <snice-option> |
snice-toast.min.js | <snice-toast>, <snice-toast-container> |
Theme and Dark Mode
Components ship with built-in light-mode token fallbacks, so they render correctly with no stylesheet. Add theme.css for the full design-token set and dark mode:
Dark mode follows the OS setting automatically. To force it, set data-theme on the root element:
Use the .min.js Builds
Load the .min.js (IIFE) bundles in <script src> tags. The .esm.min.js builds are ES modules for bundler/import use, not <script src>.
Generating Bundles
Bundles are published to the CDN, or build them yourself:
React Integration
Snice's React integration lets you build full Snice-powered apps using hooks and JSX — routing, guards, layouts, context — without decorators or custom element classes.
React pages and Snice web component pages coexist in the same route table. Migration is incremental.
- useSniceContext
- useNavigate
- useParams
- useRoute
- useRequestHandler
- Guards
- Layouts
- Mixed Pages
- Context (Standalone)
- Context Shape Reference
- Vanilla Snice Comparison
- Behavior Notes
Installation
Snice's React integration is included in the main package:
Everything imports from snice/react:
You can also deep-import individual modules:
TypeScript Types
Quick Start
SniceRouter
The root provider component. Manages URL state, route matching, guard execution, layout selection, and context propagation.
Props
| Prop | Type | Default | Description | ||
|---|---|---|---|---|---|
mode | "hash" \ | "history" | required | URL strategy. Hash uses #/path, history uses browser pushState. | |
context | object | {} | Application context passed to guards and available via useSniceContext(). | ||
layout | `Component \ | string` | none | Default layout wrapping all pages. String = Snice web component tag. | |
loading | `Component \ | string \ | JSX` | centered spinner | Shown while async guards are running. |
fallback | `Component \ | string \ | JSX` | "404 — Page not found" | Shown when no route matches. |
For layout, loading, and fallback: pass a React component, a Snice web component tag name (string), or raw JSX.
Route
Defines a route within <SniceRouter>. The <Route> component itself renders nothing — SniceRouter reads its props to build the route table.
Props
| Prop | Type | Description | ||
|---|---|---|---|---|
path | string | URL pattern. Supports dynamic segments: /users/:id, /posts/:slug. | ||
order | number | Optional specificity tie-break. Lower values match first; equal or omitted values preserve declaration order. | ||
page | `Component \ | string` | What to render. React component receives route params as props. String = Snice web component tag name (params set as attributes). | |
guard | `(ctx, params) => boolean \ | Promise<boolean>` | Single guard function. | |
guards | function[] | Multiple guards — all must pass (AND logic). | ||
guardRedirect | string | Redirect path if any guard rejects. Without this, the router renders nothing on rejection. | ||
layout | `Component \ | string \ | false` | Override the router's default layout. false = explicitly no layout. |
placard | Placard | Page metadata for layouts — navigation titles, icons, breadcrumbs. Import Placard type from snice/react. |
Hooks
useSniceContext()
Returns the full merged context. Mirrors the shape of Snice's @context decorator:
Must be used inside <SniceRouter> or <SniceProvider>. Throws if used outside.
useNavigate()
Convenience hook for programmatic navigation:
useParams()
Returns current route parameters. Shortcut for useSniceContext().navigation.params:
Note: React page components also receive params directly as props (e.g., function UserPage({ id }) { ... }), so useParams() is optional if you prefer the props pattern.
useRoute()
Returns the current matched route pattern string:
useRequestHandler()
Handle @request channels from Snice web components in React. This is how React code responds to requests from Snice elements that use the @request decorator.
Why: Snice elements make requests via @request('channel-name'). Normally a @respond controller handles these. useRequestHandler lets React components be the responder — no controller class needed.
Global Handler
Pass null as the ref to listen on document (catches all bubbling requests):
Options
| Option | Type | Default | Description |
|---|---|---|---|
passive | boolean | false | When true, doesn't stop event propagation. Allows multiple handlers to observe the same request (only one can respond). |
Behavior
- Route callbacks always use the latest version (ref-stable) — no
useCallbackneeded - Listeners re-attach only when the set of channel names changes
- Cleanup happens automatically on unmount
Guards
Guards protect routes. The same function signature works in both Snice and React:
Sync Guards
Async Guards
Async guards show the loading component while resolving:
If no loading prop is set, a default centered spinner is shown.
Multiple Guards
All guards must pass (AND logic). They run sequentially — first failure short-circuits:
Guard Failure
When a guard returns false (or rejects):
- If
guardRedirectis set → navigate to that path - If no
guardRedirect→ render nothing (blank)
Guard errors (thrown exceptions) are treated as rejection.
Sharing Guards
Guards are plain functions. The same guard works in both vanilla Snice and React:
Layouts
React layouts are components that render {children}:
Default Layout
Set on the router — wraps all pages by default:
Per-Route Override
Override the default layout for specific routes:
layout={ReportsLayout}— use a different layoutlayout={false}— explicitly no layout (e.g., login page)
Snice Layouts
Pass a string to use a Snice web component as the layout:
Mixed Pages
Snice web component pages and React pages coexist in the same route table:
When page is a string, the router creates the web component element and sets route params as attributes. When page is a React component, params are passed as props.
The same string-or-component pattern works for layout, loading, and fallback.
Context (Standalone)
<SniceProvider> can be used without a router for simpler apps that just need shared context:
SniceProvider Props
| Prop | Type | Default | Description |
|---|---|---|---|
context | object | {} | Application context. |
navigate | (path: string) => void | no-op | Navigation function. |
route | string | "" | Current route pattern. |
params | Record<string, string> | {} | Current route params. |
placards | Placard[] | [] | Registered placards. |
fetch | typeof fetch | none | Optional context-aware fetch function. |
When used inside <SniceRouter>, these props are set automatically. Use <SniceProvider> directly only when you don't need routing.
Context Shape Reference
useSniceContext() returns a SniceReactContext object:
This mirrors the shape of Snice's vanilla Context class used by the @context decorator.
Vanilla Snice Comparison
| Concept | Vanilla Snice | React | |
|---|---|---|---|
| Router setup | Router({ target, type: 'hash', layout, context }) | <SniceRouter mode="hash" layout={...} context={...}> | |
| Page definition | @page({ tag, routes, guards }) | <Route path="..." page={...} guard={...} /> | |
| Navigation | navigate('/path') | const nav = useNavigate(); nav('/path') | |
| Context access | @context() handleCtx(ctx) { ... } | const ctx = useSniceContext() | |
| Request handling | @respond('channel') controller | useRequestHandler(ref, { 'channel': handler }) | |
| Guards | `(ctx, params) => boolean \ | Promise<boolean>` | Same — shared functions work in both |
| Layouts | @layout('tag') class ... { } | function Layout({ children }) { ... } |
Guards, context shape, and page contracts are identical across both systems. A guard written for vanilla Snice works in React and vice versa.
Behavior Notes
Guards run on navigation, not on context change. If your context changes (e.g., user logs out), guards on the current route are not re-evaluated until the next navigation. This matches standard router behavior.
Route params as props. React page components receive the matched route params directly as props: <Route path="/users/:id" page={UserPage} /> means UserPage gets { id: "42" } as props. You can also access them via useParams().
Route matching uses pica-route — the same library as vanilla Snice's router. Routes are sorted by specificity (longest path first), so /users/:id takes priority over /users.
Default loading. If no loading prop is provided, async guards show a simple centered CSS spinner. Pass your own loading component/JSX to customize it.