Components

A TSRX component is just a TypeScript function that produces JSX. Use a statement-container body for component-shaped templates, especially when local setup, comments, scoped styles, or multiple rendered children belong with the markup.

In practice, components are ordinary TypeScript functions or const values. A component can use @{...} as the function body, giving you one place for local state, derived values, template control flow, rendered elements, and scoped styles.

 1 export function Button({ label, onClick }: {
 2   label: string;
 3   onClick: () => void;
 4 }) @{
 5   <>
 6     // Ripple, Preact, Solid, and Vue host elements:
 7     <button class="btn" {onClick}>{label}</button>
 8 
 9     // React host elements:
10     // <button className="btn" {onClick}>{label}</button>
11 
12     <style>
13       .btn {
14         padding: 0.5rem 1rem;
15         border-radius: 4px;
16       }
17     </style>
18   </>
19 }

Export them like any other function: export function Name() @{ <div /> }. The compiler turns that into the right component shape for the target you're using.

When a bit of logic should stay plain JavaScript rather than render into the template, put it in a normal function beside the markup. Use function fn() { ... } for ordinary control flow, then call helpers from event handlers or expressions: onClick={fn}.

 1 export function Counter() @{
 2   let count = 0;
 3 
 4   // Plain JS/TS control flow can live in nested functions.
 5   function increment() {
 6     if (count >= 10) {
 7       count = 0;
 8     } else {
 9       count += 1;
10     }
11   }
12 
13   <button onClick={increment}>Count: {count}</button>
14 }

Statement containers

When a template scope mixes TypeScript setup with rendered output, wrap the setup in@{...}. TSRX treats everything before the final renderable child as script, then the container must finish with exactly one output node.

That final output can be a JSX element, a JSX fragment, or JSX control flow like @if, @for, @switch, or @try. It cannot be a bare expression container, and no script statements can appear after it.

If the rendered part needs multiple siblings or text next to elements, wrap those children in a fragment so they become one output. The rule applies locally to component bodies, element children, and control-flow branches, so setup can stay close to the markup that uses it without turning ordinary template text into JavaScript.

Control-flow bodies are implicit statement containers too:@if,@for,@switch, and@tryarms all use{}blocks.

If you write setup statements and then a bare JSX element inside a normal{}function body, the compiler will ask you to add the missing@. Plain braces are JavaScript; statement-container braces are@{...}.

 1 function ProductCard({ product }: { product: Product }) @{
 2   const name = product.name.trim();
 3   const price = money(product.price);
 4 
 5   <article>
 6     <h2>{name}</h2>
 7 
 8     <footer>@{
 9       const hasDiscount = product.discount > 0;
10 
11       <>
12         <strong>{price}</strong>
13         @if (hasDiscount) {
14           <span>Sale</span>
15         }
16       </>
17     }</footer>
18   </article>
19 }

JS comments

JavaScript line and block comments are valid inside template children. They are comments, not rendered text, so you can annotate element bodies and control-flow branches without wrapping the note in braces.

 1 function Menu({ showAdmin }: { showAdmin: boolean }) @{
 2   <nav>
 3     // Comments can sit between template children.
 4     <a href="/">Home</a>
 5 
 6     /*
 7      * Block comments stay comments too.
 8      */
 9     @if (showAdmin) {
10       // Branch comments are allowed too.
11       <a href="/admin">Admin</a>
12     }
13   </nav>
14 }

Lazy destructuring

TSRX adds two sigils —&{ ... }for objects and&[ ... ]for arrays — that look like destructuring but defer the actual property access until each binding is read. Every reference to a lazy binding is compiled back to a property lookup on the source object, so downstream readers pick up the latest value without a manual getter.

The most common use is in component parameters. Regular object destructuring would snapshot each prop at call time and break any target runtime that relies on per-access reactivity (Ripple, Solid, Vue).&{ ... }preserves that reactivity while keeping the ergonomic destructuring syntax:

