Skip to main content
View as Markdown

Component Styles

design.components restyles every instance of an engine component type — parts, variants and states — under a focus-ring floor that outranks both your classes and the shipped recipe.

props.className restyles one component. design.components restyles every instance of a type.

The difference matters more than it sounds. Before this key, an app that wanted square corners on every button repeated the same class list at every call site — and the failure mode is not an error message. It is the one site somebody missed, found later by a customer.

app.yaml
design:
  components:
    button:
      parts:
        root: rounded-none tracking-tight

Every button in the app, in one line.

Where it sits in the cascade

Four layers, applied in this order — later wins a same-property conflict:

  1. Sovrium's recipe — the shipped default for the type.
  2. design.components — this key. Your app-wide answer.
  3. props.className — the one instance. Beats your own app-wide rule, which is what makes a one-off override possible without an escape hatch.
  4. The floor — a small, non-negotiable set, applied last.

Keyed by component type, not by your component names

The keys are engine component types — the ones Sovrium itself draws: button, table, dialog, input, and every other type the kit ships. Your own reusable templates under the top-level components[] key are not styled from here; you already control those end to end, and what they carry instead is guidance.

The key set is closed. A typo — buton: where you meant button: — is refused at boot, naming the key. An open map would accept it, tell you the config is valid, and serve an app that ignored every class you wrote under it, with nothing anywhere saying so.

Two types are excluded, each for the same underlying reason — there is no Sovrium-owned element to attach a class to. customHTML renders markup you supply, and command-palette emits no visible markup on the server: a config block and its runtime, with the overlay built in the browser on the first invocation.

What a type entry holds

Path Kind Values Default Description
parts object Classes applied to each named part of the component
variants object Per-part classes that apply only in the named variant
replace boolean Drop Sovrium's recipe for this type rather than layering on top of it. The accessibility floor still applies.

states

Path Kind Values Default Description
states object Per-part classes that apply in one interaction state, written without the state prefix
states.hover object Classes applied while the pointer is over the component.
states.focus object Classes applied while the component holds keyboard focus, however it was reached.
states.focusVisible object Classes applied while the component holds keyboard focus and the browser judges a focus ring warranted — typically after keyboard navigation, not after a click.
states.active object Classes applied while the component is being pressed.
states.disabled object Classes applied while the component refuses interaction.
states.open object Classes applied while the component is expanded, such as an open menu or dialog trigger.
states.selected object Classes applied while the component is the chosen one among its siblings.
states.checked object Classes applied while a checkbox, radio or switch is on.
states.invalid object Classes applied while the value the component holds has been refused.

All four are optional, and every leaf is a Tailwind class-list string. Within one part they layer in declaration order — parts, then the active variants entry, then any matching states entry — so a state class is later than a variant class and wins a conflict between them.

parts

Every component has a root. A composite one names its inner elements too: a table has a header, a row, a cell.

app.yaml
design:
  components:
    table:
      parts:
        root: border-2
        header: uppercase tracking-wide
        cell: font-mono

Which parts a type has is Sovrium's to define — a part is an element the renderer owns — and a name that is not one of them styles nothing.

Unlike a type key, a part name is not refused. Part and variant names are an open set: headr: decodes cleanly, boots cleanly, and paints nothing. The type key can be closed because Sovrium knows every type; a part name cannot, because the parts a type owns are an implementation detail that moves with the renderer. If a block seems to do nothing, a mistyped part is the first thing to check — and the design-system console names, per part, which layer each rendered class came from.

variants

The variant vocabulary is per type: a button has seven, a divider has none.

app.yaml
design:
  components:
    button:
      variants:
        destructive: { root: border-2 }
        ghost: { root: underline-offset-4 }

states

Write these without the state prefix. Sovrium adds it.

app.yaml
design:
  components:
    button:
      states:
        hover: { root: bg-neutral-800 }
        disabled: { root: opacity-40 }

Writing hover: { root: 'hover:bg-neutral-800' } is refused, for a concrete reason rather than a stylistic one: it would emit hover:hover:bg-neutral-800, which generates no CSS at all. You would have a valid config and no hover style. Other prefixes stay legal and are combined with the state — md:bg-neutral-800 under hover becomes hover:md:bg-neutral-800.

focus and focusVisible are both in the set rather than collapsed into one, because they are different: focus fires when a script or a mouse moves focus; focusVisible only on the keyboard path.

A state Sovrium computes rather than the browserloading, pressed — is not expressible here. Those have no CSS state to attach to; the renderer decides them. Mixing the two would mean half your entries applying through the stylesheet and half through code, with no way to tell from the config which kind you had written.

replace

