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.
- Read. REALM separates the script, styles and markup, and collects every native event handler in the markup.
- 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 callsavedirectly. - Run. The script is inserted as a normal
<script>element (a module script when your script hasimportstatements). If the page has a script with a CSPnonce, REALM copies it. REALM never useseval,new Functionorblob:URLs. - 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
- A syntax error in a script is reported once and the component renders its markup without the script.
- An error thrown while the script runs is reported, and the markup still renders.
- Defining the same source twice does nothing. A different definition for a name that already exists keeps the first one and warns, naming both files. A name already registered by another library is left alone, with a warning.
- More than one
<script>in a definition: only the first is used, with a warning.
REALM warnings start with [realm] in the console and are shown once each.