Osy#the first language built for agents
Agents firstAgentic appsWorkflowsDurable Execution — built inSecurityTestingThe editorThe UI modelOne program

Reference / UI

style props

variants { base { Bg = Surface; P = 4; BorderW = 1; Border = Border; } } — the closed set of visual props a variant assigns

Inside a `variants` block, each `Name = value` is a style prop from a fixed vocabulary the renderer maps to CSS — paint (`Bg`, `Color`, `Border`), borders (`BorderW`, `BorderStyle`), spacing (`P`, `Gap`), size (`W`, `Grow`), and more. A value is a number, a keyword, or a theme token by name. NOT on this page: `align`, `justify` and `gap` are LAYOUT ARGUMENTS passed to `Stack`/`Row`/`Box`, not style props — `osy docs ui-layout` has all three.

stable4 examples compiled by CIuistylingvariants

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 --tokens prints every token the starter theme ships — by group, with its value. (osy model --json reports the tokens your own theme blocks 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.

PropCSSValue
Bgbackground-colorcolor token
Colorcolorcolor token
Borderborder-colorcolor token
Roundedborder-radiuslength / radius token
Shadowbox-shadowCSS value, or a shadow token — haloes the element's rectangle
TextShadowtext-shadowCSS value — a shadow on the glyphs. A comma-separated list stacks, which is how a glow is built
Opacityopacitynumber
CaretColorcaret-colorcolor token — the text cursor in an input
AccentColoraccent-colorcolor token — a native checkbox/radio/range, themed without rebuilding it
BgImagebackground-imageCSS value — a gradient or url(…). A theme token reaches it as "linear-gradient(…, var(--colors-accent), …)": every token is a custom property
BgSizebackground-sizeAuto · Cover · Contain
BgPositionbackground-positionCSS value ("center", "50% 20%")
FilterfilterCSS value ("grayscale(1)", "blur(2px)")
BackdropFilterbackdrop-filterCSS value — blurs what is behind the box (a frosted modal scrim)
MixBlendModemix-blend-modeNormal · 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.

PropCSSValue
BorderWborder-widthlength (e.g. 11px)
BorderTWborder-top-widthlength
BorderRWborder-right-widthlength
BorderBWborder-bottom-widthlength
BorderLWborder-left-widthlength
BorderStyleborder-styleSolid · 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.

PropCSSValue
OutlineWoutline-widthlength (22px)
OutlineColoroutline-colorcolor token
OutlineStyleoutline-styleSolid · Dashed · Dotted · None
OutlineOffsetoutline-offsetlength — 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.
PropCSSValue
P Px Py Pt Pr Pb Plpaddinga 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 Mlmarginsame scale; mx: "auto" is how a block centres
Gapgapthe flex/grid gap, same 0.25rem scale, or a Space token

How big is it — W/H, min/max, and flex grow#

PropCSSValue
W Hwidth heighta numberpx, a string → as-is (w: "100%"), or a Length token
MinW MinHmin-width min-heightsame. minH: "100vh" is how a page fills the viewport
MaxW MaxHmax-width max-heightsame. maxW: "34rem" is how a column stops growing on a wide screen

…and the flex sizing trio:

PropCSSValue
Growflex-grownumber — Grow = 1 makes a child fill the space its siblings leave
Shrinkflex-shrinknumber — Shrink = 1 lets a child shrink below a size you stated (see below)
Basisflex-basislength / size token — a child's starting size before grow and shrink apply
AspectRatioaspect-ratioa 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 length150, 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#

PropCSSValue
FontSizefont-sizelength / size token
FontWeightfont-weightnumber (600)
FontStylefont-styleNormal · Italic
LineHeightline-heightnumber — unitless (1.4), so it scales with the font size
LetterSpacingletter-spacinglength (11px) or a string ("0.06em")
FontFamilyfont-familya string ("Inter, system-ui, sans-serif") or a token
TextAligntext-alignLeft · Center · Right · Justify · Start · End
TextTransformtext-transformNone · Uppercase · Lowercase · Capitalize
WhiteSpacewhite-spaceNormal · Nowrap · Pre · PreWrap · PreLine
FontVariantfont-variant-numericNormal · TabularNums · SlashedZero · OldstyleNums
TextDecorationtext-decorationNone · Underline · LineThrough · Overline
TextOverflowtext-overflowClip · Ellipsis
WordBreakword-breakNormal · BreakAll · KeepAll · BreakWord
VerticalAlignvertical-alignBaseline · 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.

propCSSvalues
PositionpositionStatic · Relative · Absolute · Fixed · Sticky
DisplaydisplayNone · Block · Flex · Grid · InlineFlex · Contents
OverflowoverflowVisible · Hidden · Auto · Scroll
OverflowXoverflow-xas Overflow — a table scrolls horizontally inside its card while the page owns the vertical scroll
OverflowYoverflow-yas Overflow
ScrollbarWidthscrollbar-widthAuto · Thin · None — so a tab strip scrolls without a chunky gutter
ScrollBehaviorscroll-behaviorAuto · Smooth — whether a programmatic scroll, from a bound scrollTop or a scrollIntoView, jumps or glides
Insetinsetlength — all four offsets at once
Top / Right / Bottom / Lefttop / right / bottom / leftlength
Zz-indexa ZIndex token
Transitiontransitiona motion token

Motion is two props with a page of its own — animation — looping motion with no destination state is where the

propCSSvalues
Animationanimationnames a declared animation block
AnimationDelayanimation-delayduration — offsets one element's copy of it, which is how N elements running one animation become a chase rather than a lockstep
FieldSizingfield-sizingFixed · 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.

PropCSSValue
TranslateXtransform: translateX(…)length or a string ("-100%")
TranslateYtransform: translateY(…)length or a string
Rotaterotatean angle ("45deg")
Scalescalea number (1.05) or a pair ("1.1 1")
TransformOrigintransform-originthe 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.

PropCSSValue
CursorcursorAuto · Default · Pointer · Text · Move · NotAllowed · Grab · Grabbing · Wait · Help · Crosshair · ColResize · RowResize
PointerEventspointer-eventsAuto · NoneNone makes a box invisible to the mouse; clicks pass through to whatever is beneath
UserSelectuser-selectAuto · None · Text · All
ResizeresizeNone · Both · Horizontal · Vertical — a user-draggable textarea
VisibilityvisibilityVisible · Hidden · Collapse — hidden but still occupying its space (unlike Display = Display.None)
ObjectFitobject-fitFill · Contain · Cover · None · ScaleDown — how an image fills its box
AlignSelfalign-selfAuto · Start · Center · End · Stretch · Baseline — one child opting out of the row's alignment
Orderordernumber — 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.

PropCSSValue
Colsgrid-template-columnsa track list ("2fr 1fr 1fr", "repeat(4, 1fr)", "auto 1fr auto")
Rowsgrid-template-rowsa track list
ColSpangrid-columnhow many columns a cell straddles ("span 2", "1 / -1")
RowSpangrid-rowhow many rows a cell straddles
GridAreagrid-areaa named area or an explicit span ("1 / 1 / 3 / 2")
GridAutoFlowgrid-auto-flowRow · Column — which way items that outrun the declared tracks flow
GridAutoRowsgrid-auto-rowsthe size of a row the track list did not declare ("minmax(40px, auto)")
GridAutoColsgrid-auto-columnsthe same, for columns
Wrapflex-wrapWrapping.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 ruleBg = 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 clickable

A 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 primitivesStack/Row/Box and the align/justify/gap layout arguments
  • component — where a variants block 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

Related

theme tokens

A `theme` block names your app's design tokens — colors, spacing, radii, and more — as reusable values. A token can…

layout primitives

The built-in layout primitives and how they arrange children. `Stack` stacks children in a column, `Row` lays them in a…

component

The one archetype for all UI: a bounded reactive unit — typed props, reactive members (fields, `live`…

animation — looping motion with no destination state

An `animation` block declares reusable, looping motion — a shimmer, a pulse, an indeterminate progress hint. Its…

accessibility

Tags already give an element its role, focus and keyboard behaviour. The semantic props say the rest: `role:` for a…