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

Reference / UI

theme tokens

theme { Colors { … } Radius { … } } — named design tokens, and references between them

A `theme` block names your app's design tokens — colors, spacing, radii, and more — as reusable values. A token can hold a literal value (`Primary = "#0077B6"`) or reference another token by name (`Grid = Border`), so shared values stay defined in one place.

stable13 examples compiled by CIuithemetokens

Summary#

A theme block declares your app's design tokens — the named values (colors, spacing steps, radii, …) your UI is built from. Grouping them in a theme means a value like your brand color is defined once and reused everywhere, so restyling the app is a change in one place.

osy kit --tokens prints the tokens you already have — the starter theme the Osyrin.Ui (the UI kit) ships, by group and with each value — which is where to look before inventing a name.

theme Default {
  Colors {
    Primary = "#0077B6";
    OnPrimary = "#FFFFFF";
    Border = "#E3E6EA";
  }
  Radius {
    Md = 10;
    Lg = 16;
  }
}

Tokens are organized into groups. Each leaf assignment (Primary = "#0077B6") is one token; its path through the groups (ColorsPrimary) is what makes its name unique, so two groups can both have a Md without colliding.

The top-level groups are a closed set — the token kinds the editor understands, so Bg = ‹caret› can offer your colors, FontSize = ‹caret› your sizes, and so on:

Colors · Space · Radius · Font · FontSize · FontWeight · Shadow · Motion · ZIndex · Breakpoints · Series · Length · Density · Touch

The font kinds are separate flat groups (Font for families, FontSize, FontWeight) rather than one mixed Type { … }, so each token's kind is unambiguous. An unrecognized group name is a compile error with a did-you-mean — a theme group that isn't one of these is a typo, not a silent no-op.

Signature#

theme { Colors { … } Radius { … } } — named design tokens, and references between them

Description#

Where a theme goes — and why there is nothing to wire#

Declaring it is the whole step. Put the theme block in any file the manifest's model glob already covers — model/theme.osy by convention — and every control and page in the app is styled by it. There is no registration call, no Theme = on the app, nothing to import: a theme is part of the model, like an entity.