By default your classes are merged over the recipe: p-8 beats the recipe's p-4, and everything you did not mention is inherited. replace: true drops the recipe instead — the escape hatch for a default that is wrong in kind rather than in degree. It does not drop the accessibility floor.

Every leaf is validated at boot. What it refuses is narrow and deliberate: inside an arbitrary value, the constructs that would let a class list reach out of the stylesheet — url(, image-set(, attr(, expression( and @import.

Arbitrary values themselves stay legal (bg-[oklch(0.7_0.1_250)], text-(length:--sv-density-text)), and so does an empty string. The rule is not "no arbitrary values"; it is "a class list styles, it does not fetch". An image belongs in imagery, not smuggled into a background.

What you cannot override

One thing here is a floor — classes applied after yours, so the guarantee holds whatever you write. The rest are not classes at all, and could not be changed from a class list in the first place.

The focus ring. A button, an input and a textarea keep a visible focus-visible ring; a link keeps its focus-visible underline. ring-0 is a legal class list and the single most damaging one you can ship — a keyboard user loses track of where they are — so it is accepted, applied, and then overruled. Three qualifications, each worth knowing before you rely on it:

  • It covers those four types, and only the root part. Nothing else carries a floor.
  • It is armed by your declaration. An app that declares no entry for a type has no floor on it either. The floor guards against your override; where there is no override there is nothing to guard.
  • props.className still wins today. The floor outranks design.components. A focus-visible:ring-0 written on a single instance is a known gap, not a guarantee working as intended.

Beyond the floor: aria-* attributes and roles are not styling, and a class list is not the place to change what a component is; the names of the role tokens cannot move, because every recipe reads them (retuning a role's value is what colorRoles is for); the parts a type is made of belong to the renderer; and the Sovrium element mark is a trademark.

Seeing which layer won. GET /api/admin/design-system/provenance?type=button&part=root returns the resolved class list with the chain that produced it — one entry per contributing layer, with the floor entry marked locked and carrying the reason. A layer that contributed nothing is omitted, so the chain's length is itself readable.

Ramps and role values

A palette is not a bag of hex values. It is usually a small number of ramps — ordered lightness ladders — plus a set of roles pointing into them.

Path Kind Values Default Description
50 string A colour literal, or a reference to another ramp step
100 string A colour literal, or a reference to another ramp step
200 string A colour literal, or a reference to another ramp step
300 string A colour literal, or a reference to another ramp step
400 string A colour literal, or a reference to another ramp step
500 string A colour literal, or a reference to another ramp step
600 string A colour literal, or a reference to another ramp step
700 string A colour literal, or a reference to another ramp step
800 string A colour literal, or a reference to another ramp step
900 string A colour literal, or a reference to another ramp step
950 string A colour literal, or a reference to another ramp step

Declare only the steps you use. A ramp step may reference another ramp's step by name.

Path Kind Values Default Description
usage string What this colour is for, and what it must not be used for
pairsWith string Name of the token this colour is designed to sit against (usually its foreground companion). May name a platform role token the app never redeclared.
value string The ramp step this role follows, or a colour literal
dark string The ramp step this role follows, or a colour literal

A role's value says what it resolves to; dark says what it becomes under the dark scheme, and omitting it means "the same in both". A role that declares a value defines the token, so it does not need to already exist in design.colors; a role that only carries prose still documents a token declared there.

app.yaml
design:
  ramps:
    neutral:
      '50': 'oklch(0.985 0 0)'
      '500': 'oklch(0.56 0 0)'
      '950': 'oklch(0.14 0 0)'
    info:
      '50': neutral-50
      '500': neutral-500
  colorRoles:
    background:
      value: neutral-50
      dark: neutral-950
      usage: The page ground. Never a fill on a control.

A reference that resolves to nothing is refused at boot, listing the ramps that do exist — an unresolved one would reach the browser as a variable nothing defines, and the surface would paint its initial value in silence.

oklch() yes, var() no. Colour values accept hex, rgb(), hsl() and oklch(). Sovrium's own ramps are written in oklch, the only widely supported space in which a ramp can be retuned by lightness without the hue drifting underneath. var() and color-mix() are refused because they are references, not values: they resolve against the browser's cascade, so nothing reading your config can know what colour they name — not the contrast checker, not the token export, not an agent reading your design system. When you want a token to follow another, say so with a reference the schema understands.

Behaviour

Author Class Overrides, Class-List Validation and Density

  • A declared className beats the recipe for the same CSS property, and the recipe still applies otherwise
  • A className carrying url( inside an arbitrary value is refused at boot, naming the token
  • A design.density ladder boots and serves; a byZone key naming no declared zone is refused
  • An author overrides a recipe class, declares a density, and is refused for both otherwise-silent mistakes

Per-Engine-Type Component Styling, Colour Ramps and Numeric Scales

  • An empty design renders exactly the body that no design renders, and a declared style renders a different one
  • A declared class reaches the component in the SSR markup and survives the island's hydration
  • The accessibility floor beats an operator's focus-visible:ring-0, while their other classes still apply
  • An unknown component type and a doubled state prefix are refused at boot, naming the offender
  • replace: true drops the recipe surface; omitting it layers over the recipe
  • oklch() reaches the stylesheet; a var() colour is refused with a message naming ADR-032
  • Ramps, role values and the numeric ladders boot; an unresolved ramp reference is refused, listing the declared ramps
  • The accessibility floor covers a focusable part that is not the component root, while the operator's other part classes still apply
  • replace: true drops the recipe from a non-root part without dropping that part's focus ring
  • An operator restyles every button, cannot remove the focus ring, and is named for every otherwise-silent mistake

Data scenes render consistently against v1

  • populated-data-table scene renders v1-styled paginated table with 10 rows + status pills + priority pills + pagination indicator in light + dark
  • calendar-with-events scene renders v1-styled 7-day week grid with 14 events distributed across day columns + kind-tinted event chips in light + dark
  • kanban-board scene renders v1-styled 3-column board (To do · In progress · Done) with 5 cards per column + status pill footers in light + dark
  • dashboard scene renders v1-styled 4-KPI tile row + 12-month chart (utility-class height ladder) + recent-activity feed in light + dark
  • gallery-grid scene renders v1-styled 4-column product grid (12 tiles) with theme-aware tone placeholders + title + price + ID footer in light + dark
  • timeline-with-events scene renders v1-styled 8-entry vertical timeline with day + time + kind glyph + duration in light + dark
  • User can navigate to each /scenes/data/{name} page and see the canonical v1 rendering in both light and dark modes

Forms scenes render consistently against v1

  • Contact-form scene renders v1-styled name input, email input, subject select, message textarea, and primary/secondary action row in light + dark
  • Settings-form scene renders v1-styled profile, preferences, and notifications sections separated by dividers, with grouped fields and checkbox rows in light + dark
  • Multi-step-form scene renders v1-styled 3-step indicator strip (complete + current + upcoming states) and an active-step form body with back/skip/continue actions in light + dark
  • Search-filter-form scene renders v1-styled search input with ⌘K hint, status filter pills (active + inactive), sort selector, and active-filter summary row in light + dark
  • User can navigate to each /scenes/forms/{name} page and see the canonical v1 rendering in both light and dark modes

Layout scenes render consistently against v1

  • auth-layout scene renders v1-styled centered card with branding + email/password fields + submit + forgot-password link in light + dark
  • empty-state scene renders v1-styled dashed-border surface with icon placeholder + heading + body + CTA in light + dark
  • error-page scene renders v1-styled 404 layout with text-error-solid status code + heading + body + primary/secondary actions in light + dark
  • app-shell scene renders v1-styled master composition (sidebar with Dashboard active + header bar + main content with 2x2 KPI grid) in light + dark
  • User can navigate to each /scenes/layouts/{name} page and see the canonical v1 rendering in both light and dark modes
  • sidebar-app scene renders v1-styled vertical rail with branding + 5 nav items (Dashboard active via bg-primary) + user footer in light + dark
  • header-with-search scene renders v1-styled top app-bar with logo + horizontal nav + search input + user actions in light + dark
  • breadcrumb-deep scene renders v1-styled 5-level trail with chevron separators (final crumb foreground role token) in light + dark
  • tab-navigation scene renders v1-styled 5 tabs with mixed badges (primary/info/warning tones, Overview active via underline) in light + dark
  • User can navigate to each /scenes/navigation/{name} page and see the canonical v1 rendering in both light and dark modes

The prebuilt component library exists and looks right against canonical v1 defaults

  • Buttons cluster renders the primary/secondary/ghost variants × sm/base/lg sizes plus disabled + loading states in light + dark
  • Inputs cluster renders text/email/number/password/search variants plus default/error/disabled/readonly states in light + dark
  • Selects cluster renders the closed-state select trigger, searchable-select trigger, and plain-select trigger in light + dark
  • Toggles cluster renders checkbox states (unchecked/checked/indeterminate/disabled) + radio group + switch on/off + toggle + toggle-group
  • Numeric cluster renders the single slider, range slider, and number-input with stepper buttons in light + dark
  • Date cluster renders the closed-state date picker triggers (start + end) in light + dark
  • Overlays cluster renders dialog + alert-dialog + drawer + popover + tooltip + menu surfaces as separate sub-containers in light + dark
  • Disclosure cluster renders the tabs (3 tabs, "Activity" active) and accordion (3 items, item 2 expanded) surfaces in light + dark
  • Feedback cluster renders the 5-variant badge row + 4 alert variants (info/success/warning/error) + skeleton + presence + comment-count
  • Data displays cluster renders table (header + 3 rows + 1 selected) + kanban (2 cols × 2 cards) + simple-bar chart as sub-containers
  • Layout cluster renders card + divider + 3×2 grid + max-width container with vertical scroll-area in light + dark
  • Forms cluster renders auth-form (sign-in) + crud-form (3 fields with Cancel/Create footer) + file-upload (dashed dropzone + file row)
  • Navigation cluster renders search-list + page-scoped search-input (header input with ⌘K hint) + nav-menu (3 items, Dashboard active) in light + dark
  • Typography-in-chrome cluster renders h1/h2/h3 hierarchy + paragraph + inline code + code block + Source Serif blockquote + ul + ol
  • UI Kit page renders end-to-end as a single scrollable document — all 14 clusters compose without cross-cluster visual interference
  • User can navigate to /ui-kit and see every cluster rendered against canonical v1 (no theme override) in both light and dark modes

Component Base Styles And Variants

  • A rendered button keeps the btn-* token its variant was derived from
  • A design colour reaches every button that uses it, with no per-component config
  • .btn-icon makes a button as wide as it is tall regardless of its label
  • The input, select, textarea rule paints a form field that carries no class of its own
  • A page-level input is painted by its recipe instead, because utilities outrank components
  • .form-page draws the standalone form shell — centred and bounded
  • .form-page > form draws the card the fields sit in
  • .form-title and .form-description carry the standalone form's type hierarchy
  • The description tightens under the title on a standalone page only
  • .form-group-label rules a field group off from the fields under it
  • Dark mode repaints a rendered component from the app's dark tokens
  • The surviving component-layer rules paint what nothing else does (regression)

Component Schema Enhancements

  • className on a .card element adds/overrides theme-derived classes
  • style attribute provides inline CSS that overrides theme tokens
  • Arbitrary Tailwind values (bg-[#custom]) override theme tokens
  • Specificity: inline style > arbitrary value > className > theme token > fallback
  • Responsive overrides (md:bg-red-500) work on theme-aware components
  • variant: 'default' applies primary background and foreground colors
  • variant: 'destructive' applies destructive background and foreground colors
  • variant: 'outline' applies border with transparent background
  • variant: 'secondary' applies secondary background and foreground colors
  • variant: 'ghost' renders transparent with hover background
  • variant: 'link' renders as styled text link with underline on hover
  • size: 'sm' renders a compact button; size: 'lg' renders a larger button
  • size: 'icon' renders a square button sized for icon-only content
  • loading: true shows a spinner inside the button and disables interaction
  • badgeVariant: 'default' applies muted background with muted-foreground text
  • badgeVariant: 'secondary' applies secondary background and foreground
  • badgeVariant: 'destructive' applies destructive background and foreground — the error-bg / error-fg pair, not the solid
  • badgeVariant: 'outline' applies border with transparent background
  • alertVariant: 'default' applies standard card background and foreground
  • alertVariant: 'destructive' applies destructive color tokens (bg, text, border)
  • alertVariant: 'warning' applies warning color tokens
  • alertVariant: 'info' applies info color tokens
  • alertVariant: 'success' applies success color tokens

Form And Disclosure Component Enhancements

  • inputType: 'text' renders a standard text input (default)
  • inputType: 'email' renders an email input with browser email validation
  • inputType: 'password' renders a masked password input
  • inputType: 'number' renders a numeric input with increment controls
  • inputType: 'tel' renders a telephone input with appropriate keyboard hint
  • inputType: 'url' renders a URL input with browser URL validation
  • inputType: 'search' renders a search input with clear button
  • accordionType: 'single' allows only one item to be expanded at a time
  • accordionType: 'multiple' allows multiple items to be expanded simultaneously
  • defaultOpen specifies which item IDs are expanded on initial render
  • Clicking a collapsed item header expands its content panel
  • In single mode, expanding one item collapses the previously expanded item
  • tabsOrientation: 'horizontal' renders tab triggers in a horizontal row
  • tabsOrientation: 'vertical' renders tab triggers in a vertical column
  • defaultTab sets which tab panel is visible on initial render
  • Clicking a tab trigger switches the visible tab panel
  • Active tab trigger is visually distinguished, not merely marked aria-selected
  • Keyboard: arrow keys navigate between tabs, Enter/Space selects

Last updated September 23, 2026

This documentation was written with AI, so errors or outdated content are possible. Sovrium is in beta. Contributions and corrections are welcome.

Built with Sovrium