@fr0st/component
v1.0.0
Published
Native JavaScript stateful web components with reactive bindings, slots, shadow DOM, suspense, and HTML template autoloading.
Maintainers
Readme
FrostComponent
Native JavaScript stateful web components with reactive bindings, slots, shadow DOM, suspense, and HTML template autoloading, with dynamic component loading and no compilation step.
Highlights
- Default ESM export for browser projects and bundlers
- Browser UMD bundle in
dist/exposed asglobalThis.Component - No compilation step or virtual DOM
- Reactive component state built on
@fr0st/state - HTML template autoloading for
x-*elements - Text, attribute, property, event, and input bindings
- Control-flow directives with
x:if,x:else-if,x:else, andx:each - Slots, shadow DOM templates, and built-in
x-suspense - JSDoc-powered IntelliSense
Installation
Bundlers / browser projects
npm i @fr0st/componentFrostComponent is ESM-only. Import the default Component export into browser-targeted bundles and apps.
Browser (UMD)
Load the bundle from your own copy or a CDN:
<script src="/path/to/dist/frost-component.min.js"></script>
<!-- or -->
<script src="https://cdn.jsdelivr.net/npm/@fr0st/component@latest/dist/frost-component.min.js"></script>
<script>
Component.bootstrap({ baseUrl: '/components', extension: 'html' });
</script>The browser bundle exposes globalThis.Component. Call Component.bootstrap(...) to start the runtime and register built-ins such as x-suspense.
Quick Start
HTML autoloaded components
Point Component.bootstrap() at a folder of component templates and drop x-* elements into the page.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<script src="https://cdn.jsdelivr.net/npm/@fr0st/component@latest/dist/frost-component.min.js"></script>
</head>
<body>
<script>
Component.bootstrap({ baseUrl: '/components', extension: 'html' });
</script>
<x-counter start="3"></x-counter>
</body>
</html>/components/x-counter.html
<!-- shadow -->
<script>
this.state.use('count', Number(this.state.start ?? 0));
</script>
<button @click="{ this.state.count++ }">Count: {count}</button>See the examples/ folder for complete browser and component examples.
JavaScript-defined components
You can also define components directly with customElements.define(...).
import Component from '@fr0st/component';
class XGreeting extends Component {
static get template() {
return `
<div>
<h1>Hello {name}</h1>
<button @click="{ this.state.count++ }">Clicked {count} times</button>
</div>
`;
}
initialize() {
this.state.use('name', 'World');
this.state.use('count', 0);
}
}
customElements.define('x-greeting', XGreeting);JS-defined classes can also opt into shadow DOM with static shadowMode = 'open' or static shadowMode = 'closed'.
TypeScript note: FrostComponent is written in JavaScript and uses JSDoc types, which most editors surface as IntelliSense.
Authoring Model
FrostComponent revolves around a small base class and declarative template bindings.
- Component tag names must begin with
x- - Components must render exactly one root element
- Root
<slot>elements are not allowed this.stateis aStateStorefrom@fr0st/state- Non-
x:host attributes become initial state and are removed from the host x:keyexposes keyed descendants directly on the component instance
Host attributes are parsed as JavaScript when possible and otherwise kept as strings.
<x-profile
name="Ada"
age="37"
active="true"
state="{ theme: 'dark', compact: true }">
</x-profile>Inside the component, that becomes state like:
this.state.name; // 'Ada'
this.state.age; // 37
this.state.active; // true
this.state.theme; // 'dark'
this.state.compact; // truex:key lets you grab important nodes directly from the instance:
<div>
<button x:key="saveButton">Save</button>
</div>this.saveButton; // <button>Bindings
Binding syntax is context-sensitive rather than "full JavaScript everywhere".
Text interpolation note:
Use {key} for state lookups and {{ expression }} for full JavaScript expressions.
- Most non-event bindings treat a bare value as a state lookup, not as arbitrary JavaScript.
:title="label",.service="service",x:bind="text", andx:if="visible"all resolve throughthis.state. - Wrap non-event bindings in braces when you need real JavaScript, such as
:class="({ active: this.state.active })"orx:else-if="{ this.state.items.length < 10 }". - Event bindings are different: a bare value is a component method name, not a state key.
@click="save"resolvessaveon the component instance. - Event bindings also accept function expressions such as
@remove="(event) => { ... }"and braced statement bodies such as@click="{ this.state.count++ }".
Text nodes interpolate state keys with single braces and full expressions with double braces:
<p>Hello {name}. Next: {{ this.state.count + 1 }}</p>Attributes
Use :attr for dynamic attributes:
<button
:class="({ active: this.state.active, disabled: this.state.disabled })"
:style="({ color: this.state.urgent ? 'red' : '' })"
:title="label">
Save
</button>Object literals need braces so they are parsed as JavaScript instead of as a bare state lookup.
You can also bind class and style from state keys such as :class="classes" or :style="styles". class bindings support strings, arrays, and object maps. style bindings support strings and object maps.
When the target is another component, :state merges object values into the child state, and other bound attributes become child state keys.
Properties
Use .prop to assign custom JavaScript properties on DOM elements:
<button .service="service"></button>.prop is intentionally limited to custom properties. Built-in DOM properties such as .value are not supported.
Events
Use @event to attach handlers.
Valid handler forms are:
- Component method names such as
@click="save" - Function expressions such as
@remove="(event) => { ... }" - Braced statement bodies such as
@click="{ this.state.count++ }"
Supported modifiers:
.prevent.stop.once.self.capture.passive
<button @click.prevent="{ this.dispatch('save') }">Save</button>
<x-item @remove="(event) => { this.removeItem(event.target.state.id) }"></x-item>Form inputs
Use x:bind to keep form controls and state in sync. x:bind always takes a state key, not a general expression.
Supported behaviors:
- Text inputs, textareas, and single selects read and write string values
- Checkbox bindings become booleans by default
- Checkbox bindings become arrays when the current state value is an array
- Radio groups read and write the selected radio's
value - Multi-selects read and write arrays of selected option values
<input type="text" x:bind="title" />
<textarea x:bind="body"></textarea>
<input type="checkbox" x:bind="enabled" />
<input type="checkbox" value="red" x:bind="colors" />
<input type="checkbox" value="blue" x:bind="colors" />
<input type="radio" name="size" value="s" x:bind="size" />
<input type="radio" name="size" value="m" x:bind="size" />
<select x:bind="status">
<option value="draft">Draft</option>
<option value="published">Published</option>
</select>
<select multiple x:bind="tags">
<option value="news">News</option>
<option value="docs">Docs</option>
</select>For checkbox arrays and multi-selects, initialize the state as an array:
this.state.use('colors', []);
this.state.use('tags', []);Control Flow
FrostComponent supports conditional and loop blocks directly in templates.
Conditionals
Conditionals follow the same expression rules as other non-event bindings: use a bare state key for simple lookups, or wrap JavaScript expressions in braces.
<x-empty x:if="empty"></x-empty>
<x-list x:else-if="{ this.state.items.length < 10 }"></x-list>
<x-list-large x:else></x-list-large>Notes:
x:else-ifandx:elsebelong to the immediately preceding conditional chain- Only the first matching branch is attached
- Blocks can be nested inside other conditionals and loops
x:ifandx:eachcannot be used on the same element
Loops
x:each is used on component elements, not plain DOM elements. FrostComponent clones the component, assigns each item into its state, and reuses initialized instances when identifiers stay stable.
<x-todo-item x:each="items" x:id="id"></x-todo-item>x:eachdefaults toitemswhen left emptyx:iddefaults toid- The iterable must resolve to an array
- The loop target must be a component element
- Each item must include the identifier property
- Identifiers must be unique within the array
- Existing component instances are reused when identifiers stay stable
Example:
<x-todo-item x:each="items" x:id="id"></x-todo-item>
<x-empty x:if="{ this.state.items.length === 0 }"></x-empty>Slots
Slots work in both light DOM and shadow DOM components.
<div>
<slot name="header"></slot>
<slot></slot>
</div>In light DOM components, FrostComponent replaces descendant <slot> elements with markers and moves matching children into place. In shadow mode, assigned children continue to behave like native slotted content.
HTML Template Components
Autoloaded HTML components can include one render root plus optional top-level scripts and styles. These scripts and styles must be direct children of the template, not nested inside the render root.
Scripts
<script src="...">: external scripts loaded once per source<script connected>: runs on each connection<script>: runs duringinitialize()
Styles
- Light DOM templates append top-level
<style>and<link rel="stylesheet">tags todocument.head - Shadow templates clone those styles into each shadow root
Shadow mode
Use a top-level comment directive in an HTML template:
<!-- shadow -->or
<!-- shadow:closed -->This keeps the host element and renders the template inside a shadow root. Without a shadow directive, a light DOM component replaces its host with the rendered root element.
Component API
FrostComponent exports a single default class:
import Component from '@fr0st/component';Component.bootstrap(options)
Bootstraps built-in components, DOM observation, and optional autoloading for undefined x-* elements.
Component.bootstrap({
baseUrl: '/components',
extension: 'html',
});Options:
options.baseUrl: folder used to fetch component templatesoptions.extension: optional file extension appended to component URLs
You can call Component.bootstrap() more than once. Omitted options keep the current autoload settings.
Instance properties
component.state: the component's reactiveStateStorecomponent.element: the component's public DOM node and dispatch surface; the host element in shadow mode, otherwise the final rendered element exposed outside nested light-DOM wrapperscomponent.rootElement: the root element returned byrender()component.renderRoot: the container that holds rendered output; aShadowRootin shadow mode, otherwiserootElementcomponent.parentComponent: the nearest parent component instance, if anycomponent.childComponents: child component instances rendered inside this componentcomponent.connected: whether the component has entered the connection lifecyclecomponent.mounted: whether the runtime currently considers the component mounted in the observed DOMcomponent.visible: whether the runtime currently considers the component visiblecomponent.initialized: whether state parsing, binding, andinitialize()have completedcomponent.loaded: whether child components and deferred loads have finished settling
Instance methods
component.initialize(): lifecycle hook after render and bindingcomponent.effect(callback, options): register a reactive effectcomponent.dispatch(name, detail): dispatch a bubbling composed custom eventcomponent.deferLoad(promise): hold backloadeduntil a promise settlescomponent.ready(callback): run a callback once the component is loadedcomponent.slot(name = ''): access a parsed light-DOM slot object withassign(node)andassigned(), orundefinedwhen no slot exists
Effects
component.effect() tracks the state reads inside its callback and re-runs when those values change.
class XCounter extends Component {
static get template() {
return '<div>{count}</div>';
}
initialize() {
this.state.use('count', 0);
this.effect(() => {
console.log('count =', this.state.count);
});
}
}Effects are always deferred until the component is mounted. By default they also wait until the component is visible, and any skipped re-runs are flushed on the next visible event.
Pass { waitForVisible: false } when the effect should continue to run while the component is mounted but off-screen:
this.effect(() => {
localStorage.setItem('draft', this.state.text ?? '');
}, { waitForVisible: false });Deferred loading
Use component.deferLoad() when a component should not be considered loaded until some async work finishes:
class XLoader extends Component {
static get template() {
return '<div>{label}</div>';
}
initialize() {
this.deferLoad(
fetch('/api/data')
.then((response) => response.text())
.then((label) => {
this.state.label = label;
}),
);
}
}x-suspense
x-suspense is registered when you call Component.bootstrap(...). It renders fallback content until child components finish loading, then unwraps the real content.
Using the XLoader example above:
<x-suspense>
<template slot="fallback">
<div>Loading...</div>
</template>
<x-loader></x-loader>
</x-suspense>The fallback stays visible until the child components finish loading, including any promises passed to deferLoad().
Behavior Notes
- Light DOM components replace their custom-element host with the rendered root. Shadow components keep the host element.
dispatch()emits from the component's public DOM node. In light DOM that is the rendered root element. In shadow mode that is the host element..propbindings support custom properties only.- Event handlers must be a method name, a function expression, or a braced statement body.
x:eachcan only be used on component elements.- Slots must be descendants of the render root, not the root element itself.
effect(callback, { waitForVisible })always waits until mount, and defers invisible re-runs by default.
Development
npm test
npm run js-lint
npm run buildLicense
FrostComponent is released under the MIT License.
