Component anatomy

A component is defined by one <custom-element> tag. Its name must contain a dash, like every custom element name. Inside it there are three kinds of children, in any order:

Part What it holds Rules
<script> The component's logic in plain JavaScript At most one. It runs once per element instance
<style> and <link rel="stylesheet"> The component's CSS Adopted into the component's shadow root, so it only applies inside
Everything else Markup Value slots, _ bindings, native event handlers, fetch attributes and the REALM tags

There is no <template> wrapper and no JavaScript definition API.

<custom-element name="user-badge">
  <script>
    const props = defineProps({ name: prop.string().default("Guest") });
    const badge = useState({
      get initials() {
        return props.name.split(" ").map((part) => part[0]).join("");
      },
    });
  </script>

  <style>
    :host {
      display: inline-flex;
      gap: calc(var(--spacing) * 2);
      align-items: center;
    }
  </style>

  <ui-avatar><ui-avatar-fallback><slot name="#badge.initials"></slot></ui-avatar-fallback></ui-avatar>
  <span><slot name="@name"></slot></span>
</custom-element>

Attributes on the definition

Attribute Effect
name The tag name. Required, must contain a dash
form-associated Makes the element a form field through ElementInternals. See Forms
role Default ARIA role for every instance, set through ElementInternals.role
delegates-focus Creates the shadow root with delegatesFocus: true, so focusing the host focuses the first focusable element inside

Where definitions live

In their own file (recommended). A file such as user-badge.html can hold one or many <custom-element> definitions and is loaded with <import-element from="user-badge.html">. The file is parsed off-page with DOMParser, so nothing in it runs or renders until REALM assembles it. Use a plain <script> and <style>, or <script type="module"> when the script uses import.

Inline in the page, for prototypes and demos. Write the definition anywhere in the page and give its script and style type="realm":

<custom-element name="hello-there">
  <script type="realm">
    const hello = useState({ who: "there" });
  </script>
  <style type="realm">
    p { font-weight: var(--font-weight-semibold); }
  </style>
  <p>Hello, <slot name="#hello.who"></slot></p>
</custom-element>

<hello-there></hello-there>

The browser ignores scripts and styles with an unknown type, so the script does not run in the page scope and the style does not apply to the page. A plain <script> in an inline definition has already run as page JavaScript by the time REALM sees it, so REALM warns about it. REALM removes inline definitions from the page once it has read them, including ones added to the page later.

So type="realm" belongs only on definitions written in a page. The live demos on this site define their components in the page so the preview runs in place, but their source tabs show each definition the recommended way: in its own file with a plain <script> and <style>, loaded from index.html with <import-element>.

How it works without a compiler

You never write this yourself, but it explains several rules.

  1. Read. REALM separates the script, styles and markup, and collects every native event handler in the markup.
  2. Assemble. It builds one script for the component type. Your script becomes the body of a setup function, unchanged, and each event handler from the markup is placed inside that same function. That is why onclick="save()" can call save directly.
  3. Run. The script is inserted as a normal <script> element (a module script when your script has import statements). If the page has a script with a CSP nonce, REALM copies it. REALM never uses eval, new Function or blob: URLs.
  4. Define. REALM registers a native custom element. Each time one connects, it creates a shadow root, adopts the shared stylesheets, runs setup for that instance, then clones the markup and connects every slot, binding and handler.

Rules that follow:

Rule Why
The script runs once per instance It is the body of the setup function. Code shared between instances goes in a module you import
import statements go at the top of the script They are moved above the wrapper. Relative paths resolve against the component file
Only useState, defineProps and store values update the page A plain let count = 0; count++ changes a variable that nothing can observe
Markup that arrives later is never scanned HTML from requests or html slots is data. It is sanitised and never gets handlers or bindings. See Security
Top-level names are what markup can reference #todo.items reads the top-level todo from the script. Names declared inside functions or blocks are not visible to markup

Helpers available in the script

These names are in scope in every component script without importing anything:

Helper Purpose
defineProps(schema) Declare typed attributes. See Props
prop The prop type builder used with defineProps
useState(object) Reactive state. See State and reactivity
observe(fn) Run code now and again whenever what it read changes
tick() Wait until pending updates are applied to the DOM
defineStore(name, initial, options) App-wide state, optionally persisted. See Stores
defineMethods(object) Add public methods and getters to the element
dispatch(type, detail, options) Emit a bubbling, composed, cancelable CustomEvent from the host
listen(type, handler, target) addEventListener that stops when the element is removed
attrs Reactive read and write access to any host attribute
useChildren(selector, attributes) A reactive list of the host's light DOM children that match a selector
onConnected, onDisconnected Lifecycle hooks. See Lifecycle
onFormReset, onFormDisabled, onFormStateRestore Form lifecycle hooks
lookup(name) Find a name in parent components, then globals
host The element itself
refs Elements marked with ref="name" in the markup
signal An AbortSignal that aborts when the element is removed
internals The element's ElementInternals

Write scripts in this order: defineProps, then useState, then functions, then defineMethods, listen, lifecycle hooks and observe last. Keep them short: logic that several components need belongs in a shared module or a store.

The API reference lists every signature.

Errors

REALM warnings start with [realm] in the console and are shown once each.

Edit this page on GitHub · View as Markdown