The name is only a name. theme Default and theme Anything behave identically; light and dark are a property of each TOKEN (Modes.Of(light: …, dark: …) — see [[ui-theming#modes]]), not of the block.

(Said here because its absence is what a reader goes looking for. Every other framework has a provider, a plugin or a config key, so "I have written the tokens — now how do I attach them?" is the next question, and a page that never answers it reads as incomplete. One run spent a call grepping this page for app.osy, Theme =, UseTheme and attach, and found nothing, because there is nothing.)

The groups a theme may declare#

A theme's top-level groups are a closed set — anything else is a compile error with a did-you-mean. Each group feeds a family of style props, which is why the grouping exists rather than one flat list of names.

GroupHoldsFeeds
Colorscolours and palettesbg:, color:, border:
Spacethe few spacings that are a decision, not a scale stepp:, m:, gap:
Radiuscorner radii — owns the Sm/Md/Lg triplerounded:
Fontfont stacksfont:
FontSizetype sizes, named for the ROLE the text playsfontSize:
FontWeighttype weightsfontWeight:
Shadowelevation, named for the elevation not the controlshadow:
Motionwhole transitions, not bare durationstransition:
ZIndexlayering policyz:
Breakpointsyour own width names — Layout.AtLeast(Tablet) reads theseresponsive props
Lengthwidths, heights, control and row heightsw:, h:, minW:, maxW:, basis:
Serieschart series coloursplotting controls

Length is the sizing group. Size, Density and Touch are accepted as named sub-scales of the same kind, so an existing theme keeps compiling — but the kit spends Size on the control-size enum (size: Size.Lg), and one spelling standing for two different things is the reason Length is what every kit example uses.

Giving a token a plain value — a color or a number#

A token most often holds a literal value — a color string or a number:

theme Default {
  Colors   { Primary = "#0077B6"; }
  Radius   { Md = 10; }
  FontSize { Body = "13px"; Heading = "22px"; }
}

Each literal token becomes a reusable style value your components draw from. Changing the literal changes every place that uses the token.

Can one token reuse another's value?#

A token can reference another token by name instead of repeating a value:

theme Default {
  Colors {
    Border = "#E3E6EA";
    Grid   = Colors.Border;      // Grid resolves to whatever Border is
  }
}

Grid = Border keeps Grid pointing at Border as a living reference, not a copy — if you later change Border, Grid follows automatically, with no need to update it or rebuild. A reference names a token by its plain leaf name; a token in the same group is preferred, so the Border above binds to the Border in Colors.

Referencing a token that doesn't exist is a compile error — a typo like Grid = Bordr is caught, not silently ignored (the same way a mistyped color step like Primary.Hund is).

One seed color, a whole ramp — Palette.From#

A color token can be a whole palette instead of a single value — Primary = Palette.From("#0077B6") generates an even ramp of shades from one seed, and Primary.Hover / Primary[600] reach its steps. See color palettes for the full story.

theme Brand {
  Colors {
    Primary = Palette.From("#0077B6");
    Line    = Primary[200];        // a light step from the ramp
  }
}

Why is my token name a collision? — one leaf, one value#

A reference names a token by its group and name (Rounded = Radius.Card) — a bare leaf does not say which vocabulary it means, since a theme token, an enum member and a style keyword are all spelled alike in that position. The leaf still has to denote exactly one value across your whole app, because the token map is keyed by it: declaring the same name under two different groups is a compile error:

theme Admin {
  Space  { Md = "12px"; }
  Radius { Md = "8px"; }     // error: duplicate token 'Md' declared in groups 'Space' and 'Radius'
}

Name tokens by their role and the question doesn't arise — Radius { Control; Card; Pill; } reads better at the call site (Rounded = Card) than a second Sm/Md/Lg scale would, and it can only mean one thing.

Re-declaring a name under the same group is not a collision — that is how you override a token a UI kit shipped, and both resolve to the same value slot.

Your value wins. A token you declare shadows a same-named one from the kit, exactly as your own component shadows a kit component of the same name. So a theme of your own needs to restate only what you are changing:

⚑ That holds across GROUPS too, and it is why declaring FontWeight { Normal = …; } is not an error even though the kit ships a Motion.Normal: leaf names are one flat namespace, so your declaration takes the leaf and the kit's becomes unreachable in your app. A use site that names the kit's group is told so — "'Normal' is a token in the FontWeight group, not Motion" — rather than the whole theme being refused.

theme Brand {
  Colors { Primary = "#0077B6"; }   // shadows the kit's Primary
}
// every other kit token — Surface, Border, Danger — still applies

Shadowing is per token, not per theme: the kit's other tokens keep applying, so you never have to copy a kit's whole palette to change one colour of it.

Does a length token need px? — units#

A token that holds a length carries its own unit, as a string:

theme Admin {
  Radius { Card = "12px"; }      // ✓  border-radius: 12px
  Space  { Md = "12px"; }        // ✓  gap: 12px
}

A bare number is emitted unitless, which is correct for ZIndex, FontWeight and Breakpoints — and wrong for anything the browser needs a unit for. Style props are the other way round: there a bare number takes the prop's unit (Px = 41rem), because the prop already knows what it is.

Dark mode — per-mode values#

A token can hold different values per mode — most commonly light and dark — by giving it a mode map:

theme Default {
  Colors {
    Surface = Modes.Of(light: "#FFFFFF", dark: "#111111");
    OnSurface = Modes.Of(light: "#111111", dark: "#F5F5F5");
  }
}

The Light value is the default. Dark applies automatically when the visitor's device prefers a dark color scheme — the correct colors are there on the very first paint, with no flash. You can also force a mode explicitly (for a theme toggle) by setting data-theme="dark" on the page, which wins over the device preference. A visitor's explicit choice is remembered across visits and applied on the first paint of their next visit — still with no flash — so a returning user always lands in the mode they picked.

A mode value is a normal token value — a literal (as above) or a reference to another token — so everything from the sections above applies inside a mode map too. Modes are open-ended: Light and Dark are the common pair, but you can define others and select them with data-theme.

Making the app dark-only (or light-only) — Mode#

The device preference is the right default for an app that offers both looks. An app that has one look says so, with a Mode setting at the top of its theme:

theme Midnight {
  Mode = Dark;
  Colors {
    Surface = Modes.Of(light: "#FFFFFF", dark: "#111111");
    Accent  = "#FF2D95";
  }
}

Now every token resolves its Dark value by default, on any device, on the first paint. Mode = Light; does the same in the other direction — worth writing when your app is deliberately light, because without it a visitor whose phone prefers dark gets the dark column of every mode map you wrote.

It pins the default, not the choice: data-theme still wins, so a toggle keeps working in an app that declares a Mode. Leave Mode out to follow the device, which is what every theme does by default.

This matters most for tokens you did not write. The UI kit's controls paint with the kit's own Surface/OnSurface/Border tokens — and those carry mode maps. So an app whose own palette is dark, but which never says Mode = Dark;, gets kit buttons and cards in their light colors on a light-mode browser: a white button on a near-black page, from source that reads perfectly.

Switching mode — a toggle#

Two verbs switch the mode at runtime and remember the choice:

  • Theme.Toggle() — flip between light and dark.
  • Theme.Set(mode) — apply a named mode, e.g. Theme.Set("dark") (use this when your theme has more than the light/dark pair).

Both persist the choice, so it survives navigation and the visitor's next visit (applied on the first paint, no flash). Call them from an action:

component ModeButton() {
  action Flip() { Theme.Toggle(); }
  render { Pressable(onClick: Flip) { Text("🌓"); } }
}

You don't have to write your own — the UI kit ships a ready one. Drop ThemeToggle() into any page (pass label to change its face, or declare your own same-named ThemeToggle to fully restyle it):

[Page("/")]
[AllowAnonymous]
component Home() {
  render {
    Row { Text("My app"); ThemeToggle(); }
  }
}

The platform ships the switching mechanism and the kit control, but injects no toggle of its own — where the button lives is your layout's decision, not the platform's.

Styling a component — variants#

A component styles itself with a variants recipe. base is what it always looks like; each dimension names one of the component's parameters, and adds the styling that applies for its value.

theme Kit {
  Colors { Surface = "#FFFFFF"; OnSurface = "#111111"; Primary = "#0077B6"; OnPrimary = "#FFFFFF"; Danger = "#C1121F"; }
  Radius { Md = "10px"; }
}

enum Tone { Neutral, Primary, Danger }
enum Size { Sm, Md, Lg }

component Button(Tone tone, Size size) {
  variants {
    base { Bg = Colors.Surface; Color = Colors.OnSurface; Rounded = Radius.Md; Px = 4; Py = 2; }
    tone { Primary { Bg = Colors.Primary; Color = Colors.OnPrimary; } Danger { Bg = Colors.Danger; } }
    size { Sm { Px = 3; Py = 1; } Lg { Px = 5; Py = 3; } }
  }
  render { Text("Save"); }
}

An enum parameter lists a block per member, as above. A bool parameter has only one thing to say, so it says it directly — these are the styles that apply when it is true:

component Rail(bool collapsed, bool drawerOpen) {
  variants {
    base { W = "264px"; }
    collapsed { W = "64px"; }        // when `collapsed` is true
    drawerOpen { TranslateX = "0"; } // when `drawerOpen` is true
  }
  render { Text("Rail"); }
}

There is no false block, because not applying the styles is exactly what false means. Writing the enum shape on a bool (collapsed { True { … } }) is a compile error that shows you the spelling above.

Reach for a bool before minting an enum to carry one. A two-member enum RailMode { Open, Collapsed } says no more than bool collapsed does, and it costs you at the call site: the component ends up taking the enum and a bool for the same fact, because a variant's enum value is not something the render body can read back as a condition.

The property names are a closed vocabulary — a typo is a compile error with a suggestion, not a declaration that silently does nothing:

GroupProps
PaintBg Color Border Rounded Shadow Opacity
SpacingP Px Py Pt Pr Pb Pl · M Mx My Mt Mr Mb Ml · Gap
SizeW H MinW MinH MaxW MaxH Grow
TypeFontSize FontWeight
PlacementPosition Display Overflow Inset Top Right Bottom Left Z
MotionTransition TranslateX TranslateY

Values are tokens, numbers, or keywords. A bare name is a token reference (Bg = Surface) — which is what lets one theme restyle everything. A number takes the prop's unit: spacing props use the spacing scale (Px = 41rem), size props are pixels (W = 280280px).

Position, Display and Overflow take a keyword from a fixed set, because there is nothing a theme could usefully say about position: fixed. A keyword is written qualified, with the prop's own name as the group — Position = Position.Fixed; — because a bare Fixed could equally be a token you declared, and the two are spelled alike. (A token reference stays bare, as above: the prop already decides that vocabulary.)

PropAccepts
PositionPosition.Static Position.Relative Position.Absolute Position.Fixed Position.Sticky
DisplayDisplay.None Display.Block Display.Flex Display.Grid Display.InlineFlex Display.Contents
OverflowOverflow.Visible Overflow.Hidden Overflow.Auto Overflow.Scroll

Responsive — one design, every width#

Declare your breakpoints as tokens, then override any style prop at any of them:

theme Admin {
  Breakpoints { Compact = 640; Cozy = 960; }
}

enum RailState { Closed, Open }

component Sidebar(RailState rail) {
  variants {
    base {
      Position = Position.Fixed; TranslateX = "-100%";     // a phone: an off-screen drawer
      Cozy { Position = Position.Static; TranslateX = "0"; }   // ≥ 960px: a docked rail
    }
    rail { Open { TranslateX = "0"; } }
  }
  render { Text("Sidebar"); }
}

Styling is mobile-first. base is unconditional — it is what the narrowest screen gets — and each breakpoint adds styling on top of it as the screen grows. Write it this way round and a phone never pays to undo a desktop layout it was never going to use.

A breakpoint block may contain any style props, and may nest a pseudo-state (Cozy { Hover { … } }). Naming a breakpoint you didn't declare is a compile error, with a suggestion.

Which width? For a component, the browser window. A component decides placement — is this a sidebar or a drawer — and placement is a property of the page, not of the box the component happens to sit in. A foreign control is the opposite: it reflows its own interior against the size of its own mount element, so the same grid becomes cards whether it is narrow because the phone is narrow or because you put it in a narrow panel. Your breakpoint tokens are visible to controls as CSS variables, so both use the same numbers.

There is no drawer or modal — building chrome from tokens#

The platform ships no drawer, no modal, no sticky header. It ships the vocabulary, so you build the one you want — and the theme still owns the policy: which layer things stack on, how fast they move.

theme Admin {
  ZIndex { Overlay = 20; }
  Motion { Slide = "transform 0.18s ease"; }
}

enum RailState { Closed, Open }

component Sidebar(RailState rail) {
  variants {
    base { Position = Position.Fixed; Inset = 0; Right = 0; W = 280; Overflow = Overflow.Auto;
           Z = ZIndex.Overlay; Transition = Motion.Slide; TranslateX = "-100%"; }
    rail { Open { TranslateX = "0"; } }
  }
  render { Text("Sidebar"); }
}

Z = Overlay and Transition = Slide are ordinary token references, so layering and timing stay consistent across every piece of chrome in the app — the same reason colors do.

See also#

  • color palettes — turn one seed color into a full ramp with Palette.From and its named/numbered steps.
  • layout primitives — the layout primitives (gap/align/justify) you arrange components with.
  • component — declaring a component and its render block.

Related

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`…