Writing a block
A block is @@( … ), and what goes inside it is CSS.
A block is a value, and that value is a string
The classes it compiled to, space separated — so it goes on className, in the attribute or in a
binding you name and use later:
const card = <div className={@@( display: flex; )}>inline</div>;
const panel = @@( display: flex; );
const named = <div className={panel}>named, and reusable</div>;
Name it when it is long, when two elements share it, or when you want to compose it into another.
Beside a class of your own, use mergeClassNames:
import { mergeClassNames } from "@ramonda/css";
const row = <div className={mergeClassNames("lead", @@( display: flex; ))}>a row</div>;
Not a template literal. `lead ${@@( … )}` is text to the compiler, so a block written in one is
found by nothing at all — it is reported rather than compiled to silence. mergeClassNames is also what keeps
one class per thing set when two blocks meet; joining them with a space would keep both and let the
stylesheet break the tie.
Nothing about a block requires JSX. const panel = @@( … ) is a value like any other, and a
block in a .ts file with no markup in it works the same way — this extends TypeScript, not JSX.
Each declaration becomes a class
const row = <div className={@@( display: flex; color: #333; )}>x</div>;
ships as
<div class="r-disp-flex r-c-#333">x</div>
A shorthand becomes the longhands it sets, each a class of its own — padding: 8px is four of them.
Shorthands is what that changes, which is less than it sounds.
Two files that write display: flex get the same class, without knowing about each other,
because the name is derived from the declaration and from nothing else. That is also what makes
composing possible: merging two blocks keeps, per thing set, the one
written later — and it can only do that if each thing set has a class of its own to keep or drop.
Each file carries the rules it names in its own stylesheet, so under Vite a code-split route stands on its own; esbuild puts them all in the entry's sheet — see a code-split app. Where two files produce identical stylesheets the bundler dedupes them by content, and it costs nothing.
& is CSS, not ours
& means the thing this rule is nested in. It is CSS Nesting, standardised in 2023 and in every
current browser, and the browser resolves it — nothing here rewrites it.
const card = @@(
padding: 12px;
&:hover { background: #f8fafc; }
&.active { border-color: #10b981; }
& .title { font-weight: 600; }
);
Two of CSS's own rules surprise people, so they are worth saying out loud:
- The space matters.
& .titleis a descendant;&.titleis the same element carrying both classes. One character, two different rules. - A prelude that names no parent gets one.
div { … }inside a block means& div, which is what CSS Nesting says a bare selector means. The two compile to the same class.
A value in a declaration is written out
Everything in a block is decided when the block compiles. A TypeScript expression standing in for a
declaration's value — $( … ), a hole — is refused:
color: $(this.brand); ✗ hole-not-allowed
Three things cover what a hole would be reached for.
If the value is one of two, write the choice. Both values become classes and the condition picks one — see a choice between two values:
class Note extends Component<{ urgent: boolean; text: string }> {
render() {
return <p className={@@( color: $(this.props.urgent) ? #ef4444 : #374151; )}>{this.props.text}</p>;
}
}
If the value is one of a few, write them all out. match makes each
arm its own rule and its own class, so the subject only picks between classes that already exist:
class Chip extends Component<{ tone: "hot" | "cold" }> {
render() {
return (
<span className={@@(
padding: 4px 10px;
color: match $(this.props.tone) {
hot => #ff0055;
cold => #0ea5e9;
};
)}>{this.props.tone}</span>
);
}
}
If it really comes from data, declare it. @@property( … ) gives you a name the whole stylesheet
can read and your element can set — one name however many declarations read it:
const width = @@property( syntax: "<percentage>"; initial-value: 0%; inherits: false; );
const meter = @@( height: 8px; width: var($(width)); );
That page is values that come from data, and it is worth reading before you reach for either.
Why a hole is not simply allowed
A value written in a block becomes a class, shared by every element that carries it: color: red
emits r-c-red { color:red; }. A value from data cannot be a class, so a hole in a declaration
would have to be written on every element instead — a list of ten thousand rows, ten thousand style
attributes.
And a hole would belong to the declaration it stood in, so two declarations wanting one value would get two custom properties:
padding-left: {v}; padding-right: {v}; TWO variables, two classes
padding-left: var({pad}); padding-right: var({pad}); ONE, however many read it
$( … ) is how an expression gets in, and everywhere else it stands is untouched, because none
of them puts a value on an element: when $(…) and ...$(…) choose between whole rules,
match $(…) and $(…) ? a : b choose between classes, and var($(name)) names a @@property
site the compiler resolves before the CSS is written.
A value you build in TypeScript can be typed
Nothing goes into a block from TypeScript, but plenty goes onto an ELEMENT — the value you set a
registered property to, or a theme you hand to toStyle. Those are strings
you build, and they can be held to a shape.
The type goes where the value is made, which is where the error is worth having: on the line somebody wrote, not on the element that used it.
const border: CssDimension = `${weight}px`; ✓
const border: CssDimension = `${weight}pddx`; ✗ TS2322, on this line
No as const is needed — the annotation is the context — and it works in a getter, which is where a
value like this usually comes from:
import type { CssDimension } from "@ramonda/css";
class Card {
weight = 4;
get border(): CssDimension {
return `${this.weight}px`;
}
}
The unit set is a parameter, so an app that has settled on one says so:
const gap: CssDimension<"px"> = `${n}rem`; ✗ TS2322 — this app writes px
There is a union per family — CssLengthUnit, CssAngleUnit, CssTimeUnit, CssResolutionUnit
and CssFrequencyUnit — so CssDimension<CssLengthUnit> is a length and refuses 12deg. All of
them are generated from the same unit table the checker measures a typo against, so the two cannot
disagree.
One looseness, on purpose: any call is admitted. calc(), min(), clamp() and var() can
each produce any dimension and nothing in a type can read inside one, so calc(1rem + 2px) passes
CssDimension<"px">. Refusing calls would make the type useless in the one place you reach for it.
In a project with a config, name the property instead
CssDimension says a length. When your project has a
ramonda.css.ts, Value<"gap"> says whatever gap takes here — units
narrowed, closed lists, hardcoded: false and all. One property, one name, and the answer moves
when the config does.
For a value made at run time, this type is the only place that rule holds. A block reads such a
value through var(), and gap: var($(space)) is accepted however your config narrows gap — the
value does not exist yet. The style attribute takes any string. So the type goes on the value,
where it is made — most often a prop:
import type { Value } from "../css-system";
const space = @@property( syntax: "*"; initial-value: 8px; inherits: false; );
const stack = @@( display: flex; gap: var($(space)); );
class Stack extends Component<{ gap: Value<"gap"> }> {
render() {
return <div className={stack} style={{ [space]: this.props.gap }}>…</div>;
}
}
In a project that writes gap in px, <Stack gap="3rem" /> is refused on the caller's line.
The stylesheet cannot hold that rule for you. A registered property's syntax names a type —
<length> — and never a list of lengths, so @property has no way to say 8px or 16px. The
narrowing reaches a value made at run time only through its type, which is why the value set on
space above is a Value<"gap"> rather than a string.
And space is registered as *, because a syntax has to take everything the property does:
gap also takes normal, a CSS-wide keyword and a var(). A narrower one refuses part of what the
type allows — toStyle, which checks a value against the syntax, will not put a Value<"gap">
into a <length-percentage>.
Inside a block you never need it. A value written there is checked as it is, and a choice
between fixed values is a choice —
gap: $(dense) ? 4px : 8px; — which compiles to two classes and sets nothing on the element. A
function that hands back a whole block is typed CssBlock, not Value.
And AnyToken<"color"> is any token this project declares of that kind — which is what you want when
a function chooses between them and hands the one it picked on:
import { read } from "@ramonda/css";
import { $color } from "../css-system";
import type { AnyToken } from "../css-system";
declare const loud: boolean;
const pick = (): AnyToken<"color"> => (loud ? $color.accent : $color.surface);
const now = read(pick(), document.body);
Inside a block, a choice between two tokens is a choice
instead — color: $(loud) ? $color.accent : $color.surface; — and both become classes.
Comments
A block is CSS, so its comment is CSS's, and it is stripped from the emitted rule:
const card = @@(
/* the dot is the level, and it is set from the theme */
display: grid;
gap: 8px; /* matches the list beside it */
);
// is not a comment here. CSS has no line comment, so the characters would go into the
stylesheet as they stand, and the CSS compiler would refuse the whole file — naming nothing about
the block, the file, or the line it came from. So it is reported before it gets there, as
line-comment, on the // itself.
Inside $( … ) a comment is TypeScript's, because that is what the escape holds:
declare const loud: boolean;
const card = @@(
when $(/* the brand, not the accent */ loud) { color: #ff0055; }
);
Next
- What is checked — every rule and what it catches.
- Composing — reusing a block, conditions, and which declaration wins.