Composing, and who wins
A block is rarely one thing. A button has a base, a variant, a size and a couple of toggles — and a toggle changes groups of keys, not just values.
Two spellings do that, both written inside the block, and both mean the same: later wins, which is the rule you already have when you read CSS.
const button = @@(
display: inline-flex; align-items: center; gap: 8px;
border-radius: 6px; font-weight: 600; cursor: pointer;
&:hover { filter: brightness(1.02); }
);
const variants = {
primary: @@( background: #10b981; color: #fff; &:hover { background: #0e9f6e; } ),
secondary: @@( background: transparent; color: #10b981; box-shadow: inset 0 0 0 1px #10b981; ),
};
class Button extends Component {
@state variant: "primary" | "secondary" = "primary";
@state disabled = false;
@state full = false;
render() {
return (
<button css={@@(
...{button};
...{variants[this.variant]};
if ({this.disabled}) {
opacity: 0.5;
cursor: not-allowed; /* wins over `cursor: pointer`, because it is BELOW it */
}
width: {this.full ? "100%" : "auto"};
)}>press</button>
);
}
}
...{ … }merges another block here, and it works across files. What it merges is a value, so it can be imported, put in an object, or picked out of one.if ({ … }) { … }merges a group only when the condition holds.
Both are arguments of the same merge, in the order you wrote them.
There is no @else. For a choice between several blocks, spread a lookup — the one above,
keyed by the variant. TypeScript then checks the map covers the union, so adding a third variant and
forgetting the map is reported. For a two-way choice of a single value, write a hole with a
ternary.
Why the condition is inside { }
Because that is the one rule this syntax has: TypeScript appears inside { } and nowhere else.
if (this.disabled) would read more naturally and would be a second spelling for the same thing —
and the moment there are two, every reader has to learn which one a given line is.
What is checked in a group
Everything a block is checked for, a group is checked for the same way — a typo inside if is the
same error, with the same did you mean, that it is outside one.
The condition is an ordinary expression and is not required to be a boolean. It is the same
if JavaScript has, and if ({items.length}) is the shape people reach for; demanding a boolean
would refuse it for nothing. What is refused is a condition that can never be FALSE, because that is
a group that can never be off. A type that holds false, 0, "", null or undefined is a
condition; one that holds none of them is a mistake.
| written | what happens |
|---|---|
if ({this.method}) — a method you forgot to call | reported: a function is always truthy — call it, or test a value |
if ({someObject}), if ({"yes"}), if ({items}) | reported: this is always truthy, so the group can never be off |
if ({maybeUndefined}) | fine — that is the shape a prop has |
if ({items.length}), if ({name}) | fine — 0 and "" are false, so the group can be off |
...{notABlock} | reported: only a style block can be spread |
...{base} inside &:hover or a @media | reported — see below |
Nesting, and a shorthand meeting its longhand
if nests, and a nested condition means both must hold. A selector inside a group and a group inside
a selector mean the same thing, so write whichever reads better.
One thing worth knowing, because CSS itself works this way: a shorthand written later clears the
longhands it covers. If a base sets padding-left: 40px and a modifier sets padding: 8px, the
modifier wins completely — which is what those two declarations would do in a plain stylesheet. The
other direction leaves both standing, also as CSS does.
That holds across the logical spellings too: margin sets all four sides whichever way the text
runs, so it clears margin-inline, margin-block-start and the rest.
A spread goes at the top of a block, or inside if — not inside a selector or a @media. It
merges a whole block, and a block carries the context each of its own declarations was written in, so
there is nothing sensible for a nested one to mean. An if is fine: it changes no declaration, it
only decides whether the whole thing lands.
Logical and physical, in one block
margin-inline is the left and right margins when the text runs across, and the top and bottom ones
when it runs down. Which it is depends on writing-mode, and that is not known until the page is
laid out.
So a block that writes a physical side and then a logical one that might cover it is reported:
margin-left: 4px;
margin-inline: 8px; ✗ whether this overrides the line above depends on writing-mode
Write both in one system — margin-inline-start and margin-inline, or margin-left and margin
— and the question does not arise. The other order is fine, and so is a four-side shorthand in
either position, because neither leaves anything for the layout to decide.
The one place this is not plain CSS
Every rule is emitted inside @layer ramonda, and a layer is the one thing here that behaves
differently from CSS written by hand. It is worth two minutes, because it decides who wins.
A layer is a bucket, and buckets are ranked ahead of everything else — a rule in no bucket beats a rule in one, and that is decided before specificity is looked at. The same two declarations, five ways:
.a { color: red } .b { color: blue } blue the later one wins
.b { color: blue } .a { color: red } red the later one wins
.a { color: red } @layer L { .b { color: blue } } red
@layer L { .b { color: blue } } .a { color: red } red order stops mattering
.a { color: red } @layer L { p#q.b.c { color: blue } } red specificity stops mattering too
So your own stylesheet always wins:
/* app.css */
.panel { padding: 0 }
const panel = <div className="panel" css={@@( padding: 12px; )}>…</div>;
The element gets padding: 0. Not because of where the files load, and not because one selector is
stronger — the block is in a layer and app.css is not.
This is on purpose, and it is a trade. Adding blocks to a project that already has CSS does not
make you fight your own stylesheet: nothing has to be rewritten and no !important appears. What it
costs is CSS's own answer, where those two would be settled by whichever was written later.
Without the layer there is no stable answer to give. Which of the two wins would depend on which stylesheet your bundler emits last — something you do not choose, and which can differ between a dev server and a build. The layer replaces that with a rule you can read here.
Letting a block win
Put your own CSS in a layer too, and say which order the layers go in. The same
.panel { padding: 0 } against the same block:
.panel { padding: 0 } 0px unlayered, so it wins
@layer app, ramonda; @layer app { .panel … } 12px app ranked first, so it loses
@layer ramonda, app; @layer app { .panel … } 0px app ranked last, so it wins
@layer app { .panel … } 12px no statement: first seen is first
The @layer a, b; statement is what ranks them, and a layer named later in it wins — so
@layer ramonda, app; puts your CSS above the blocks, and @layer app, ramonda; puts it below.
Without that statement the order is whichever layer the browser meets first, which is the same "your bundler decides" problem in a smaller box. Write the statement.
What a block cannot hold is @layer itself
const a = <div css={@@(
@layer buttons {
color: red;
}
)}>…</div>;
A layer written in a block would be a sublayer of ramonda, and CSS orders layers it was given no
explicit order for by first appearance. The stylesheet writes one file at a time, so which sublayer
wins would be decided by which file your bundler reached first — and there is nowhere inside a block
to write the @layer a, b; that would settle it. It looks like a cascade control and cannot be
one, so it is reported rather than emitted.
Next
- Project settings — the names a block emits, and making the rules stricter.
- Tooling — formatters, linters, and other JSX libraries.