Components
Studio writes this format for you. This page documents the underlying JSON, which is useful when you want to hand-edit a file, review a diff, or understand what the visual tools produce.
A Jx component is a single
.json
file. All state, computed values, and functions are declared in
state
. Simple components need no sidecar file.
A component is one file
{
"$id": "Counter",
"state": {
"count": 0,
"increment": {
"$prototype": "Function",
"body": "state.count++"
}
},
"tagName": "my-counter",
"children": [
{ "tagName": "span", "textContent": "${state.count}" },
{ "tagName": "button", "textContent": "+", "onclick": { "$ref": "#/state/increment" } }
]
}
The four shapes of a state entry
Every entry in
state
is one of four shapes, and you can tell which by looking at it. Nothing declares the shape; the entry's own structure decides.
Shape 1: a naked value
A JSON scalar, array, or plain object with no reserved keys:
{ "state": { "count": 0, "name": "World", "tags": [] } }
Shape 2: a typed value
An object with a
default
property and optional
type
:
{
"state": {
"count": {
"type": { "$ref": "#/$defs/Count" },
"default": 0,
"description": "Current counter value"
}
}
}
Shape 3: a computed template string
A string containing
${}
syntax:
{
"state": {
"fullName": "${state.firstName} ${state.lastName}",
"isEmpty": "${state.items.length === 0}"
}
}
A string that is one
${}
from end to end keeps the expression's own value, so
isEmpty
is a real boolean; any text around it makes the value a string. See
State
.
Shape 4: a prototype (
$prototype
)
An object with
$prototype
for functions and data sources:
{
"state": {
"increment": {
"$prototype": "Function",
"body": "state.count++"
},
"userData": {
"$prototype": "Request",
"url": "/api/users/",
"method": "GET"
}
}
}
External sidecars
When functions grow complex, extract them to a
.js
file:
{
"state": {
"increment": { "$prototype": "Function", "$src": "./counter.js" },
"decrement": { "$prototype": "Function", "$src": "./counter.js" }
}
}
export function increment(state) {
state.count++;
}
export function decrement(state) {
state.count = Math.max(0, state.count - 1);
}
The first parameter is always
state
, the component's reactive scope.
this
is never used.
Custom elements
A component whose
tagName
contains a hyphen is a custom element:
{
"tagName": "user-card",
"state": {
"username": "Guest",
"status": "offline"
},
"children": [{ "tagName": "h3", "textContent": "${state.username}" }]
}
Custom elements render to the
light DOM
. Nothing attaches a shadow root, so scoping is done with selectors instead: a component's own rules are prefixed with its tag name, and a nested element with its own
style
gets a generated
.<tagName>-<n>
class.
sty-card {
color: red;
}
sty-card .inner {
color: blue;
}
This cuts both ways. Your page CSS can reach into a component and restyle it, which is handy when you want it and the reason a stray global rule can change a component you didn't touch. There is no encapsulation boundary to stop either one.
Handlers written at the root listen on the element itself.
An
onclick
,
onkeydown
or
ontoggle
beside
tagName
attaches to the host, with the component's own
state
and the host as
event.currentTarget
. That is where a component's contract belongs: a row's activation or a panel's keyboard handling arrives at the element, not at a wrapper it had to render to hear it.
{
"tagName": "menu-row",
"state": {
"value": "",
"activate": {
"$prototype": "Function",
"body": [
{ "stopPropagation": true },
{ "dispatchEvent": "select", "detail": { "$ref": "#/state/value" }, "bubbles": true }
]
}
},
"onclick": { "$ref": "#/state/activate" },
"children": [{ "tagName": "slot" }]
}
Opting into a shadow root
If you want that boundary, ask for it per component:
{
"tagName": "sd-card",
"$shadow": "open",
"style": { "border": "1px solid", "& .inner": { "color": "blue" } },
"children": [
{ "tagName": "div", "className": "inner", "children": ["hello"] },
{ "tagName": "slot" }
]
}
Or for the whole project, with
"defaults": { "shadow": "open" }
in
project.json
. A component's own
$shadow
always wins, including
"$shadow": false
, which is how you keep one component in the light DOM when everything else moved.
The build emits a declarative shadow root , so the component paints correctly before any JavaScript runs:
<sd-card>
<template shadowrootmode="open">
<link rel="stylesheet" href="/components/sd-card.css" />
<div class="inner">hello</div>
<slot></slot>
</template>
<p>your slotted content, out here where the slot can project it</p>
</sd-card>
What changes
| Light DOM (default) | $shadow | |
|---|---|---|
| Page CSS reaches in | yes | no |
<slot> |
emulated: children are moved into place | real slot distribution |
| Your styles are scoped by | the tag name | :host |
| Stylesheet lives in | the page
<head> |
the shadow root |
Slots are the difference that matters.
The light-DOM emulation
moves
your children into the component's rendered tree. A real slot leaves them where they are and projects them, so they stay your page's children, your page's CSS still styles them, and the component reaches them with
::slotted()
.
Write
:host
in your styles either way. In a shadow component it stays
:host
; in a light one the build turns it into the tag name, and
:host(.wide)
into
sd-card.wide
. That means the same style object works in both modes, so flipping
$shadow
doesn't silently break your CSS.
closed
works too, and means what the standard says: nothing outside can reach the root, not even your own scripts via
element.shadowRoot
. Use it when you mean it.
Props and encapsulation
Props are passed via
$props
on an instance node, which is the only mechanism for crossing component boundaries. Register the component in
$elements
and instantiate it by its custom-element tag:
{
"$elements": { "my-card": { "$ref": "./card.json" } },
"children": [
{
"tagName": "my-card",
"$props": {
"title": "Static string",
"count": { "$ref": "#/state/count" }
}
}
]
}
Signal scope is bounded at the component level. No implicit scope leaking.
An instance can sit inside another component's own
children
as well as on a page or in a slot, and the build expands it at every level, props resolved against the enclosing component's state. See
Components inside components
for what the compiler writes and the one shape it refuses (a component that renders itself with the same props).
The live runtime refuses that shape too, with the same words. A component that instantiates its own tag inside its own
children
, forwarding the same props, is stopped at the level where the props repeat: that instance stays an empty element, the console reports
Component <my-card> renders itself: my-card → my-card …
, and the rest of the page, its siblings and every other
<my-card>
included, renders as usual. The refused element also dispatches a bubbling
jx-error
event (an
ErrorEvent
, with the diagnostic as its
error
and
message
), so a page can listen for the refusal where a console line is out of reach. A component that renders itself with props that change at each level (a tree node) is allowed up to 32 levels deep in the runtime as in the build; past that it is reported the same way, with the chain it walked. Slot content does not count:
<my-card><my-card></my-card></my-card>
written on a page is two instances, not a component rendering itself. One shape only the runtime can meet, a component whose
onMount
or a later state change flips a
$switch
into its own tag again, is ended by the same 32-level cap rather than by the cycle check, because at that point the runtime cannot tell a component looping from a user opening one more level of a tree.
When a component cannot render yet
A component whose render throws with the values it was given (a card that formats an empty
date
with
Intl/formatDate
, say) is reported the same way:
Jx: <my-card> was not rendered:
in the console and a
jx-error
event from the element, with the rest of the page drawn around it. In the live runtime that is not final. A prop bound to data that arrives later, such as a collection the page loads after its first render, reaches the component after it connected, so when one of the component's props changes the runtime draws it again, and its
onMount
runs once a render succeeds. The same error is reported once, not on every attempt.
onMount
receives the component's state and the element, and runs with the element as
this
, as a built site runs it. A prop named like a property every element has, such as
title
or
lang
, reaches the component from a parent's
$props
even when it changes after the component connected.