Elements
Studio writes this format for you. Every element you place on the canvas and edit in the Properties panel is stored as one of these objects, and this page documents what lands in the file.
An element definition is a JSON object describing one DOM element. Its keys are the element's real DOM property names, set directly on the created element with no translation layer in between. If a property exists on the DOM element, you can set it here.
{
"tagName": "div",
"id": "my-element",
"className": "container active",
"hidden": false,
"tabIndex": 0,
"textContent": "Hello World"
}
DOM properties
Property names follow the DOM, not HTML source:
className
(not
class
),
textContent
(not inner text),
tabIndex
(not
tabindex
). Any string-valued property may contain a
${}
template for a reactive value (see
Reactivity
), or be an object with
$ref
bound to state (see
References
):
{
"tagName": "div",
"textContent": "${state.count} items remaining",
"hidden": "${state.items.length === 0}"
}
Two properties are
protected
:
id
and
tagName
identify the element and may not be set via
$ref
bindings.
Custom attributes
Anything that is not a standard DOM property (
data-*
,
aria-*
,
slot
) goes in the
attributes
object, written exactly as it appears in HTML:
{
"tagName": "div",
"attributes": {
"data-component": "my-widget",
"aria-label": "Interactive counter",
"slot": "header"
}
}
Attribute values may also be reactive templates (
"aria-label": "${state.count} unread messages"
).
Boolean values
An attribute value of
true
or
false
, written directly or resolved from a
${...}
template, is spelled the way the attribute itself is read. HTML has two rules here, and Jx picks the right one from the attribute name, so conditional markup is just a boolean:
{
"tagName": "details",
"attributes": { "open": "${state.expanded}" }
}
Presence attributes
are read by presence alone:
open
,
disabled
,
checked
,
hidden
,
required
,
selected
and the rest of the HTML boolean family.
true
writes the bare name,
false
removes the attribute entirely:
<details open></details>
<!-- state.expanded === false -->
<details></details>
Never write
"open": "false"
as a string. HTML counts
any
value as present,
"false"
included, so
<details open="false">
is an
open
<details>
. This is the reason a boolean is not stringified.
Enumerated attributes
carry the word in their text and treat an empty value as unset. Every
aria-*
attribute is one, along with
contenteditable
,
draggable
and
spellcheck
. A boolean writes the word for these, in both directions:
{ "attributes": { "aria-expanded": "${state.open}" } }
<div aria-expanded="true"></div>
<!-- state.open === false -->
<div aria-expanded="false"></div>
popover
sits between the two families and is the one attribute worth naming. It is enumerated (its keywords are
auto
,
manual
and
hint
), but an empty value means
auto
rather than unset, so a boolean
true
writing the bare name happens to be right. A value the browser does not recognise falls back to
manual
, which gives up closing on Escape and on a click outside without saying so, and
popover="true"
is such a value. Write the keyword:
{ "attributes": { "popover": "auto" } }
Studio reports a boolean
popover
as a problem rather than correcting it. See
Popovers and overlays
.
That distinction matters for accessibility: a bare
aria-hidden
is
not
hidden, and an omitted
contenteditable
means "inherit from the parent" rather than
false
. Writing either as a presence attribute would silently invert it.
A value of
null
removes the attribute.
A
$ref
to a missing entry, or a template whose single expression yields
null
or
undefined
, takes the attribute away rather than writing an empty string. That is how one template carries an attribute that exists only in some states:
{
"tagName": "li",
"attributes": {
"role": "menuitem",
"aria-haspopup": "${state.haspopup ? 'menu' : null}"
}
}
A
string
is never reinterpreted in either family.
"aria-current": "false"
stays exactly that, which is how you write the not-the-current-page marker beside
"aria-current": "page"
.
The compiled page and the live runtime apply the same rule, so a prerendered element does not change meaning when it hydrates.
Children
children
is an array of element definitions, rendered in order:
{
"tagName": "div",
"children": [
{ "tagName": "h1", "textContent": "Title" },
{ "tagName": "p", "textContent": "Content" }
]
}
A
children
array may freely mix element objects, bare text nodes, and
repeaters
(
$prototype: "Array"
members) or
switches
.
Text nodes
Bare strings and numbers are valid
children
items. They produce DOM
Text
nodes directly, with no wrapper element. This is how text with inline markup is written:
{
"tagName": "p",
"children": ["Hello ", { "tagName": "strong", "textContent": "world" }, "!"]
}
That is the HTML
<p>Hello <strong>world</strong>!</p>
. Template strings in text nodes are reactive:
{ "children": ["Welcome, ${state.name}!"] }
.
Slots
Custom elements use the standard HTML
slot
mechanism for content composition. A component's template places
<slot>
elements; content the instance provides is distributed to them by
name
:
{
"tagName": "card-component",
"children": [
{
"tagName": "header",
"children": [{ "tagName": "slot", "attributes": { "name": "header" } }]
},
{ "tagName": "main", "children": [{ "tagName": "slot" }] }
]
}
A slot's own
children
act as fallback content, kept when the instance provides nothing for it.
Whitespace between children is not content, so a component given only named children still shows its unnamed slot's fallback. When two slots share a name, the first one in the template takes the children and the second shows its fallback. A slot written directly inside another component's children passes on what it receives, placed by its own
slot
attribute.
The
<slot>
itself leaves nothing behind. What the instance provided stands exactly where the slot was written, as a direct child of whatever contained it. That is what lets a component style what it was given: a rule like
"& > [part=\"body\"] > b"
reaches a slotted
<b>
, because the
<b>
really is a child of that part.
Annotations
Any element may carry
$title
and
$description
, developer-facing labels that never reach the DOM:
{
"tagName": "section",
"$title": "Hero Section",
"$description": "Primary landing area with headline and call-to-action",
"children": []
}
Studio's
Outline panel
shows
$title
as the element's display name.
How it works
The runtime creates the element with
document.createElement(tagName)
, assigns each listed property directly on the element object, and writes
attributes
entries with
setAttribute
. Properties whose values are templates or
$ref
bindings are wrapped in reactive effects, so the DOM updates whenever the underlying state changes. Slot distribution is manual light-DOM distribution: host children are captured before the template renders, then each
<slot>
is replaced by the children matching it, or by its own fallback children when nothing matches. A built page distributes the same way, in the component's module and in its prerendered HTML.
Rules
tagNameis required on every element definition.idandtagNameare protected, and never settable via$ref.Standard DOM properties go at the top level; everything else (
data-*,aria-*,slot) goes inattributes.Bare strings and numbers in
childrenbecome text nodes; when all children are bare strings with no element siblings, prefertextContentinstead.$titleand$descriptionare plain strings: not reactive, not$ref-resolvable, never applied to the DOM.Styling does not use DOM properties. The
styleobject has its own grammar, covered in Styling .
Related
Documents : the file these objects live in
Reactivity :
${}templates in any string propertyStyling : the
styleobject, nesting, and breakpointsLists and iteration : repeaters inside
childrenProperties panel : the Studio surface that edits these objects