1 function UserCard(&{ name, age }: { name: string; age: number }) {
2   return <div>
3     <h2>{name}</h2>
4     <p>Age: {age}</p>
5   </div>;
6 }

Lazy destructuring is supported across every target. In React and Preact it compiles to direct property access on the source object; in Ripple, Solid, and Vue it preserves tracked, signal, or proxy-backed reactivity without needing a wrapper call.

Prop shorthands

When a prop name matches the variable you're passing, you can use the shorthand{name}instead ofname={name}. This works for any attribute or prop — including event handlers like{onClick}.

1 // Instead of repeating the name:
2 <Input value={value} onChange={onChange} />
3 
4 // Use the shorthand:
5 <Input {value} {onChange} />

Lexical scoping

Every statement container and control-flow block creates its own lexical scope. You can declare variables, compute derived values, or call functions there — they're scoped to that block and won't leak into the surrounding function.

 1 function App() @{
 2   const name = 'World';
 3 
 4   <div>@{
 5     // This is a new scope - you can declare variables here
 6     const greeting = 'Hello, ' + name + '!';
 7 
 8     <h1>{greeting}</h1>
 9   }</div>
10 }

This applies to all block-like contexts: statement containers and control flow branches (@if,@for,@switch,@try). Each one has its own scope.

Conditional rendering

Use@if/else if/elsetemplate expressions directly inside templates, or return them directly from a component. Branches render their template output, while ordinaryifstatements in setup stay plain JavaScript for guard returns. Directreturn,continue, andbreakare not allowed inside@iftemplate branches.

1 function StatusBadge({ status }: { status: 'active' | 'idle' | 'offline' }) @{
2   @if (status === 'active') {
3     <span class="badge active">Online</span>
4   } @else if (status === 'idle') {
5     <span class="badge idle">Away</span>
6   } @else {
7     <span class="badge">Offline</span>
8   }
9 }

List rendering

Render lists with@for (... of ...)loops. TSRX extends the syntax with optionalindexandkeyclauses so you don't need separate counters or key-extraction boilerplate. The optional@emptybranch renders when the iterable has no items.

Filter the collection before passing it to@forwhen some items should not render. Use@emptyfor the no-items branch. Directcontinue,break, andreturnstatements are not allowed in@fortemplate loop bodies; nested functions keep ordinary JavaScript control flow.

Other JavaScript loops are not template rendering constructs: regularfor,for...in,while, anddo...whileare rejected in TSRX template scope. Move imperative loops into a nested function or effect, or render collections with@for (... of ...).

 1 function TodoList({ items }: { items: Todo[] }) @{
 2   const visibleItems = items.filter((item) => !item.hidden);
 3 
 4   <ul>
 5     @for (const item of visibleItems; index i; key item.id) {
 6       <li>{i + 1}. {item.text}</li>
 7     } @empty {
 8       <li>No todos yet</li>
 9     }
10   </ul>
11 }

Switch statements

Multi-branch rendering uses an@switchtemplate expression. Eachcaseordefaulthas its own{}body, cases never fall through, andbreakandreturnare invalid inside the case body.

 1 function StatusMessage({ status }: { status: string }) @{
 2   @switch (status) {
 3     @case 'loading': {
 4       <p>Loading...</p>
 5     }
 6     @case 'success': {
 7       <p class="success">Done!</p>
 8     }
 9     @default: {
10       <p>Unknown status.</p>
11     }
12   }
13 }

Error boundaries

Wrap components in@try/catchto create error boundaries. If a child component throws, the catch block renders its fallback children.

1 function SafeProfile({ userId }: { userId: string }) @{
2   @try {
3     <UserProfile id={userId} />
4   } @catch (error) {
5     <div class="error">
6       <p>Something went wrong.</p>
7     </div>
8   }
9 }

Thecatchblock also receives aresetfunction as its second argument. Callingreset()clears the error state and re-renders the children, which is useful for building retry UIs:

 1 export function RetryBoundary() @{
 2   @try {
 3     <ComponentThatMightFail />
 4   } @catch (e, reset) {
 5     <div>
 6       <p>Error: {e.message}</p>
 7       <button onClick={() => reset()}>Try again</button>
 8     </div>
 9   }
10 }

