Custom Elements
defineElement registers a component as a real custom element, so any page — loom or not — can consume it as <some-element>. This topic covers registration, passing props in from the consuming page, the light-vs-shadow DOM decision, and the current limitations.
defineElement Copy link
component() defines a component for use inside loom templates (see Components). It does not define a custom element. When you want a component to be consumable from a non-loom page as <some-element>, define it with defineElement() instead — it is component() plus registration, and returns the same callable Component.
API defineElement<Props>(name, templateFunction, options?)
Argument | Type | Description |
|---|---|---|
|
| The custom element name. Must start with a lowercase letter, contain a hyphen, and use no uppercase characters. |
|
| The render function, exactly as you would pass to |
|
| Render into a shadow root. Defaults to |
|
| Stylesheets adopted by the shadow root. Ignored without |
Returns Component — the same callable component function, so it still composes normally inside other loom templates (via element syntax or the functional form).
import { component, defineElement } from '@loom-js/core';
// Composable in loom templates. No custom element is defined. export const Card = component(
(html, props) => html`
<div>${props.children}</div>
` );
// Also available to any page as <fancy-button>. export const FancyButton = defineElement<ButtonProps>(
'fancy-button',
(html, props) => html`
<button type="${props.type}">${props.label}</button>
` );Use one or the other for a given component, never both. An invalid or already-taken element name throws immediately, naming the element.
Passing props from a consuming page Copy link
Consumers pass props as $-prefixed attributes, which map to camelCase props:
<fancy-button $label="Save" $type="submit"></fancy-button>
From inside a loom template, a $-prefixed interpolated value is set as a real JS property rather than an attribute, so objects, arrays, and functions arrive uncoerced:
const SaveControls = component(
(html, { handleClick, label }) => html`
<fancy-button $label=${label}
$onClick=${handleClick}
></fancy-button> ` );Child nodes of the host element arrive as the children prop.
This $name form is the custom-element side of the $ sigil. On plain elements, $ marks loom's own element bindings instead — and $props=${object} hands a custom element several properties at once.
Light DOM vs. shadow DOM Copy link
By default a registered element renders into the light DOM — its content is an ordinary part of the document tree, and your application and design-system CSS applies to it with no extra work.
Pass shadow to encapsulate instead. Be aware that a shadow root blocks document stylesheets entirely, so a shadow-rooted component is unstyled until you supply its styles. Two mechanisms carry styles across the boundary:
CSS custom properties, which pierce the shadow boundary by inheritance. This is what makes theming work with no additional wiring.
options.styles, adopted onto the shadow root for the component's own CSS:
const buttonStyles = new CSSStyleSheet();
buttonStyles.replaceSync(`
button { padding: var(--p-space-4); color: var(--p-color-text); } `);
export const FancyButton = defineElement<ButtonProps>(
'fancy-button',
(html, props) => html`
<button>${props.label}</button>
`,
{ shadow: { mode: 'open' }, styles: [buttonStyles] }
);mode should stay 'open'. 'closed' provides no additional style encapsulation — it only makes element.shadowRoot unreachable, which breaks tests, devtools, and external queries.
Why light DOM is the default. This is a deliberate call, not a rejection of encapsulation. Loom ships no styling machinery for shadow content beyond
options.styles, so a shadow-by-default element would render completely unstyled the first time anyone reached fordefineElement. The orthodox web-components position is the opposite — with shadow, a consuming page's CSS cannot break your component and your CSS cannot leak into their page — and when that trade fits, encapsulation is one option away: passshadow. Light DOM stays the default.
Known limitations Copy link
Registration must happen before a consuming template is parsed. For
$prop=${value}to reach a JS property, the element must already be upgraded when the template renders. In a bundled app this is automatic. Under lazy loading or an unbundled module graph, import the defining module before rendering a template that uses its element — otherwise the value is silently set as a string attribute. Loom warns when it detects this.Attributes are read once, at
connectedCallback. There is noobservedAttributessupport yet, so changing a$-attribute on an already-connected element does not re-render it.
Off-browser, custom elements registered via defineElement are applied to each injected window automatically — see Server Rendering.