Scoped CSS, themes, and apply
Styling
A <style> block styles the items beside it and everything below them, never the element that contains it. Assign one to a variable and it becomes a theme you can pass around, compose, and apply elsewhere.
A block styles the elements beside it
A <style> block styles its siblings and everything below them, and never the element
that contains it. Write the CSS next to the markup it styles:
export function Card(props) @{
<>
<style>
.card {
padding: 1.5rem;
border: 1px solid var(--line);
}
h2 {
margin: 0 0 0.5rem;
}
</style>
<article class="card">
<h2>{props.title as string}</h2>
<p>{props.summary as string}</p>
</article>
</>
}The compiler gives the block a hash and adds it to every selector: .card becomes
.card.tsrx-1a20093c and h2 becomes h2.tsrx-1a20093c. It also adds
tsrx-1a20093c to the class list of each element the block reaches. That class is the
block's hash class: the class the compiler adds to an element so the block's
selectors match only there. Rules never leak into a parent, an outer sibling, a child
component, or the page around it.
To style an element, put the block beside it, as siblings in a fragment, as above. A
block written inside <article> would style the article's children, not the article.
Wrap the block and its markup in a fragment
A @{ … } body and every @if/@for/@switch/@try branch hold setup statements
and exactly one output node, and a block counts as an output node. Written beside the
output node, it is the multiple-outputs parser error; written as the only output, it
styles nothing (STYLE_STANDALONE_NEEDS_FRAGMENT).
// Error: two output nodes.
export function Broken() @{
<style>
p {
margin: 0;
}
</style>
<p>Hello</p>
}
// Works: one fragment holding both.
export function Note() @{
<>
<style>
p {
margin: 0;
}
</style>
<p>Hello</p>
</>
}Wrap the block and the output in <>…</>, inside branches too.
The CSS is static
A block holds plain CSS: no { … } expressions and no template statements. Put a runtime
value in a custom property on the element and read it from the CSS:
export function Tag(props) @{
<>
<style>
span {
color: var(--tone);
}
</style>
<span style={{ '--tone': props.tone }}>{props.label as string}</span>
</>
}A scoped block accepts only two attributes, ref and apply. To reach elements outside
the scope, see Reach outside the scope with :global.
What a scope is
A scope is one list of children — the children of an element or of a fragment — that holds at least one block. Sibling blocks share one hash class, and a block belongs to the list it is written in. These lists count:
- The children of a native element.
- The children of a fragment, including the fragment a
@{ … }block or an@if,@else if,@else,@for,@empty,@case,@default,@try,@pending, or@catchbranch renders. - The children of an element or fragment used as a value inside a component body: a
template assigned to a variable, or one written inside a
{ … }expression.
Scopes nest. An element gets the hash class of every scope around it, outermost first,
so an outer block's rules reach into nested scopes and an inner block's rules never
reach out. A child component's own elements, and elements returned from a callback
such as items.map((item) => <li />), get no hash class from the parent.
export function Panel() @{
<>
<style>
div {
color: black;
}
</style>
<div class="outer">Black</div>
@{
<>
<style>
div {
font-weight: bold;
}
</style>
<div class="inner">Black and bold</div>
</>
}
</>
}With A and B as the two hash classes, the compiled classes are outer A and
inner A B. The nested div matches both rules; the outer one matches only its own.
Several blocks, one scope
Blocks written among the same children share one hash class and become one stylesheet. Use that to keep each rule next to the element it styles:
export function Toolbar() @{
<>
<style>
.toolbar {
display: flex;
gap: 0.5rem;
}
</style>
<div class="toolbar">
<style>
.save {
font-weight: 600;
}
</style>
<button class="save">Save</button>
<style>
.cancel {
opacity: 0.7;
}
</style>
<button class="cancel">Cancel</button>
</div>
</>
}The two blocks inside the toolbar become one sheet, in source order, and style the
buttons beside them. The .toolbar rule sits beside the toolbar in the fragment
because a block never styles the element that contains it. A scope's blocks always come
out together as one group, even when other scopes sit between them in the source.
Styles inside @if and @for
A <style> block inside an @if or @for branch applies only to the elements that
branch renders. Its CSS is still always part of the file's stylesheet, whether or not
the branch ever renders, because CSS is static. Rules you want everywhere belong outside
the branch.
export function Status(props) @{
<>
<style>
.status {
padding: 0.5rem;
}
</style>
<section class="status">
@if (props.ready) {
<>
<style>
.ok {
color: green;
}
</style>
<p class="ok">Ready</p>
</>
} @else {
<>
<style>
.wait {
color: gray;
}
</style>
<p class="wait">Waiting</p>
</>
}
</section>
</>
}With A, B, and C as the hash classes of the fragment and the two branches, the
compiled classes are status A, ok A B, and wait A C, and the three sheets come out
in that order. Both the .ok and .wait rules are in the stylesheet when the module
loads, but .ok cannot reach the waiting paragraph and .wait cannot reach the ready
one, so the same class name can mean different things in @if and @else.
Assign a block to get a class map
Assign a <style> block to a variable and it becomes a plain object of class names
instead of a scoped block. The object has $class, the block's hash class (after the
classes of any theme it applies), plus one key per class selector whose value is the
hash class and the class name together:
export const theme = <style>
div {
color: green;
}
.dark {
color: purple;
}
</style>;
// theme.$class → 'tsrx-fe4e37b1'
// theme.dark → 'tsrx-fe4e37b1 dark'
export function Badge(props) @{
<span class={theme.dark}>{props.label as string}</span>
}Pass these strings to child components as ordinary props; the hash class is already in
them. The declaration can sit at module scope or anywhere a declaration is legal inside
a component body. $class is reserved, so a block cannot declare a .$class selector,
and a standalone <style> at module scope is an error: assign it to a variable.
A block that is exported, applied, or whose $class is read anywhere in the module is
a theme and keeps every selector, element and descendant selectors included. A
local block used only through its class keys is a plain class map: it keeps only the
selectors that are a single class, such as .dark, and every other selector is removed
as unused and left as a /* (unused) … */ comment in the compiled CSS. Descendant
selectors and :global escapes on that local block are unused too — they belong on a
theme you apply, or on a sibling block beside the markup.
Apply a theme to a scope
<style apply={theme} /> adds the theme's $class to the items beside it and
everything below them — the same elements a block's own rules reach, never the element
that contains it — so the theme's element and descendant rules match those elements as
if the theme had been written there. A self-closing apply adds no hash class of its
own. A block with CSS in it, <style apply={theme}>…</style>, applies the theme and
declares the scope's own block in one tag:
// theme.tsrx
const base = <style>
div {
font-family: system-ui;
}
</style>;
export const theme = <style apply={base}>
div {
color: green;
}
.dark {
color: purple;
}
</style>;
// theme.$class → base's hash, then theme's own hash// Panel.tsrx
import { theme } from './theme.tsrx';
export function Panel() @{
<>
<style apply={theme}>
div {
color: black;
}
</style>
<span class={theme.dark}>Purple</span>
<div>Black: the local rule beats the theme's green</div>
</>
}
export function Card() @{
<>
<style apply={theme} />
<article>
<h2>Green, system-ui</h2>
</article>
</>
}apply={[a, b]}applies several themes in order, and a theme can apply another theme: its$classthen lists the applied classes first and its own hash class last.export const bundle = <style apply={[a, b]} />;composes themes with no CSS of its own;bundle.$classis the two themes' classes.- A theme declared in the same module becomes a string literal in the class attribute,
so the element's static HTML is still built once, up front. An imported theme is read
at runtime through
theme.$class, so that element's class is built when it renders. - A theme must be declared before the block that applies it.
An element's final class list is: its own classes, then the hash class of each enclosing scope outer to inner, then the applied theme classes.
Opt single elements in with $class
apply covers a whole scope. When only some elements should pick up a theme, give
those elements class={theme.$class} and leave apply out: the theme's element and
descendant rules match exactly the elements that carry the class, and their siblings
stay untouched. Because $class is a plain string, a child component can receive it
through a prop and put it on its own elements; the passed class lands before the
child's own hash class.
function Card({ parentClass }: { parentClass: string }) @{
<>
<style>
.local {
padding: 0;
}
</style>
<article class={['local', parentClass]}>
<h2 class={parentClass}>Blue, from the parent's theme</h2>
</article>
</>
}
export function App() @{
const theme = <style>
div,
h2 {
color: blue;
}
.card {
color: red;
}
</style>;
<>
<Card parentClass={theme.$class} />
<div class={theme.$class}>Blue: opted in</div>
<div class={theme.card}>Red: a class entry carries the hash too</div>
<p>Untouched</p>
</>
}Reading theme.$class is what makes theme a theme: the div, h2 rule survives even
though nothing exports or applies the block. A block whose only reads are class entries
such as theme.card stays a class map and loses its element selectors. Opt one element
into several themes with class={[a.$class, b.$class]}, the counterpart of
apply={[a, b]}; the two forms compose, so a scope can apply a base theme while single
elements opt into an accent.
Reach outside the scope with :global
Wrap part of a selector in :global(…) and that part gets no hash class; everything outside
the parentheses is still scoped. Each form reaches a different set of elements:
export function Card(props) @{
<>
<style>
/* → .toast */
:global(.toast) {
position: fixed;
}
/* → .card.tsrx-1a20093c .note */
.card :global(.note) {
color: blue;
}
/* → .theme-dark .card.tsrx-1a20093c */
:global(.theme-dark) .card {
background: black;
}
/* → .card.tsrx-1a20093c.is-open */
.card:global(.is-open) {
display: block;
}
</style>
<article class="card">
<Markdown source={props.body} />
</article>
</>
}- Bare,
:global(.toast)→.toast. A plain page-wide rule: it matches anywhere, ancestors, siblings, and other components included, exactly like a rule in a global stylesheet. - Prefixed,
.card :global(.note)→.card.tsrx-1a20093c .note. Reaches only elements below your scoped.card, a child component's internals included. It can never climb up. - Leading,
:global(.theme-dark) .card→.theme-dark .card.tsrx-1a20093c. Your own element, only when an ancestor carries the class, such as a theme class toggled on<html>. - Compound,
.card:global(.is-open)→.card.tsrx-1a20093c.is-open. Your own element, with a class another library toggles on it.
:global(…) may only sit at the start or the end of a selector. In the middle,
.card :global(.x) .title, it is the CSS_GLOBAL_PLACEMENT error.
:global also has a block form. :global { … } drops the wrapper and leaves every rule
inside it unscoped. Nested under a scoped rule, it reaches only below that rule, the same as
the prefixed form with the scoped prefix written once:
export function Post(props) @{
<>
<style>
/* → .toast { … } body { … } */
:global {
.toast {
position: fixed;
}
body {
margin: 0;
}
}
/* → .post.tsrx-1a20093c { pre { … } .footnote { … } } */
.post {
:global {
pre {
overflow-x: auto;
}
.footnote {
font-size: 0.875rem;
}
}
}
</style>
<article class="post">
<Markdown source={props.body} />
</article>
</>
}- Block,
:global { .toast { … } body { … } }→.toast { … } body { … }. The wrapper goes, left behind as a comment in the output, and every rule inside it is a page-wide rule. This is the natural way to write several page-level rules at once, and the same advice applies as for the bare form. - Nested block,
.post { :global { pre { … } } }→.post.tsrx-1a20093c { pre { … } }. The same reach as.post :global(pre), written with CSS nesting: only elements below your.post. The selector form nests the same way,.post { :global(pre) { … } }, and gives the same output. Plain nesting,.post { pre { … } }, scopes both parts:.post.tsrx-1a20093c { pre.tsrx-1a20093c { … } }.
Which one to use
Reach for :global only when a prop cannot do the job. Start from what you want:
| I want to … | Use … |
|---|---|
| Style my own elements | A block beside them. Nothing global. |
| Share chrome across several of my own components | Assign a theme and <style apply={theme} /> in each (how). |
| Let a child component pick up my styles | Pass theme.$class, or a class-map entry such as theme.card, as a prop (how). A child you own can also take its own sibling block. |
| Style a child I cannot change (a third-party component, rendered HTML or markdown) | .wrapper :global(.their-class), or .wrapper { :global { … } } for several classes, with a scoped selector in front. |
React to page-level state (a theme class or attribute on <html>) | :global(.theme-dark) .card or :global([data-theme='dark']) .card. |
Write page-wide rules (body, resets, fonts) | A .css file the page links, not a bare :global. |
For a child you own, pass the class instead of reaching in with :global. With a prop, the
dependency is visible in code, the child decides which of its elements take the class,
renaming a class inside the child cannot silently break the parent, and the hash keeps the
rule on the elements that carry it. With .wrapper :global(.their-class) the child has no
say and cannot see who styles it, so keep that form for children you cannot change, and
always put a scoped selector in front so the rule cannot reach ancestors or unrelated
components. When several of the child's classes need styling, nest one :global { … }
block under the scoped wrapper, as in Post above, so the scoped prefix is written once.
A bare :global(.toast) is a global stylesheet hidden inside a component: it matches
anywhere on the page, and nothing on the matched element points back to the file that wrote
it. Never write one for anything but page-level elements, and put those in a .css file the
page links.
How a global rule ranks
A scoped rule adds one hash class to its first compound only; later compounds get
:where(.tsrx-1a20093c), which adds no specificity, so .card .title becomes
.card.tsrx-1a20093c .title:where(.tsrx-1a20093c). :global changes what a rule can
reach, and the hash decides which rule wins:
- A scoped
.note.tsrx-1a20093c(two classes) beats a bare:global(.note)(one class) from anywhere on the page. A class-map entry or atheme.$classrule carries its hash too, so it beats a bare global as well. - A prefixed
.card.tsrx-1a20093c .note(three classes) beats the child component's own.note.<its hash>rule (two classes). It overrides the child, so keep it narrow. - At equal specificity the later stylesheet wins, as Which rule wins explains.
Global Keyframes
Keyframe names are scoped by default, and the compiler rewrites references to them in
the block's animation and animation-name declarations. To share an animation across
components, prefix its declaration with -global-. The compiler removes that prefix
and leaves the name unscoped, so other components reference it without the prefix:
export function App() @{
<>
<style>
/* Scoped to this block. */
@keyframes slideIn {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
/* Shared as fadeIn, without the -global- prefix. */
@keyframes -global-fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.parent {
animation: slideIn 1s;
}
</style>
<div class="parent">
<Child />
</div>
</>
}
function Child() @{
<>
<style>
.child {
animation: fadeIn 1s;
}
</style>
<div class="child">Child content</div>
</>
}The stylesheet that defines fadeIn must be loaded wherever it is used. In this
example, App supplies it for Child; an animation shared across unrelated pages can
instead live in a global .css file that those pages link.
Which rule wins
At equal specificity, the rule that comes later in the CSS wins, and the CSS comes out
in source order, outer scope first. A scope's sheet goes where its first block is: after
the assigned blocks declared before it in the same statement list, and before the
scopes and assigned blocks nested inside it. Sibling scopes follow source order. An
applied theme's sheet comes before the block that applies it, so a local rule wins over
the theme's rule at equal specificity; that is why the div in Panel above is black,
not the theme's green.
Where the CSS goes at runtime
The compiled module carries one injectStyle(hash, css) call per scope. Where it runs
depends on the runtime:
- On the client, every call sits at module scope and runs when the module is
evaluated, in the order above. The runtime injects each hash once, appends one
<style data-octane="hash">element per sheet to<head>, and skips a sheet the server already sent. - On the server, the calls sit at the top of each component body, so a request
collects only the sheets of the components it actually rendered. An assigned block
injects its sheet when the request reads it, and a component that applies an
imported theme reads the theme before its own sheets, so the theme still comes before
the scope that applies it, even across modules. Buffered renderers return the sheets
as the
cssfield of the result, deduplicated by hash in injection order; the streaming renderers have nocssfield and send each scoped<style data-octane>tag inline with the content that uses it.
Bundlers do not see this CSS: it travels inside the compiled JavaScript, not in a
virtual CSS module, so CSS plugins, PostCSS, and build.cssMinify never touch it.
Global stylesheets that every component reads, such as design tokens, belong in a
.css file the page links.
A style with href is a head resource, not a scoped block
A <style href="…" precedence="…"> is a Float resource: the runtime moves it into
<head> and keeps one copy per href. It accepts no apply, gets no hash class, and
adds no class to any element. The same is true of a <style> rendered inside <head>.
Diagnostics
Style errors stop the compile. Each carries one of these codes, which octane analyze
prints under the message and the bundler plugins surface in their own error output:
STYLE_APPLY_VALUE(tsrx-style-apply-value):applyneeds an expression value —apply={theme}orapply={[a, b]}.STYLE_APPLY_TARGET(tsrx-style-apply-target): anapplyentry is not an identifier, member expression, or array of those, or does not resolve to a style block.STYLE_APPLY_BEFORE_DECLARATION(tsrx-style-apply-before-declaration): the applied theme is declared after the block that applies it. Move the theme above.STYLE_APPLY_DUPLICATE(tsrx-style-apply-duplicate): twoapplyattributes on one block. Pass several themes as one array.STYLE_APPLY_UNSUPPORTED_HOST(tsrx-style-apply-unsupported-host):applyon a<head>style or anhrefresource style.STYLE_RESERVED_CLASS_KEY(tsrx-style-reserved-class-key): an assigned block declares a.$classselector. Rename it.STYLE_STANDALONE_AT_MODULE_SCOPE(tsrx-style-standalone-at-module-scope): a bare standalone<style>statement at module scope. Assign it to a variable.STYLE_STANDALONE_OUTSIDE_TEMPLATE(tsrx-style-standalone-outside-template): raw CSS in a<style>outside every@{ … }or@if/@for/@switch/@trybody — a plain function returning JSX, or an element assigned at module scope. Use<style>{css}</style>in TSX, or assign the block.STYLE_STANDALONE_NEEDS_FRAGMENT(tsrx-style-standalone-needs-fragment): a<style>as the lone output of a@{ … }or@if/@for/@switch/@trybody. Wrap it with the output it styles in a fragment:<><style>…</style><div>…</div></>.STYLE_UNKNOWN_ATTRIBUTE(tsrx-style-unknown-attribute): an attribute other thanrefandapplyon a scoped block.CSS_GLOBAL_PLACEMENT(tsrx-css-global-placement)::global(…)used where the scoping rules do not allow it.
Next
- TSRX vs TSX/JSX — the
@{ … }shorthand and the@if/@forbranches whose fragments hold style blocks. - Core APIs — where the server's
cssgoes. - Build tools — how each bundler carries scoped CSS.