Summary#
A component's appearance lives in its variants block. Each leaf assignment there — Bg = Surface;,
P = 4;, BorderW = 1; — is a style prop: a name from a fixed vocabulary that the renderer maps to one or
more CSS properties. The vocabulary is closed and checked at compile time, so a misspelled prop (Backgroud = …) is
an error, not a declaration that silently styles nothing.
component Card() {
variants {
base {
Bg = Surface; // paint
P = 4; // spacing (a step on the 0.25rem scale)
Rounded = Card; // a theme radius token
BorderW = 1; // a 1px border...
Border = Border; // ...painted with the theme's Border color
Shadow = "0 1px 3px rgba(0,0,0,0.08)"; // a CSS box-shadow, or a theme Shadow token by name
}
}
render { Stack { Slot; } }
}Which token names do I actually have?
osy kit --tokensprints every token the starter theme ships — by group, with its value. (osy model --jsonreports the tokens your ownthemeblocks declare.) This page answers which style props exist; those answer which values they may take, and the two are different questions.
A style prop's value is one of four things: a number (lowered to the prop's unit — a spacing step, a pixel
length, or a raw number), a keyword from that prop's closed set (Display = Display.Flex), a theme tokens token
referenced by name (Bg = Surface), or a literal CSS string for a prop whose value is passed through verbatim
(Shadow, Cols, …) — the per-prop tables below give each prop's value forms. A theme group is never a prerequisite:
an app with no Shadow { } group can still write Shadow = "0 1px 3px rgba(0,0,0,0.08)". Reach for a token when the
value is part of the app's design language and should change with the theme; reach for a literal for a one-off.
Layout arguments (align, justify, gap) are separate — see layout primitives.
Signature#
variants {
base {
<StyleProp> = <number | keyword | tokenName>;
...
}
}Description#
The style-prop vocabulary is grouped by what it controls. Values are a number, a keyword (closed set), or a theme token name.
What color is it — background, text, shadow, opacity#
Every row below that says color token takes one of these — the starter theme's Colors, in scope wherever
using Osyrin.Ui; is:
Colors.Bg · OnBg · Surface · OnSurface · Border · BorderDanger · Muted · TextMuted ·
TextSecondary · Primary · OnPrimary · Danger · Success · Warning · Scrim · Transparent
Primary, Danger, Success and Warning are built with Palette.From(...), so each is a whole RAMP rather
than one colour. A step is reached by a semantic alias — Subtle · Muted · Default · Hover · Active ·
Strong (Bg = Colors.Primary.Hover) — or by number: 50 · 100 · 200 · 300 · 400 · 500 · 600 · 700 · 800 · 900 · 950 (Colors.Primary[600]). The plain name is the base.
An app that declares its own theme adds to this set rather than replacing it, so
osy model --json reports the tokens THIS app actually has, light and dark separately — the answer whenever
the list above is not the whole of it.
| Prop | CSS | Value |
|---|---|---|
Bg | background-color | color token |
Color | color | color token |
Border | border-color | color token |
Rounded | border-radius | length / radius token |
Shadow | box-shadow | CSS value, or a shadow token — haloes the element's rectangle |
TextShadow | text-shadow | CSS value — a shadow on the glyphs. A comma-separated list stacks, which is how a glow is built |
Opacity | opacity | number |
CaretColor | caret-color | color token — the text cursor in an input |
AccentColor | accent-color | color token — a native checkbox/radio/range, themed without rebuilding it |
BgImage | background-image | CSS value — a gradient or url(…). A theme token reaches it as "linear-gradient(…, var(--colors-accent), …)": every token is a custom property |
BgSize | background-size | Auto · Cover · Contain |
BgPosition | background-position | CSS value ("center", "50% 20%") |
Filter | filter | CSS value ("grayscale(1)", "blur(2px)") |
BackdropFilter | backdrop-filter | CSS value — blurs what is behind the box (a frosted modal scrim) |
MixBlendMode | mix-blend-mode | Normal · Multiply · Screen · Overlay · Darken · Lighten · ColorDodge · ColorBurn · HardLight · SoftLight · Difference · Exclusion · Hue · Saturation · Color · Luminosity |
Drawing a border, a divider, or a focus ring#
Border (above) sets only the color — a border becomes visible once it has width. BorderW turns a solid,
theme-colored hairline on; Border recolors it. Per-edge widths draw a single rule — a rail's right edge, a
table row's bottom edge — without a full box.
| Prop | CSS | Value |
|---|---|---|
BorderW | border-width | length (e.g. 1 → 1px) |
BorderTW | border-top-width | length |
BorderRW | border-right-width | length |
BorderBW | border-bottom-width | length |
BorderLW | border-left-width | length |
BorderStyle | border-style | Solid · Dashed · Dotted · None |
A hairline is BorderW = 1; (the style defaults to solid); recolor it with Border = <token>. A divider under a
list row is BorderBW = 1; on the row.
An outline is drawn outside the box and takes no layout space, which is what makes it the right tool for a focus ring: showing one must not move the page.
| Prop | CSS | Value |
|---|---|---|
OutlineW | outline-width | length (2 → 2px) |
OutlineColor | outline-color | color token |
OutlineStyle | outline-style | Solid · Dashed · Dotted · None |
OutlineOffset | outline-offset | length — the gap between the box and the ring |
A custom focus ring is those four inside a Focus { … } block. Only replace the default one, never remove it:
Outline* with no visible result is a keyboard user losing their place on the page.
Padding, margin, and the gap between children#
⚑ The whole spacing and sizing vocabulary, stated compactly. Measured on eval run 261: the model wanted a
full-viewport page, could not find minH in the style guide, wrote "safer to skip it", and shipped a worse
layout for a prop that has always worked.
⚑ Why a FENCE and not a paragraph. osy docs ui-styling prints every code fence in full and replaces the prose
with a line saying how much there is — so a prop named only in a sentence is absent from the answer the command
actually gives, while a prop named in a fence is always in it. These props were prose, and that is why a run could
look them up and not find them.
// SPACING — a number is a step on the 0.25rem scale (`p: 4` is 1rem); a string passes through (`mx: "auto"`).
Box(p: 4, px: 6, py: 2, pt: 1, pr: 1, pb: 1, pl: 1); // padding
Box(m: 4, mx: "auto", my: 2, mt: 1, mr: 1, mb: 1, ml: 1); // margin
Stack(gap: 3); // flex/grid gap — same scale, or a Space token
// SIZE — a number is px, a string passes through, or a Length token.
Stack(w: "100%", h: "4rem");
Stack(minW: "20rem", minH: "100vh"); // minH: "100vh" is how a page fills the viewport
Stack(maxW: "34rem", maxH: "40rem"); // maxW is how a column stops growing on a wide screen
Stack(w: Length.Measure); // …or a Length token the kit ships — `osy kit --tokens` lists every one
// ⚠ THERE IS NO `Length.Column`. A page-column width is YOURS to name — declare it in your own theme first:
// theme Default { Length { Column = "820px"; } } // …then `Stack(maxW: Length.Column)` resolves
// Without that declaration `Length.Column` is a compile error, not a fallback.| Prop | CSS | Value |
|---|---|---|
P Px Py Pt Pr Pb Pl | padding | a number is a step on the 0.25rem scale — p: 4 is 1rem; a string passes through (px: "auto") |
M Mx My Mt Mr Mb Ml | margin | same scale; mx: "auto" is how a block centres |
Gap | gap | the flex/grid gap, same 0.25rem scale, or a Space token |
How big is it — W/H, min/max, and flex grow#
| Prop | CSS | Value |
|---|---|---|
W H | width height | a number → px, a string → as-is (w: "100%"), or a Length token |
MinW MinH | min-width min-height | same. minH: "100vh" is how a page fills the viewport |
MaxW MaxH | max-width max-height | same. maxW: "34rem" is how a column stops growing on a wide screen |
…and the flex sizing trio:
| Prop | CSS | Value |
|---|---|---|
Grow | flex-grow | number — Grow = 1 makes a child fill the space its siblings leave |
Shrink | flex-shrink | number — Shrink = 1 lets a child shrink below a size you stated (see below) |
Basis | flex-basis | length / size token — a child's starting size before grow and shrink apply |
AspectRatio | aspect-ratio | a ratio ("16 / 9", "1") — reserves the box's shape before an image loads |
A size you state is a size you get
A definite size on the main axis holds. W on a child of a Row, or H on a child of a Stack, is not a
suggestion that the layout may overrule: a child written H = 2000 is 2000 tall, and a Row whose children total
more than its width overflows rather than squeezing them.
Stack(h: 150, overflowY: Overflow.Auto) {
Box(h: 2000) { Text("tall"); } // 2000 tall — so the Stack scrolls
}⚠ THIS IS A DELIBERATE DEVIATION FROM CSS, and the one place in the style vocabulary where a prop does not mean
exactly what its CSS twin means. In CSS flex-shrink: 1 is the initial value, so an explicit height is only a
starting point and a flex child shrinks past it to fit. That default is right for a language where you write
flex-basis and think in flex terms; it is wrong for one where you write h: 2000, because writing a number is
the statement that you want that number.
"Definite" means a plain length — 150, 12.5rem, 50vh. It does not include a percentage (which resolves
against the container, and is exactly where shrinking is the point), the content-driven keywords (auto,
fit-content, min-content, max-content), or anything computed or referenced (calc(…), min(…), clamp(…),
a size token) — those could hold any of the above, so they keep the CSS default.
To opt back in, say so: Shrink = 1 makes a child shrink again, and being an inline style it beats the rule,
so nothing is unreachable. Reach for it in the case it is meant for — a responsive toolbar whose items may compress.
Though the more usual answer there is not to state a width at all, and use Grow or MinW = 0 instead.
⚑ The cross axis is untouched. H on a child of a Row never shrank, and still does not — flex-shrink only
governs the main axis.
Styling text — font, weight, alignment, truncation#
| Prop | CSS | Value |
|---|---|---|
FontSize | font-size | length / size token |
FontWeight | font-weight | number (600) |
FontStyle | font-style | Normal · Italic |
LineHeight | line-height | number — unitless (1.4), so it scales with the font size |
LetterSpacing | letter-spacing | length (1 → 1px) or a string ("0.06em") |
FontFamily | font-family | a string ("Inter, system-ui, sans-serif") or a token |
TextAlign | text-align | Left · Center · Right · Justify · Start · End |
TextTransform | text-transform | None · Uppercase · Lowercase · Capitalize |
WhiteSpace | white-space | Normal · Nowrap · Pre · PreWrap · PreLine |
FontVariant | font-variant-numeric | Normal · TabularNums · SlashedZero · OldstyleNums |
TextDecoration | text-decoration | None · Underline · LineThrough · Overline |
TextOverflow | text-overflow | Clip · Ellipsis |
WordBreak | word-break | Normal · BreakAll · KeepAll · BreakWord |
VerticalAlign | vertical-align | Baseline · Top · Middle · Bottom · Sub · Super |
WhiteSpace = WhiteSpace.Nowrap keeps a tab, a table cell, or a button on one line (the row's own OverflowX handles the
excess). A tracked-out uppercase section label is TextTransform = TextTransform.Uppercase; LetterSpacing = "0.06em";; a numeric table
column right-aligns with TextAlign = TextAlign.Right;. FontVariant = FontVariant.TabularNums gives every digit the same width so a column
of numbers — a metric, a price, a count — lines up vertically instead of jittering as the digits change.
A truncated line is three props, not one: TextOverflow = TextOverflow.Ellipsis needs WhiteSpace = WhiteSpace.Nowrap and
Overflow = Overflow.Hidden beside it, or there is nothing to truncate. WordBreak = WordBreak.BreakWord is the other answer — for a
long URL or an unspaced identifier that would otherwise widen its container and push the whole layout sideways.
Pinning, layering, scrolling and transforms#
These are what let an app build a drawer, a sticky header, or a modal out of ordinary style props — the platform widens this vocabulary rather than shipping the component.
| prop | CSS | values |
|---|---|---|
Position | position | Static · Relative · Absolute · Fixed · Sticky |
Display | display | None · Block · Flex · Grid · InlineFlex · Contents |
Overflow | overflow | Visible · Hidden · Auto · Scroll |
OverflowX | overflow-x | as Overflow — a table scrolls horizontally inside its card while the page owns the vertical scroll |
OverflowY | overflow-y | as Overflow |
ScrollbarWidth | scrollbar-width | Auto · Thin · None — so a tab strip scrolls without a chunky gutter |
ScrollBehavior | scroll-behavior | Auto · Smooth — whether a programmatic scroll, from a bound scrollTop or a scrollIntoView, jumps or glides |
Inset | inset | length — all four offsets at once |
Top / Right / Bottom / Left | top / right / bottom / left | length |
Z | z-index | a ZIndex token |
Transition | transition | a motion token |
Motion is two props with a page of its own — animation — looping motion with no destination state is where the
| prop | CSS | values |
|---|---|---|
Animation | animation | names a declared animation block |
AnimationDelay | animation-delay | duration — offsets one element's copy of it, which is how N elements running one animation become a chase rather than a lockstep |
FieldSizing | field-sizing | Fixed · Content — an input that grows with what is typed into it |
Both are ordinary style props — usable inline or in a variants block — and animation — looping motion with no destination state is where the
declaration, the timing vocabulary and the staggering example live.
Transforms move, turn and resize a box without re-running layout, so they composite on the GPU and cost nothing
per frame. That is why a drawer slides with TranslateX rather than by animating Left.
| Prop | CSS | Value |
|---|---|---|
TranslateX | transform: translateX(…) | length or a string ("-100%") |
TranslateY | transform: translateY(…) | length or a string |
Rotate | rotate | an angle ("45deg") |
Scale | scale | a number (1.05) or a pair ("1.1 1") |
TransformOrigin | transform-origin | the point it turns/scales about ("center", "top left") |
TranslateX and TranslateY both write the single transform property, so the compiler merges them into one
declaration — setting both is transform: translateX(a) translateY(b), not one silently overwriting the other.
Rotate/Scale are CSS's own individual properties and compose on their own.
Cursor, click-through, selection, hiding#
What a box does to the pointer, the caret and the selection — the props that make a decorative overlay click-through, or a label un-selectable so a double-click selects the row instead of the word.
| Prop | CSS | Value |
|---|---|---|
Cursor | cursor | Auto · Default · Pointer · Text · Move · NotAllowed · Grab · Grabbing · Wait · Help · Crosshair · ColResize · RowResize |
PointerEvents | pointer-events | Auto · None — None makes a box invisible to the mouse; clicks pass through to whatever is beneath |
UserSelect | user-select | Auto · None · Text · All |
Resize | resize | None · Both · Horizontal · Vertical — a user-draggable textarea |
Visibility | visibility | Visible · Hidden · Collapse — hidden but still occupying its space (unlike Display = Display.None) |
ObjectFit | object-fit | Fill · Contain · Cover · None · ScaleDown — how an image fills its box |
AlignSelf | align-self | Auto · Start · Center · End · Stretch · Baseline — one child opting out of the row's alignment |
Order | order | number — reorders a flex/grid child visually without moving it in the DOM |
⚠ Order changes only the PAINTED order. Tab order and screen-reader order still follow the source, so a visual
order that disagrees with the document order is an accessibility bug, not a layout trick.
Lining columns up across rows — grid#
With Display = Display.Grid, these define a grid — the way to align columns across rows (a data table) without a shipped
Table component. Children flow into the tracks in order.
| Prop | CSS | Value |
|---|---|---|
Cols | grid-template-columns | a track list ("2fr 1fr 1fr", "repeat(4, 1fr)", "auto 1fr auto") |
Rows | grid-template-rows | a track list |
ColSpan | grid-column | how many columns a cell straddles ("span 2", "1 / -1") |
RowSpan | grid-row | how many rows a cell straddles |
GridArea | grid-area | a named area or an explicit span ("1 / 1 / 3 / 2") |
GridAutoFlow | grid-auto-flow | Row · Column — which way items that outrun the declared tracks flow |
GridAutoRows | grid-auto-rows | the size of a row the track list did not declare ("minmax(40px, auto)") |
GridAutoCols | grid-auto-columns | the same, for columns |
Wrap | flex-wrap | Wrapping.Wrap · Wrapping.Nowrap · Wrapping.WrapReverse — flex, not grid: whether a row breaks onto a second line. The vocabulary is Wrapping, so the prop and the value do not stutter |
A table header + rows all using the same Cols line up automatically; Gap (see spacing) sets the grid gap. The
GridAuto* props take over when the data outruns the declared tracks, which is the usual case for a list of unknown
length: declare Cols and let the rows generate themselves.
Styling hover, focus, disabled, and one screen width#
A nested Hover { … } / Focus { … } / Active { … } / Disabled { … } block styles that interaction state; a
nested breakpoint block (Cozy { … }) applies its props only at that width and up (mobile-first). Both nest inside
any variant value.
Styling one atom without minting a component#
The style props above are usually set in a component's variants recipe, but an atom can also carry them inline as
call arguments — the C#-natural way to style a one-off without minting a component for it:
Text("osyrin", fontSize: 17, fontWeight: FontWeight.Medium, color: Colors.TextPrimary)
Box(bg: Colors.Surface, p: 4, rounded: Radius.Card)
Icon(Icons.Chev, size: 18, color: Colors.TextMuted)Argument names are the camelCase of the prop (fontSize, bg, p, rounded); values are the same forms as in a
variant (a theme token, a number, a string, a keyword) — or a conditional that picks between them (below). A
hover or responsive style still belongs in variants (those are pseudo-states and breakpoints, not value choices).
⚠ Inline, a name value is written QUALIFIED — bg: Colors.Surface, not bg: Surface. An argument slot accepts
every kind of value, so a bare capitalised name there could be a theme token, an enum member or a style keyword, and
all three are spelled alike; the group name is what says which vocabulary you meant. Each prop's group is the one its
values come from — Colors for bg/color/border, Radius for rounded, Shadow for shadow, FontSize /
FontWeight / Font for the type props, and the keyword props take their own name (Display.Flex,
Position.Fixed, Overflow.Auto, Cursor.Pointer). A bare name is refused, and the refusal names the exact
spelling to write.
⚑ A variants block follows the same rule — Bg = Colors.Surface;, Rounded = Radius.Card;,
Display = Display.Flex;. There is no position where a value is written bare. The prop on the left does not
decide the vocabulary, which is the thing that makes the rule uniform rather than arbitrary: any group's token is
accepted for any prop, so Bg = Radius.Card; is a legal (if odd) thing to write and the group is genuinely
carrying information.
The one sentence is: a value names the vocabulary it comes from. Not "when it is ambiguous" — always, so you never have to work out which case you are in.
An inline value may be a conditional (cond ? A : B) whose branches are each an ordinary style value — so one
property can depend on one piece of component state without minting an enum + a variants dimension for it:
theme App {
Colors { Surface2 = "#eef1f5"; TextPrimary = "#111827"; TextMuted = "#6b7280"; }
Radius { Control = "8px"; }
}
[Composable] component Tab(string label, bool active, Action onClick) {
render {
Row(bg: active ? Colors.Surface2 : "transparent", color: active ? Colors.TextPrimary : Colors.TextMuted, rounded: Radius.Control) {
Text(label);
}
}
}The condition is a Boolean read from component state; the value re-evaluates and the element re-renders whenever that
state changes. Branches may mix a token and a string and may nest (bg: a ? (b ? "#fee" : Colors.Border) : Colors.Bg). For a whole
set of properties that changes together across several states, an enum-typed state variant is still the better
fit — a conditional value is for the common "one property, one condition" case.
Giving an element a NAME — not a style prop#
A style prop says how an element looks. Three other ambient vocabularies say what it is, what state it is in, and what it is called — and the last one is the one people miss, because the obvious-looking candidate is a style prop's neighbour in the same argument list:
Input(value: email, placeholder: "you@example.com") // ✗ has NO name
Text("Email", labelFor: field); Input(value: email, id: field) // ✓ named, and the words are clickableA placeholder: is not a name: it disappears the moment you type, is not announced as the field's name, and nothing
can address the field by it. The naming props (labelFor:/id:, labelledBy:, describedBy:, label:) are ambient
— legal on every element, so they appear in no atom's signature. osy kit --atoms lists them under Ambient;
accessibility is the full reference.
Styling a child when an ANCESTOR is hovered — inside#
A nested inside <Component>.<State> { … } block styles this element when it sits inside an ancestor component
that is in a given interaction state — the reveal-on-hover pattern (a row's actions, a card's menu, a tab's close):
theme App {
Motion { Fade = "opacity 0.12s ease"; }
}
[Composable] component RowActions() {
variants {
base {
Opacity = 0; Transition = Motion.Fade; // hidden by default…
inside DataRow.Hover { Opacity = 1; } // …shown when its DataRow is hovered
}
}
render { Row { Text("⋯"); } }
}
[Composable] component DataRow() {
render { Row { Slot; } }
}The child names the ancestor, so it stays reusable in any matching container and the container needs to know nothing
about it. <State> is one of the universal interaction states (Hover / Active / Focus) — a declared state
like "selected" is data the child can see, so it rides an ordinary state variant fed by a prop, not inside.
The block nests inside a value block (usually base), like a pseudo-state.
Examples#
A flat, hairline-bordered card with a hover lift — the whole look from the vocabulary, no shipped component:
theme App {
Colors { Surface = "#ffffff"; Border = "#e5e7eb"; }
Radius { Card = 12; }
}
[Composable] component Card() {
variants {
base {
Bg = Colors.Surface; BorderW = 1; Border = Colors.Border; Rounded = Radius.Card; P = 4;
Hover { BorderW = 1; Shadow = "0 1px 3px rgba(0,0,0,0.08)"; }
}
}
render { Stack(gap: 2) { Slot; } }
}A list row with a bottom divider, the last row suppressing it via a passed-in state:
theme App {
Colors { Border = "#e5e7eb"; }
}
enum RowEdge { Divided, Last }
[Composable] component ListRow(RowEdge edge) {
variants {
base { Px = 3; Py = 2; }
edge {
Divided { BorderBW = 1; Border = Colors.Border; }
Last { }
}
}
render { Row(justify: Justify.SpaceBetween) { Slot; } }
}See also#
- theme tokens — the design tokens a style prop's value references by name
- layout primitives —
Stack/Row/Boxand thealign/justify/gaplayout arguments - component — where a
variantsblock lives and how a component is authored - accessibility — the other ambient argument vocabularies: what an element IS, what STATE it is in, and what it is CALLED