Component Styles
design.componentsrestyles 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.
design:
components:
button:
parts:
root: rounded-none tracking-tightEvery 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:
- Sovrium's recipe — the shipped default for the type.
design.components— this key. Your app-wide answer.props.className— the one instance. Beats your own app-wide rule, which is what makes a one-off override possible without an escape hatch.- 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.
design:
components:
table:
parts:
root: border-2
header: uppercase tracking-wide
cell: font-monoWhich 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.
design:
components:
button:
variants:
destructive: { root: border-2 }
ghost: { root: underline-offset-4 }states
Write these without the state prefix. Sovrium adds it.
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 browser — loading, 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.
Legal class lists, and the ones that are not
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
rootpart. 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.classNamestill wins today. The floor outranksdesign.components. Afocus-visible:ring-0written 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.
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
classNamebeats the recipe for the same CSS property, and the recipe still applies otherwise - A
classNamecarryingurl(inside an arbitrary value is refused at boot, naming the token - A
design.densityladder boots and serves; abyZonekey 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
designrenders exactly the body that nodesignrenders, 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: truedrops the recipe surface; omitting it layers over the recipeoklch()reaches the stylesheet; avar()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: truedrops 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-solidstatus 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
Navigation scenes render consistently against v1
- 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-kitand 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-iconmakes a button as wide as it is tall regardless of its label- The
input, select, textarearule 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-pagedraws the standalone form shell — centred and bounded.form-page > formdraws the card the fields sit in.form-titleand.form-descriptioncarry the standalone form's type hierarchy- The description tightens under the title on a standalone page only
.form-group-labelrules 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
classNameon a.cardelement adds/overrides theme-derived classesstyleattribute 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 colorsvariant: 'destructive'applies destructive background and foreground colorsvariant: 'outline'applies border with transparent backgroundvariant: 'secondary'applies secondary background and foreground colorsvariant: 'ghost'renders transparent with hover backgroundvariant: 'link'renders as styled text link with underline on hoversize: 'sm'renders a compact button;size: 'lg'renders a larger buttonsize: 'icon'renders a square button sized for icon-only contentloading: trueshows a spinner inside the button and disables interactionbadgeVariant: 'default'applies muted background with muted-foreground textbadgeVariant: 'secondary'applies secondary background and foregroundbadgeVariant: 'destructive'applies destructive background and foreground — theerror-bg/error-fgpair, not the solidbadgeVariant: 'outline'applies border with transparent backgroundalertVariant: 'default'applies standard card background and foregroundalertVariant: 'destructive'applies destructive color tokens (bg, text, border)alertVariant: 'warning'applies warning color tokensalertVariant: 'info'applies info color tokensalertVariant: '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 validationinputType: 'password'renders a masked password inputinputType: 'number'renders a numeric input with increment controlsinputType: 'tel'renders a telephone input with appropriate keyboard hintinputType: 'url'renders a URL input with browser URL validationinputType: 'search'renders a search input with clear buttonaccordionType: 'single'allows only one item to be expanded at a timeaccordionType: 'multiple'allows multiple items to be expanded simultaneouslydefaultOpenspecifies 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 rowtabsOrientation: 'vertical'renders tab triggers in a vertical columndefaultTabsets 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.