Skip to content

Styling

Studio writes this format for you. The Design inspector and Project Styles produce everything below. This page documents the style model for hand-editing and reference.

Styles are JSON objects. Property names are the CSS ones in camelCase, the same spelling the CSSOM uses, so background-color is written backgroundColor .

Style objects

The style property accepts a JSON object:

{
  "tagName": "div",
  "style": {
    "backgroundColor": "blue",
    "marginTop": "10px",
    "fontSize": "16px",
    "display": "flex"
  }
}

Nested CSS selectors

Keys beginning with : , . , & , or [ are treated as nested selectors:

{
  "style": {
    "backgroundColor": "blue",
    ":hover": {
      "backgroundColor": "darkblue",
      "cursor": "pointer"
    },
    ".child": { "color": "white" },
    "&.active": { "outline": "2px solid white" }
  }
}

A key may be a selector list, and so may the scope it sits in. Each nested block then applies to every member: "& .a, & .b" holding a ":hover" block styles both .a and .b on hover, the way native nesting's implicit :is() would.

Every declaration becomes a CSS rule, the base ones and the nested ones alike. The build scopes them with a generated class , .<tagName>-<n> , which it also puts on the element:

<div class="sty-card-0">…</div>
.sty-card-0 {
  background-color: blue;
}
.sty-card-0:hover {
  background-color: darkblue;
  cursor: pointer;
}

Base rules come first, so a state block overrides its own base property. That ordering is the whole reason nothing is written as an style="…" attribute: an inline declaration beats any ordinary rule, so a backgroundColor written on the element could never be overridden by the :hover block sitting next to it in the same object.

When Studio renders the page live, the same rules are delivered through the document's adopted stylesheets and scoped with data-jx instead of a class. Different handle, same cascade, so what you see in the canvas is what the published page does.

(You'll also see data-jx-static , data-jx-prerendered and data-jx-slots in compiled output. Those mark hydration state and are never used as CSS selectors.)

Your own style attribute , written under attributes , is untouched by any of this. It stays a literal attribute at inline precedence, so it wins over the object.

Nesting goes as deep as you write it

Selector blocks and @ blocks nest inside each other, in either order and to any depth:

{
  "style": {
    "& .nav-link": { "color": "gray", ":hover": { "color": "white" } },
    "@--sm": {
      "& li:nth-of-type(2n)": { ":hover": { "opacity": "0.8" } }
    }
  }
}

One flattener resolves that tree for the compiler, the live preview and Studio's canvas, so all three agree about what it means. Note that & never reaches the browser: Jx resolves it itself, because .child compounds onto the element here ( .sty-card-0.child ) where native CSS nesting would read the same key as a descendant.

At-rules that hold declarations

Most @ keys wrap selectors: a breakpoint , @supports , @starting-style . Four hold plain declarations instead, and their block is emitted as written, with no selector inside and no scoping to the element that declares it: @position-try , @property , @font-face and @counter-style . The name such a rule declares is global to the document, so it is written where it is used, the way you would write it in a stylesheet:

{
  "style": {
    "position-anchor": "--menu-button",
    "position-try-fallbacks": "--flip-up",
    "@position-try --flip-up": { "inset-block-start": "auto" }
  }
}

One of those four can be written more than once: give the key a list of blocks and each is emitted in turn. A font family with several weights is the case that needs it, since @font-face is the only one of the four whose name is not part of the key:

{
  "style": {
    "@font-face": [
      { "font-family": "Inter", "font-weight": "400", "src": "url(\"inter-400.woff2\")" },
      { "font-family": "Inter", "font-weight": "700", "src": "url(\"inter-700.woff2\")" }
    ]
  }
}

Only those four keys take a list. Anywhere else a list is refused, because one block already says the same thing.

Explaining a rule

Any style block can carry a $description : why the rule is there, in your own words.

{
  "style": {
    "$description": "The panel fills its column so a short list still shows the border under it.",
    "height": "100%"
  }
}

It is prose, never a declaration, so it changes nothing about what the rule does. A build that writes a stylesheet puts it in a comment above the rule; everywhere else it is ignored. Every key starting with $ works this way, and no CSS property starts with $ , so nothing you write as styling can be mistaken for a note.

@keyframes is not one of them. It is the third shape, and its own section.

Animations with @keyframes

A @keyframes key holds keyframe selectors : from , to , a percentage, or a comma-separated list of those. They name points on a timeline rather than elements, so nothing is scoped to the element that declared the block, and the whole block is written once:

{
  "style": {
    "animation": "toast-in 180ms ease-out",
    "@keyframes toast-in": {
      "from": { "opacity": "0", "translate": "0 1rem" },
      "to": { "opacity": "1", "translate": "0 0" }
    }
  }
}

That emits the animation declaration on the element and @keyframes toast-in { from { … } to { … } } into the document, exactly as you would write it in a stylesheet.

Three things follow from an animation name being global to the document:

  • One name, one definition. Where two @keyframes rules share a name, the browser keeps the last and ignores every earlier one. Two elements declaring different bodies under one name will not both get what they asked for.

  • The block is written once and shared. Any number of elements may name the same animation; the rule is hoisted for the document and released when the last of them lets go.

  • A stop cannot hold a reactive value. A ${...} template or a { "$ref": ... } inside a stop is dropped rather than emitted. Reactive values are delivered through a custom property set on the one element that declared them, and a shared block has no such element to read from. Animate a custom property from the element instead, and let the stops reference it with var() .

A @keyframes block may sit inside a breakpoint or a @supports block, and keeps that wrapper.

Named media breakpoints

Declare breakpoints at root level with $media :

{
  "$media": {
    "--sm": "(min-width: 640px)",
    "--md": "(min-width: 768px)",
    "--lg": "(min-width: 1024px)",
    "--dark": "(prefers-color-scheme: dark)"
  }
}

Use @--name keys in any style object:

{
  "style": {
    "fontSize": "14px",
    "@--md": { "fontSize": "16px" },
    "@--dark": { "color": "#ccc" },
    "@(min-width: 1280px)": { "fontSize": "18px" }
  }
}

@--name references named breakpoints. @(condition) is a literal inline media query, and the parentheses are the query's own: a feature query keeps them ( @(min-width: 1280px) ), while a bare media type does not, so @(print) emits @media print .

Color-scheme variants

A $media entry whose value is exactly a prefers-color-scheme query (like --dark above) is a scheme query : its @--dark blocks respond both to the OS preference and to a visitor-forced scheme, and the compiler wires up color-scheme and no-flash persistence automatically. See Color schemes for the full contract and how to build a switcher.

Values that come from your data

A declaration value can be a template or a binding, so a style follows state:

{
  "tagName": "li",
  "style": { "--row-face": { "$ref": "$map/item/face" } },
  "textContent": "${$map.item.name}"
}

That is how a row renders in the thing it names: a font row set in its own face, a colour row showing its own colour. Put the binding on a custom property and read it from the rule that lays the rows out:

{ "tagName": "ul", "style": { "& > li": { "fontFamily": "var(--row-face, inherit)" } } }

Written that way every row shares one rule and sets one variable of its own, so a list of two hundred costs one rule rather than two hundred. Bind a normal property directly if you prefer, and a single element will do the right thing either way.

A built page is the exception. These values resolve against live state, which a built page has only where the runtime is present, so the compiler leaves them out and tells you which ones it left out. A project's own style block cannot use them at all, because it becomes the site stylesheet before there is any state to read.

Styling a component from outside it

A component carries two style objects: the one in its definition, and the one you write where you use it. Both apply, and yours wins where the two set the same property.

{ "tagName": "my-card", "style": { "color": "blue", "&:hover": { "color": "teal" } } }

The merge goes property by property, into nested blocks as well. Writing &:hover at the use site replaces only the declarations you repeat there, and the rest of the component's own &:hover block still applies.

A component with no display in the top level of its style gets display: block , because a custom element is otherwise inline and a component usually behaves like a <div> . It is an ordinary rule, so any rule of yours overrides it without !important . Only the top level counts: a display you set under &:hover or inside a breakpoint is for that state, not a declaration that the component is laid out that way at rest.

Write "display": "revert-layer" when the browser should decide instead. That is the right answer for anything the platform hides and shows on its own, such as a popover or a dialog, where a display of your own would keep it laid out while it is closed. Prefer it over plain revert , which looks identical on a published page and differs inside the Studio canvas: the editor supplies its own hiding rule in a cascade layer, and only revert-layer rolls back onto it.

Static style extraction

The compiler extracts every static style definition into a single <style> block in the document <head> , so a page carries one stylesheet rather than a rule per element.

Design tokens as CSS custom properties

Define tokens in your project.json style block and use them everywhere:

{
  "style": {
    ":root": {
      "--color-primary": "#3b82f6",
      "--color-surface": "#ffffff",
      "--font-sans": "Inter, system-ui, sans-serif"
    }
  }
}

Components reference tokens with standard var() :

{
  "style": {
    "color": "var(--color-primary)",
    "fontFamily": "var(--font-sans)"
  }
}

CSS custom properties cascade through the DOM, so every component can use them without importing anything.