Skip to content

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;
}
Note

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() .

Tip

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.