Async boundaries

Wrap a component subtree in@try/pending/catchto handle async children. While a lazy child or resource is in flight, thependingbranch renders; when it resolves, the@trybody takes over; if it rejects — or any child throws synchronously —catchruns. Bothpendingandcatchare optional and can be used independently. Each branch follows the same setup-then-one-output rule as a statement container.

Async work itself is expressed using the target's own lazy-loading primitive —lazy()fromreact,octane,preact/compat, orsolid-js,defineVaporAsyncComponent()on Vue Vapor, andtrackAsync()on Ripple. In Solid, Vue, and Ripple targets, returned TSRX templates are synchronous and do not allow inlineawait. On React and Preact, template-bodyawaitis supported and TSRX emits an async component function.

React/Preact note:for await...ofis not supported inside component templates. Use an upstream async helper and render the resolved data.

 1 const UserProfile = lazy(() => import('./UserProfile.tsrx'));
 2 
 3 export function App() @{
 4   @try {
 5     <UserProfile id={1} />
 6   } @pending {
 7     <p>Loading...</p>
 8   } @catch (e) {
 9     <p>Something went wrong.</p>
10   }
11 }

Dynamic elements and components

Use the dynamic tag syntax<{expression}>when the host element tag or component constructor is selected at runtime. The expression can evaluate to a string tag name like'section'or a component value, and the closing tag repeats the same expression:</{expression}>. No import is required; each target compiler lowers the tag to its own runtime helper.

The tag expression must be able to resolve to an element name: an identifier, member access, static string, or a runtime expression composed of those. Calls, spreads, string concatenation, string interpolation, and static non-string literals are not valid tag names.

 1 type PanelProps = {
 2   as?: 'section' | 'article';
 3   item: Item;
 4   expanded: boolean;
 5 };
 6 
 7 function Summary({ item }: { item: Item }) @{
 8   <p>{item.summary}</p>
 9 }
10 
11 function Details({ item }: { item: Item }) @{
12   <article>{item.body}</article>
13 }
14 
15 export function Panel({ as = 'section', item, expanded }: PanelProps) @{
16   const Body = expanded ? Details : Summary;
17 
18   <{as} className="panel">
19     <{Body} item={item} />
20   </{as}>
21 }

The example uses React'sclassNameprop. Ripple, Preact, Solid, and Vue useclassfor host classes. Removed dynamic tag forms like<@tag />and<@Component />are not part of current TSRX.

Scoped styles

A<style>block styles only the elements next to it and below it. The compiler rewrites every selector in the block to also require a hash class: a class it adds to each element the block reaches, so the block's selectors match only there. Rules never leak into child components.

A block styles its siblings and everything below them

A<style>block is a child of an element or a fragment. It styles the other children of that list and everything below them. It never styles the element that contains it. To style an element, put the block and the element side by side in a fragment.

 1 function Panel() @{
 2   <>
 3     <style>
 4       /* scope A: this fragment's children list */
 5       div { color: black; }
 6     </style>
 7     <div class="outer">Black</div>
 8 
 9     <section>
10       <style>
11         /* scope B: the section's children list, nested inside A.
12            It styles the items beside it, never <section> itself. */
13         div { font-weight: bold; }
14       </style>
15       <div class="inner">Black and bold</div>
16     </section>
17 
18     <style>
19       /* still scope A: shares the first block's hash */
20       p { margin: 0; }
21     </style>
22     <p>No margin</p>
23   </>
24 }

Every element gets the hash class of each scope around it, outer first. The outerdiv, thesection, and the trailingpget the hash class of scope A; the nesteddivgets A and B. Outer rules reach into nested scopes; inner rules never reach out. Elements returned from a callback such asitems.map((item) => <li />)are outside the scope and get no hash class.

Sibling blocks share one hash class

