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": "undefined" },
{ "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": "undefined undefined",
"isEmpty":
}
}
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": "undefined" }]
}
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.
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.