Several blocks in one children list share one hash class, so thep { margin: 0; }block above is part of scope A. A nested children list with blocks of its own is a nested scope with its own hash class. A block can sit anywhere in its list; only the list it sits in decides what it styles.

Put a block and its output in one fragment

A@{ ... }body and every@if,@for,@switch, or@trybranch render exactly one output node, and a<style>block counts as an output node. A block beside the output is an error, and a block that is the only output styles nothing. Wrap the block and the output it styles in a fragment.

 1 function Note({ open }: { open: boolean }) @{
 2   <div>
 3     @if (open) {
 4       // Error: two output nodes in one branch.
 5       <style>
 6         p { color: red; }
 7       </style>
 8       <p>Red</p>
 9     }
10   </div>
11 }
12 
13 function Note({ open }: { open: boolean }) @{
14   <div>
15     @if (open) {
16       // Works: one output node, a fragment holding the block and the <p>.
17       <>
18         <style>
19           p { color: red; }
20         </style>
21         <p>Red</p>
22       </>
23     }
24   </div>
25 }

Raw CSS needs a template around it

Raw CSS in<style>is TSRX template syntax. A block with CSS in it needs a@{ ... }body or a control-flow branch somewhere above it. In plain TSX, write<style>{css}</style>: that is an ordinary element with an expression child. The compiler leaves it untouched, gives it no hash class, and never scopes it.

Later rules win

CSS is output in source order, outer scopes first, and at equal specificity the rule output last wins. Outer scopes come before the scopes nested in them, even when the nested scope is written first. A scope's blocks stay together as one group, in source order. Sibling scopes follow source order. An applied theme comes before the block that applies it. In the first example, the nested block makes itsdivbold without disturbing the color the outer block set.

A block in a branch styles only that branch

A<style>block inside an@ifor@forbranch 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.

 1 function Status({ ready }: { ready: boolean }) @{
 2   <>
 3     <style>
 4       .status { padding: 0.5rem; }
 5     </style>
 6     <section class="status">
 7       @if (ready) {
 8         <>
 9           <style>
10             .ok { color: green; }
11           </style>
12           <p class="ok">Ready</p>
13         </>
14       } @else {
15         <>
16           <style>
17             .wait { color: gray; }
18           </style>
19           <p class="wait">Waiting</p>
20         </>
21       }
22     </section>
23   </>
24 }

.statussits outside the branches and reaches both of them..okmatches only thepin the ready branch and.waitonly thepin the other one, and both rules are in the stylesheet at all times.

Unused selectors are removed

A selector that matches nothing the block can reach is removed from the output and left behind as acomment. That includes a selector that only matches the element containing the block: the block cannot reach it.

Escape the scope with :global()

:global(...)marks the wrapped part of a selector as unscoped: it gets no hash class. Everything outside the parentheses is still scoped, so where the:globalsits decides how far the rule can reach. The block form:global { ... }does the same for every rule inside it, and both forms work with CSS nesting.

 1 function Card({ html }: { html: string }) @{
 2   <>
 3     <style>
 4       /* Bare: a page-wide rule. Matches anywhere on the page. */
 5       :global(.toast) { position: fixed; }
 6       /* → .toast */
 7 
 8       /* Prefixed: only below your scoped .card, never above it. */
 9       .card :global(.footnote) <span class="css-br">{</span> font-size: 0.85em; }
10       /* → .card.tsrx-1a2b3c4d .footnote */
11 
12       /* Leading: your element, when an ancestor carries the class. */
13       :global(.theme-dark) .card { background: black; }
14       /* → .theme-dark .card.tsrx-1a2b3c4d */
15 
16       /* Compound: your element, with a class another library toggles. */
17       .card:global(.is-open) <span class="css-br">{</span> border-color: blue; }
18       /* → .card.tsrx-1a2b3c4d.is-open */
19 
20       /* Block form: several page-wide rules at once. */
21       :global {
22         body { margin: 0; }
23         .toast { position: fixed; }
24       }
25       /* → body {} .toast {} */
26 
27       /* Nested block: several classes below .card, prefix written once. */
28       .card {
29         :global {
30           .footnote { font-size: 0.85em; }
31           .caption { color: gray; }
32         }
33       }
34       /* → .card.tsrx-1a2b3c4d { .footnote {} .caption {} } */
35 
36       /* Plain nesting, for contrast: both parts scoped. */
37       .card { .title { font-weight: bold; } }
38       /* → .card.tsrx-1a2b3c4d { .title.tsrx-1a2b3c4d {} } */
39 
40       /* Error tsrx-css-global-placement: :global in the middle. */
41       /* .card :global(.footnote) <span class="css-sel">.title</span> <span class="css-br">{</span> <span class="css-br">}</span> */
42     </style>
43     <div class="card" innerHTML={html} />
44   </>
45 }

A bare:global(.toast)is a plain page-wide rule. It matches anywhere: ancestors, siblings, other components, exactly like a rule in a global stylesheet. A scoped prefix,.card :global(.footnote), reaches only elements below your scoped.card, a child component's internals included; it can never climb up. A leading:global(.theme-dark) .cardstyles your own element only when an ancestor carries the class, and a compound.card:global(.is-open)styles your own element with a class another library toggles on it. A:globalin the middle of a selector is thetsrx-css-global-placementerror: it may only start or end a selector. A:global { ... }block drops its wrapper, leaving a comment behind, and everything inside it is unscoped: at the top level it is several page-wide rules at once, and nested under a scoped rule such as.cardit reaches only below that element, exactly like the prefixed.card :global(.footnote). Plain nesting without:globalscopes both parts.

Scoped rules beat bare global rules

A scoped rule adds one hash class to its first compound only; later compounds get:where(.<hash>), which adds no specificity, so.card .titlebecomes.card.<hash> .title:where(.<hash>). That makes:globalpredictable: a scoped.note(0,2,0) beats a bare:global(.note)(0,1,0) from anywhere on the page; atheme.$classor class-map rule carries its hash too, so it also beats a bare global; and a prefixed.card :global(.note)(0,3,0) beats a child component's own.noterule (0,2,0). At equal specificity the later stylesheet in module order wins.

Pass a class to a child instead of reaching in

To let a child component pick up your styles, passtheme.$classor a class-map entry such astheme.noteas a prop. The dependency is visible in code, the child decides which of its elements receive it, renaming a class inside the child cannot silently break the parent, and the hash keeps the rule on the elements that carry it. With:globalthe child has no say and cannot see who styles it. Keep:globalfor a child you cannot change: a third-party component, or rendered HTML and markdown. Always put a scoped selector in front so the rule cannot reach ancestors or unrelated components, and keep it narrow: it overrides the child's own rules by specificity. To style several of the child's classes, nest one:global { ... }block under your scoped selector, so the prefix is written once.

 1 // Preferred for a child you own: the child takes a class through a prop.
 2 function Note({ class: className }: { class?: string }) @{
 3   <p class={'note ' + (className ?? '')}>...</p>
 4 }
 5 
 6 function Card() @{
 7   const theme = <style>
 8     .note { color: gray; }
 9   </style>;
10   /* → .note.tsrx-1a2b3c4d; only elements carrying theme.note match */
11 
12   <Note class={theme.note} />
13 }
14 
15 // For a child you cannot change: a scoped prefix, then a :global block.
16 function Article({ html }: { html: string }) @{
17   <>
18     <style>
19       .body {
20         :global {
21           .footnote { font-size: 0.85em; }
22           .caption { color: gray; }
23         }
24       }
25       /* → .body.tsrx-5e6f7a8b { .footnote {} .caption {} } */
26     </style>
27     <div class="body" innerHTML={html} />
28   </>
29 }

Page-wide rules such asbody, resets, and fonts belong in a.cssfile. A bare:globalselector or a top-level:global { ... }block works, but it is a global stylesheet hidden inside a component. Never write a bare:globalselector for anything but page-level elements.

I want to ...Use ...
Style my own elementsA<style>block beside them; nothing global
Let a child component pick up my stylesPasstheme.$classortheme.cardas a prop
Style a child I cannot change (third-party component, rendered HTML).wrapper :global(.their-class), or.wrapper { :global { ... } }for several classes; scoped prefix first, keep it narrow
React to page-level state:global(.theme-dark) .cardor:global([data-theme='dark']) .card
Style my element with a class another library toggles.card:global(.is-open)
Page-wide rules:body, resets, fontsA.cssfile; a bare:global(body)only for page-level elements

Global Keyframes

Keyframes are scoped by default: the compiler adds the style block's hash to each animation name and rewrites matchinganimationandanimation-namereferences in that block. To share keyframes across components, prefix the name in the declaration with-global-.

 1 function App() @{
 2   <>
 3     <div class="parent"><Child /></div>
 4 
 5     <style>
 6       /* Scoped: the compiler renames slideIn and its references here. */
 7       @keyframes slideIn {
 8         from { transform: translateX(-100%); }
 9         to { transform: translateX(0); }
10       }
11 
12       /* Global: emitted as fadeIn, usable from other components. */
13       @keyframes -global-fadeIn {
14         from { opacity: 0; }
15         to { opacity: 1; }
16       }
17 
18       .parent { animation: slideIn 1s; }
19     </style>
20   </>
21 }
22 
23 function Child() @{
24   <>
25     <div class="child">Child content</div>
26 
27     <style>
28       /* Reference the global name without the -global- prefix. */
29       .child { animation: fadeIn 1s; }
30     </style>
31   </>
32 }

The compiler emits@keyframes -global-fadeInas@keyframes fadeIn, without a hash. Other style blocks can useanimation: fadeIn 1soranimation-name: fadeInonce the defining stylesheet is loaded. Only the keyframe name is global; selectors such as.childremain sibling-scoped.

React note: TSRX keeps authored attributes as written. UseclassNameon React host elements and React component props, just like ordinary JSX. The hash class the compiler adds still goes out through React'sclassName.

Style composition

Assign a<style>block to a variable to get a theme you can hand to other scopes and components.

Assigning a block gives you an object

Assigning a<style>expression to a variable turns it into an object instead of a scoped block. The object has$class, the block's own hash class, plus one key per class selector whose value is the hash class and the class name together. Pass those strings to child components throughclass, orclassNamefor React components. The block can live at module scope or inside a component body.

 1 export const theme = <style>
 2   div { color: green; }
 3   .dark { color: purple; }
 4 </style>;
 5 
 6 // theme.$class -> 'tsrx-1a2b3c4d'
 7 // theme.dark   -> 'tsrx-1a2b3c4d dark'
 8 
 9 function Badge({ class: className }: { class?: string }) @{
10   <span class={'badge ' + (className ?? '')}>New</span>
11 }
12 
13 function App() @{
14   <>
15     // Ripple, Preact, Solid, and Vue component props:
16     <Badge class={theme.dark} />
17 
18     // React component props:
19     // <Badge className={theme.dark} />
20   </>
21 }

A theme keeps every selector; a class map keeps only classes

A block that is exported, applied, or whose$classis read is a theme. It keeps every selector, element and descendant selectors included. A block that is only read through class entries such asstyles.cardis a class map. It keeps only its standalone class selectors; the others are removed as unused, because no element can get its hash class.

apply adds a theme to a whole scope

<style apply={theme} />adds the theme's$classto every element of the scope it sits in, so the theme's element and descendant rules reach those elements as if the theme had been written there. A self-closed apply adds no hash class of its own.<style apply={theme}>...</style>does both: it applies the theme and declares a scoped block in one go.

 1 import { theme } from './theme.tsrx';
 2 
 3 export function Panel() @{
 4   <>
 5     <style apply={theme}>
 6       /* scope A; also stamps theme.$class on every element of A */
 7       div { color: black; }
 8     </style>
 9 
10     <span class={theme.dark}>Purple</span>
11     <div>Black: the local rule beats the theme's green</div>
12 
13     @{
14       <>
15         <style>
16           /* scope B, nested inside A */
17           div { font-weight: bold; }
18         </style>
19         <div>Black and bold: A, B, and the theme all reach here</div>
20       </>
21     }
22   </>
23 }
24 
25 export function Card() @{
26   <>
27     // Self-closed: applies the theme, declares no rules of its own.
28     <style apply={theme} />
29     <article>
30       <h2>Green system-ui</h2>
31     </article>
32   </>
33 }

The local rule wins

A theme's CSS is output before the block that applies it, and a theme must be declared before any block that applies it. So a rule written in the applying scope beats the theme's rule at equal specificity: thedivabove is black, not green. A theme from the same module is written into the output as a string literal; an imported theme is read at runtime throughtheme.$class.

Themes compose

A theme can apply another theme, andapply={[a, b]}applies several in order. The resulting$classlists the applied themes' classes first and the block's own hash class last, so a composed theme reaches an element with everything it was built from.

 1 // theme.tsrx
 2 const base = <style>
 3   div { font-family: system-ui; }
 4 </style>;
 5 
 6 export const accent = <style apply={base}>
 7   div { color: blue; }
 8 </style>;
 9 
10 // accent.$class -> base's hash, then accent's own hash
11 
12 const spacing = <style>
13   p { margin: 0; }
14 </style>;
15 
16 // A self-closed assigned block bundles themes without CSS of its own.
17 export const bundle = <style apply={[spacing, accent]} />;
18 
19 export function Composed() @{
20   <style apply={[spacing, accent]} />
21   <div>
22     <p>Blue system-ui, no margin</p>
23   </div>
24 }

Opt single elements in with$class

applyis all or nothing for a scope. To pick elements instead, puttheme.$classon them. Reading$classanywhere in the module makes the block a theme, so its element and descendant rules are kept, and only the elements that carry the class match them. The value is a plain string, so a child component can take it through a prop and put it on its own elements, where it comes before the child's own hash class.

 1 function Card({ parentClass }: { parentClass: string }) @{
 2   <>
 3     <style>
 4       .local { padding: 0; }
 5     </style>
 6     <article class={`local ${parentClass}`}>
 7       <h2 class={parentClass}>Title</h2>
 8     </article>
 9   </>
10 }
11 
12 export function App() @{
13   const theme = <style>
14     div { color: blue; }
15     .card { color: red; }
16   </style>;
17 
18   <>
19     <Card parentClass={theme.$class} />
20     <div class={theme.$class}>Blue: opted in</div>
21     <div class={theme.card}>Red: theme.card</div>
22     <p>Untouched</p>
23   </>
24 }
25 
26 // theme.$class is read, so theme keeps div { color: blue }.
27 // Card's <article> and <h2> carry theme.$class, then Card's own scope hash.

Prefer$classwhen a theme should reach a handful of elements, or elements the parent does not render itself, andapplywhen it should reach a whole scope. The two combine. Listing several themes'$classvalues on one element composes them there the wayapply={[a, b]}composes them on a scope.

Cascade layers pass through

@layerrules inside a block are kept as written, and the selectors inside them are still scoped, so a codebase that already orders its layers can keep doing so.

 1 function Card() @{
 2   <>
 3     <style>
 4       @layer components {
 5         .card { padding: 1rem; }
 6       }
 7     </style>
 8     <div class="card">Layered</div>
 9   </>
10 }
11 
12 // Emitted CSS keeps the layer and scopes the selector inside it:
13 // @layer components { .card.tsrx-1a2b3c4d { padding: 1rem; } }

Why not build scoping on@layer? Layers order rules globally: every participant has to agree on layer names and declare their order up front, and a layer cannot be attached to one subtree of elements. Sibling scoping gives an order you can read off the file, andapplyadds classes, so a theme reaches exactly the elements of one scope.

Released under the MIT License.

Copyright © 2025-present Dominic Gannaway