Component Tokens
The component-scoped custom properties a Visor theme can bind for the admin-UI families — table, chip, filter bar, page header, empty state, banner, sidebar, tabs, skeleton, spinner — each with a documented Tier-1 fallback.
A Visor theme has always been able to repaint the Tier-1 semantic surface —
--surface-*, --text-*, --border-*, --interactive-*. That is enough to make
an admin UI functional. It is not enough to make it look like one thing:
nothing stopped a page inventing its own table-header treatment, its own chip
tracking, its own filter-bar padding. A fidelity retro on a shipped admin app
measured the result — 247 element categories across 13 families whose
inconsistency traced to the substrate, not to any one component. The page title
rendered five ways; the table header cell four ways; the same mono status chip at
8.5 / 9 / 9.5 / 10 / 11px across nine implementations.
Component tokens close that gap. Each family below exposes a set of
component-scoped custom properties a theme binds once, in its .visor.yaml.
Distil the design into the theme, and every consuming surface inherits it —
instead of each page re-deriving the look in local CSS.
The two guarantees
- Bind nothing and nothing changes. Every token is read as
var(--token, <Tier-1 expression>)where the fallback is byte-for-byte what the component rendered before the token existed. A theme with nocomponents:block is pixel-identical to a theme from before this contract, and the engine emits no extra CSS at all. - Bind one thing and every surface follows. A bound token retunes every consuming rule at once — the whole point of moving the decision into the theme.
Binding a family
# my-theme.visor.yaml
components:
table:
head-height: "2.5rem"
head-font-family: '"IBM Plex Mono", monospace'
head-font-size: "11px"
head-text-transform: uppercase
head-letter-spacing: "0.06em"
head-bg:
light: "#f7f7f8"
dark: "#141418"
chip:
md-font-size: "11px"
letter-spacing: "0.04em"
text-transform: uppercase
badge:
text-transform: uppercase
letter-spacing: "0.04em"
font-size: "11px"
font-weight: "600"A bare string binds both modes; { light, dark } binds them independently, and a
binding may be mode-asymmetric (dark only, say). Family and key names are
validated — an unknown key is an error, not a warning, because a typo'd
component token is otherwise silently inert: the component just keeps resolving
its fallback and you never learn the binding missed.
Where the CSS lands
Bound tokens emit into the visor-adaptive cascade layer, on the same selectors
as the Tier-1 adaptive set (light on the host selector; dark on the manual-toggle
selectors plus a prefers-color-scheme query). Single-mode brands
(color-scheme: dark-only / light-only) collapse onto the host selector, with
no toggle blocks — the same shape every other emitter uses.
Component .module.css files use no @layer at all, so a consumer that really
must override one surface locally still can. The point of this contract is that
they should not have to.
Overriding without a theme
The same properties work as plain CSS custom properties, so a block or a page shell can scope a treatment without touching the theme:
.myAdminShell {
--table-head-text-transform: uppercase;
--table-head-letter-spacing: 0.06em;
}Prefer the theme. A local override is exactly the per-page divergence this contract exists to end — reach for it only when the treatment genuinely belongs to one surface.
The contract
table
Table head, row and cell treatment. The retro measured the same header cell rendered four ways across one admin app; binding this family pins it once.
| Token | Binds | Falls back to |
|---|---|---|
--table-font-size | Base table type size. | var(--font-size-sm, 0.875rem) |
--table-text-color | Base table text colour. | var(--text-primary, #111827) |
--table-head-height | Header row height. | 3rem |
--table-head-padding-x | Header cell horizontal padding. | var(--spacing-3, 0.75rem) |
--table-head-font-family | Header cell family — set a mono/condensed face for a chrome-style header. | inherit |
--table-head-font-size | Header cell type size. | inherit |
--table-head-font-weight | Header cell weight. | var(--font-weight-medium, 500) |
--table-head-letter-spacing | Header cell tracking. | inherit |
--table-head-text-transform | Header cell casing — uppercase for the editorial admin header. | inherit |
--table-head-color | Header cell text colour. | var(--text-primary, #111827) |
--table-head-bg | Header cell fill. Sits above the shared --dt-header-bg surface role. | var(--dt-header-bg, transparent) |
--table-row-border | Row separator (width, style and colour). | 1px solid var(--border-default, #e5e7eb) |
--table-row-hover-bg | Row hover fill. | var(--surface-muted, #f3f4f6) |
--table-row-selected-bg | Selected-row fill. | var(--surface-muted, #f3f4f6) |
--table-cell-padding | Body cell padding. | var(--spacing-3, 0.75rem) |
--table-cell-font-size | Body cell type size. | inherit |
--table-cell-color | Body cell text colour. | var(--text-primary, #111827) |
--table-cell-bg | Body cell fill. Sits above the shared --dt-row-bg surface role. | var(--dt-row-bg, transparent) |
--table-cell-border-top | Cell hairline. | var(--hairline-width, 1px) solid var(--hairline, transparent) |
--table-footer-bg | Footer row fill. | var(--surface-muted, #f3f4f6) |
--table-caption-color | Caption text colour. | var(--text-secondary, #6b7280) |
data-table
The admin-ui Tier-2 data-table surface stack (D3). Shared between the Table primitive and the DataTable shell so a list page and its table read as one surface.
| Token | Binds | Falls back to |
|---|---|---|
--dt-header-bg | Header-row surface tier. | transparent |
--dt-row-bg | Data-row surface tier — a hair above the page so rows track any palette. | transparent |
--dt-container-radius | Table container rounding — 0 lets the consumer shell own the corners. | var(--radius-lg, 0.5rem) |
--dt-container-shadow | Table container elevation. | var(--shadow-sm) |
--dt-row-py | Row vertical rhythm (the density axis). | var(--spacing-3, 0.75rem) |
--dt-cell-px | Cell horizontal inset. | var(--spacing-5, 1.25rem) |
--dt-header-font-size | Sort-header type size. | 11px |
chip
Chip / filter-pill treatment. The retro found the same mono status chip at 8.5 / 9 / 9.5 / 10 / 11px with tracking .03–.14em across nine implementations; this family is the single dial.
| Token | Binds | Falls back to |
|---|---|---|
--chip-radius | Chip corner rounding. | var(--radius-full, 9999px) |
--chip-border | Outlined-chip edge colour. Width and style come from the shared control edge, so control.edge-width: 0 turns it off. | var(--control-edge-color, var(--border-default, #e5e7eb)) |
--chip-bg | Resting chip fill. | var(--surface-card, #ffffff) |
--chip-text-color | Chip label colour. | var(--text-primary, #111827) |
--chip-font-family | Chip label family — bind a mono face for token/ID chips. | var(--font-body, inherit) |
--chip-font-weight | Chip label weight. | var(--font-weight-medium, 500) |
--chip-letter-spacing | Chip label tracking. | inherit |
--chip-text-transform | Chip label casing. | inherit |
--chip-sm-height | Small chip height. | 1.5rem |
--chip-sm-padding-x | Small chip horizontal padding. | var(--spacing-2, 0.5rem) |
--chip-sm-font-size | Small chip type size. | var(--font-size-xs, 0.75rem) |
--chip-md-height | Default chip height. | 2rem |
--chip-md-padding-x | Default chip horizontal padding. | var(--spacing-3, 0.75rem) |
--chip-md-font-size | Default chip type size. | var(--font-size-sm, 0.875rem) |
--chip-lg-height | Large chip height. | 2.5rem |
--chip-lg-padding-x | Large chip horizontal padding. | var(--spacing-4, 1rem) |
--chip-lg-font-size | Large chip type size. | var(--font-size-base, 1rem) |
--chip-selected-bg | Selected chip fill. | var(--surface-accent-subtle, #eff6ff) |
--chip-selected-border-color | Selected chip edge colour. | var(--surface-accent-default, #3b82f6) |
--chip-selected-text-color | Selected chip label colour. | var(--text-link, #2563eb) |
badge
Badge treatment — the D3 Tier-2 --badge-text-transform / -letter-spacing / -font-size / -font-weight set, promoted from the admin-ui audit to a bindable contract.
| Token | Binds | Falls back to |
|---|---|---|
--badge-radius | Badge corner rounding. | var(--radius-full, 9999px) |
--badge-border | Badge outline. | 1px solid transparent |
--badge-font-family | Badge label family. | inherit |
--badge-font-weight | Badge label weight (Tier-2: 600). | var(--font-weight-medium, 500) |
--badge-text-transform | Badge casing (Tier-2: uppercase). | none |
--badge-letter-spacing | Badge tracking (Tier-2: 0.04em). | normal |
--badge-font-size | Default badge type size (Tier-2: 11px). Drives both the standard and editorial-density steps. | var(--font-size-xs, 0.75rem) |
--badge-md-padding | Default badge padding. | calc(var(--spacing-1, 0.25rem) / 2) var(--spacing-2, 0.5rem) |
--badge-md-gap | Default badge icon gap. | var(--spacing-1, 0.25rem) |
--badge-bg | Badge ground in every variant (an accent tint, an on-image scrim). Unset, each variant keeps its own surface. | The variant's own surface |
--badge-ink | Badge label colour in every variant. Unset, each variant keeps its own text colour. | The variant's own text colour |
A theme-wide bg / ink binding paints every variant the same, including destructive and success, so status colour is lost. To tint one badge or one region, set --badge-bg / --badge-ink on that badge or its container instead.
status-badge
Status-badge indicator dot and the mono readout label. Chrome is inherited from the Badge family.
| Token | Binds | Falls back to |
|---|---|---|
--status-badge-dot-size | Indicator dot diameter. | 0.5rem |
--status-badge-dot-radius | Indicator dot rounding — square it for a Linear-style status marker. | var(--radius-full, 9999px) |
--status-badge-mono-font-family | Mono-label family. | var(--font-mono, "SF Mono", "Fira Code", "Fira Mono", monospace) |
--status-badge-mono-font-size | Mono-label type size. | var(--text-11, 0.6875rem) |
--status-badge-mono-letter-spacing | Mono-label tracking. | 0.02em |
filter-bar
The operator's named case: "the filter bar above the table in many admin pages was pretty different from one to the next; that should be streamlined."
| Token | Binds | Falls back to |
|---|---|---|
--filter-bar-bg | Filter-bar fill. | var(--surface-card, #ffffff) |
--filter-bar-border | Filter-bar frame. | 1px solid var(--border-default, #e5e7eb) |
--filter-bar-radius | Filter-bar corner rounding. | var(--radius-lg, 0.75rem) |
--filter-bar-padding | Filter-bar inset. | var(--spacing-3, 0.75rem) var(--spacing-4, 1rem) |
--filter-bar-gap | Vertical rhythm between the control row and the chip row. | var(--spacing-3, 0.75rem) |
--filter-bar-text-color | Filter-bar text colour. | var(--text-primary, #111827) |
--filter-bar-dense-padding | Dense-variant inset. | var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem) |
--filter-bar-dense-gap | Dense-variant rhythm. | var(--spacing-2, 0.5rem) |
--filter-bar-control-radius | Rounding of the search input and filter triggers inside the bar. | var(--radius-md, 0.25rem) |
--filter-bar-results-font-size | Results-count type size. | var(--font-size-sm, 0.875rem) |
--filter-bar-results-color | Results-count colour. | var(--text-secondary, #6b7280) |
page-header
Page-header lockup. The retro found the page title rendered five ways across one app.
| Token | Binds | Falls back to |
|---|---|---|
--page-header-gap | Header stack rhythm. | var(--spacing-4, 1rem) |
--page-header-text-color | Header text colour. | var(--text-primary, #111827) |
--page-header-leading-gap | Gap between the leading media slot and the text stack. | var(--spacing-3, 0.75rem) |
--page-header-eyebrow-font-size | Eyebrow type size. | var(--font-size-xs, 0.75rem) |
--page-header-eyebrow-font-weight | Eyebrow weight. | var(--font-weight-semibold, 600) |
--page-header-eyebrow-letter-spacing | Eyebrow tracking. | var(--letter-spacing-wide, 0.05em) |
--page-header-eyebrow-text-transform | Eyebrow casing. | uppercase |
--page-header-eyebrow-color | Eyebrow colour. | var(--text-tertiary, #6b7280) |
--page-header-title-family | Marquee title family (titleFamily="display"). Falls through to the admin-ui marquee role before the theme display font. | var(--admin-ui-marquee-family, var(--font-display, var(--font-family-heading, inherit))) |
--page-header-title-size | Marquee title size (titleSize="marquee"). | 3.5rem |
--page-header-title-leading | Title leading. | var(--line-height-tight, 1.2) |
--page-header-title-font-size | Standard title size. | var(--font-size-2xl, 1.5rem) |
--page-header-title-font-weight | Title weight. | var(--font-weight-semibold, 600) |
--page-header-title-letter-spacing | Title tracking. | var(--letter-spacing-tight, -0.01em) |
--page-header-title-color | Title colour. | var(--text-primary, #111827) |
--page-header-description-font-size | Description type size. | var(--font-size-sm, 0.875rem) |
--page-header-description-color | Description colour. | var(--text-secondary, #6b7280) |
--page-header-actions-gap | Gap between header action buttons. | var(--spacing-2, 0.5rem) |
empty-state
Empty-state placard — surface, icon chip and copy scale.
| Token | Binds | Falls back to |
|---|---|---|
--empty-state-radius | Placard corner rounding. | var(--radius-lg, 0.75rem) |
--empty-state-text-color | Placard base text colour. | var(--text-secondary, #6b7280) |
--empty-state-gap | Placard stack rhythm. | var(--spacing-2, 0.5rem) |
--empty-state-padding | Default-size placard inset. | var(--spacing-8, 2rem) var(--spacing-5, 1.25rem) |
--empty-state-bg | Default-tone fill. | var(--surface-muted, #f9fafb) |
--empty-state-border | Default-tone dashed-edge colour. Width and style come from the shared control drop edge, so control.edge-width: 0 turns it off. | var(--control-edge-color, var(--border-default, #e5e7eb)) |
--empty-state-icon-size | Icon-chip diameter. | 72px |
--empty-state-icon-radius | Icon-chip rounding. | var(--radius-full, 9999px) |
--empty-state-icon-bg | Icon-chip fill. | var(--surface-subtle, #f5f5f6) |
--empty-state-icon-color | Icon-chip glyph colour. | var(--text-tertiary, #6b7280) |
--empty-state-heading-font-family | Heading family. | var(--font-family-heading, inherit) |
--empty-state-heading-font-size | Default-size heading type size. | var(--font-size-base, 1rem) |
--empty-state-heading-font-weight | Heading weight. | var(--font-weight-semibold, 600) |
--empty-state-heading-color | Heading colour. | var(--text-primary, #111827) |
--empty-state-description-font-size | Description type size. | var(--font-size-sm, 0.875rem) |
--empty-state-description-color | Description colour. | var(--text-secondary, #6b7280) |
--empty-state-actions-gap | Gap between placard actions. | var(--spacing-2, 0.5rem) |
--empty-state-error-color | Icon, chip glyph and heading colour for intent="error". | var(--destructive, #dc2626) |
banner
Full-width notice bar. The retro found six separate banner systems across ~180 instances in one app; binding this family collapses them to one.
| Token | Binds | Falls back to |
|---|---|---|
--banner-padding | Banner inset. | var(--spacing-3, 0.75rem) var(--spacing-4, 1rem) |
--banner-gap | Gap between icon, content and actions. | var(--spacing-3, 0.75rem) |
--banner-font-size | Banner base type size. | var(--font-size-sm, 0.875rem) |
--banner-radius | Banner corner rounding — 0 keeps the full-bleed bar. | 0 |
--banner-border-width | Banner rule weight. | 1px |
--banner-shadow | Banner elevation. | var(--shadow-sm) |
--banner-title-font-weight | Banner title weight. | var(--font-weight-semibold, 600) |
--banner-description-font-size | Banner description type size. | var(--font-size-sm, 0.875rem) |
--banner-info-bg | Info-intent fill. | var(--surface-info-subtle, transparent) |
--banner-info-text-color | Info-intent text colour. | var(--text-info, currentColor) |
--banner-info-border-color | Info-intent rule colour. | var(--border-info, currentColor) |
--banner-warning-bg | Warning-intent fill. | var(--surface-warning-subtle, transparent) |
--banner-warning-text-color | Warning-intent text colour. | var(--text-warning, currentColor) |
--banner-warning-border-color | Warning-intent rule colour. | var(--border-warning, currentColor) |
--banner-error-bg | Error-intent fill. | var(--surface-error-subtle, transparent) |
--banner-error-text-color | Error-intent text colour. | var(--text-error, currentColor) |
--banner-error-border-color | Error-intent rule colour. | var(--border-error, currentColor) |
--banner-success-bg | Success-intent fill. | var(--surface-success-subtle, transparent) |
--banner-success-text-color | Success-intent text colour. | var(--text-success, currentColor) |
--banner-success-border-color | Success-intent rule colour. | var(--border-success, currentColor) |
sidebar
Sidebar chrome and nav-item treatment. The palette roles (bg, text, border, accent-*) already ship defaults from visor-core's visor-semantic layer and are read bare; the structural roles below are new in VI-625.
| Token | Binds | Falls back to |
|---|---|---|
--sidebar-bg | Sidebar rail fill. Defaulted by visor-core. | visor-core default |
--sidebar-text | Sidebar text colour. Defaulted by visor-core. | visor-core default |
--sidebar-text-muted | Sidebar group-label colour. Defaulted by visor-core. | visor-core default |
--sidebar-border | Sidebar separator + rail colour. Defaulted by visor-core. | visor-core default |
--sidebar-accent-bg | Active / hover nav-item fill. Defaulted by visor-core. | visor-core default |
--sidebar-accent-text | Active / hover nav-item text. Defaulted by visor-core. | visor-core default |
--sidebar-header-padding | Sidebar header inset. | var(--spacing-2, 0.5rem) |
--sidebar-footer-padding | Sidebar footer inset. | var(--spacing-2, 0.5rem) |
--sidebar-content-padding | Scrollable nav-area inset. | var(--spacing-1, 0.25rem) |
--sidebar-menu-gap | Gap between nav items. | var(--spacing-1, 0.25rem) |
--sidebar-group-label-height | Nav group-label height. | 2rem |
--sidebar-group-label-font-size | Nav group-label type size. | var(--font-size-xs, 0.75rem) |
--sidebar-group-label-font-weight | Nav group-label weight. | var(--font-weight-medium, 500) |
--sidebar-group-label-letter-spacing | Nav group-label tracking. | inherit |
--sidebar-group-label-text-transform | Nav group-label casing. | inherit |
--sidebar-item-radius | Nav-item corner rounding. | var(--radius-md, 0.375rem) |
--sidebar-item-gap | Nav-item icon gap. | var(--spacing-2, 0.5rem) |
--sidebar-item-font-size | Nav-item type size. | var(--font-size-sm, 0.875rem) |
--sidebar-item-font-weight | Nav-item resting weight. | inherit |
--sidebar-item-letter-spacing | Nav-item tracking. | inherit |
--sidebar-item-text-transform | Nav-item casing. | inherit |
--sidebar-item-height | Default nav-item height. | 2.25rem |
--sidebar-item-padding | Default nav-item inset. | 0 var(--spacing-3, 0.75rem) |
--sidebar-item-active-font-weight | Active nav-item weight. | var(--font-weight-medium, 500) |
tabs
Tab rail and trigger treatment, for both the segmented (default) and underlined (line) variants.
| Token | Binds | Falls back to |
|---|---|---|
--tabs-list-radius | Tab-rail corner rounding. | var(--radius-full, 9999px) |
--tabs-list-height | Tab-rail height. | 2.25rem |
--tabs-list-padding | Tab-rail inset. | calc(var(--spacing-1, 0.25rem) * 0.75) |
--tabs-list-color | Tab-rail base text colour. | var(--text-secondary, #6b7280) |
--tabs-list-bg | Segmented-variant rail fill. | var(--surface-muted, #f3f4f6) |
--tabs-line-border-bottom | Underlined-variant rail rule (width, style and colour). Unbound, the rail rule is painted at --hairline-width; binding this draws your border instead. | 1px solid transparent (the rule is painted by border-image at --hairline-width) |
--tabs-trigger-radius | Trigger corner rounding. | var(--radius-md, 0.375rem) |
--tabs-trigger-padding | Trigger inset. | var(--spacing-1, 0.25rem) var(--spacing-2, 0.5rem) |
--tabs-trigger-font-size | Trigger type size. | var(--font-size-sm, 0.875rem) |
--tabs-trigger-font-weight | Trigger weight. | var(--font-weight-medium, 500) |
--tabs-trigger-letter-spacing | Trigger tracking. | inherit |
--tabs-trigger-text-transform | Trigger casing. | inherit |
--tabs-trigger-color | Resting trigger colour. | var(--text-secondary, #6b7280) |
--tabs-trigger-active-bg | Active trigger fill. | var(--surface-page, #ffffff) |
--tabs-trigger-active-color | Active trigger colour. | var(--text-primary, #111827) |
--tabs-trigger-active-shadow | Active trigger elevation. | var(--shadow-sm) |
--tabs-indicator-height | Underline indicator thickness. | 2px |
--tabs-indicator-color | Underline indicator colour. | var(--text-primary, #111827) |
skeleton
Loading-placeholder shimmer and the content-shape geometry the admin list/table/detail skeletons use.
| Token | Binds | Falls back to |
|---|---|---|
--skeleton-from | Shimmer gradient start. | #f3f4f6 |
--skeleton-to | Shimmer gradient peak. | #e5e7eb |
--skeleton-radius | Default placeholder rounding. | var(--radius-md, 0.375rem) |
--skeleton-duration | Shimmer cycle length. | 1.5s |
--skeleton-logo-width | Logo-plate placeholder width. | 104px |
--skeleton-logo-height | Logo-plate placeholder height. | 24px |
--skeleton-pill-width | Badge-pill placeholder width. | 64px |
--skeleton-pill-height | Badge-pill placeholder height. | 18px |
--skeleton-line-h1-height | Display-line placeholder height. | 24px |
--skeleton-line-heading-height | Heading-line placeholder height. | 18px |
--skeleton-line-body-height | Body-line placeholder height. | 14px |
--skeleton-avatar-size | Avatar placeholder diameter. | 40px |
--skeleton-avatar-lg-size | Large avatar placeholder diameter. | 64px |
--skeleton-badge-width | Row-badge placeholder width. | 72px |
--skeleton-badge-height | Row-badge placeholder height. | 22px |
--skeleton-row-gap | Skeleton row gap. | var(--spacing-3, 0.75rem) |
--skeleton-row-padding | Skeleton row inset. | var(--spacing-3, 0.75rem) 0 |
--skeleton-row-border-bottom | Skeleton row separator. | 1px solid var(--border-default, #e5e7eb) |
spinner
Inline loading ring — track, leading edge, per-size geometry and cycle length.
| Token | Binds | Falls back to |
|---|---|---|
--spinner-track-color | Ring track colour. | var(--border-default, #e5e7eb) |
--spinner-edge-color | Leading-edge colour (default tone). | var(--text-tertiary, #6b7280) |
--spinner-primary-edge-color | Leading-edge colour (primary tone). | var(--primary, #111827) |
--spinner-radius | Ring rounding. | var(--radius-full, 9999px) |
--spinner-duration | Rotation cycle length. | var(--motion-duration-1500, 1500ms) |
--spinner-xs-size | Extra-small ring diameter. | 12px |
--spinner-xs-border-width | Extra-small ring stroke. | var(--stroke-width-thin, 1px) |
--spinner-sm-size | Small ring diameter. | 16px |
--spinner-sm-border-width | Small ring stroke. | var(--stroke-width-regular, 1.5px) |
--spinner-md-size | Default ring diameter. | 24px |
--spinner-md-border-width | Default ring stroke. | var(--stroke-width-medium, 2px) |
checkbox
Control sizing — the D3 Tier-2 --checkbox-size / -radius / -bg / -border set, promoted from the admin-ui audit to a bindable contract.
| Token | Binds | Falls back to |
|---|---|---|
--checkbox-size | Checkbox box size. | 1rem |
--checkbox-radius | Checkbox corner rounding — square it for the editorial admin look. | var(--radius-sm, 0.25rem) |
--checkbox-bg | Unchecked fill. | transparent |
--checkbox-border | Unchecked edge colour. Width and style come from the shared control edge, so control.edge-width: 0 turns it off. | var(--control-edge-color, var(--border-default, #e5e7eb)) |
--checkbox-edgeless-bg | Unchecked fill shown only while the shared edge is off (control.edge-width: 0), so the box still reads as a control. | var(--control-edge-color, var(--border-default, #e5e7eb)) |
--checkbox-bg-checked | Checked fill. | var(--interactive-primary-bg, var(--primary, #111827)) |
--checkbox-border-checked | Checked edge colour. | var(--interactive-primary-bg, var(--primary, #111827)) |
input
Input shape at the default md size. Each key falls back to the matching
--input-*-md variable, so a scope that sets one still applies while the key is
unbound; a bound key wins.
| Token | Binds | Falls back to |
|---|---|---|
--input-md-height | md height. Unbound, the height follows the padding and the line. Bind md-padding with it. | var(--input-height-md, auto) |
--input-md-padding | md padding. Also the padding of DialogField's medium well, so the well tracks the field. | var(--input-padding-md, var(--spacing-3_5, 0.875rem) var(--spacing-4, 1rem)) |
--input-md-radius | md corner rounding. Also the rounding of DialogField's well. | var(--input-radius-md, var(--radius-sm, 0.5rem)) |
textarea
Textarea shape at the default md size.
| Token | Binds | Falls back to |
|---|---|---|
--textarea-padding | md padding. | var(--spacing-3_5, 0.875rem) var(--spacing-4, 1rem) |
--textarea-radius | md corner rounding. | var(--radius-sm, 0.5rem) |
select
Select trigger shape at the default md size.
| Token | Binds | Falls back to |
|---|---|---|
--select-height | md trigger height. Unbound, the height follows the padding and the line. Bind padding with it. | auto |
--select-padding | md trigger padding. | var(--spacing-3_5, 0.875rem) var(--spacing-4, 1rem) |
--select-radius | md trigger corner rounding. | var(--radius-sm, 0.5rem) |
A 40px field with 10px 12px padding and 9px corners, on all three:
components:
input:
md-height: "40px"
md-padding: "10px 12px"
md-radius: "9px"
textarea:
padding: "10px 12px"
radius: "9px"
select:
height: "40px"
padding: "10px 12px"
radius: "9px"tag-input
TagInput — the field around the tags, each held tag, and the opt-in typing slot
(entryAs="slot"). The slot follows the tag's shape unless its own keys are set.
| Token | Binds | Falls back to |
|---|---|---|
--tag-input-min-height | Field minimum height. The field grows as tags wrap. | 2.25rem |
--tag-input-padding | Field padding around the tags. | var(--spacing-1, 0.25rem) var(--spacing-2, 0.5rem) |
--tag-input-radius | Field corner rounding. | var(--radius-md, 0.375rem) |
--tag-input-tag-height | Held-tag height. Unbound, the tag is as tall as its line. | auto |
--tag-input-tag-padding-x | Held-tag horizontal padding. | var(--spacing-2, 0.5rem) |
--tag-input-tag-font-size | Held-tag type size. The typing slot types at it too. | var(--font-size-xs, 0.75rem) |
--tag-input-tag-font-weight | Held-tag weight. Unbound, the tag inherits its row's weight. | revert-layer |
--tag-input-tag-radius | Held-tag corner rounding (a pill today). | var(--radius-full, 9999px) |
--tag-input-slot-height | Typing-slot height. Follows tag-height. | var(--tag-input-tag-height, 1.375rem) |
--tag-input-slot-padding-x | Typing-slot horizontal padding. Follows tag-padding-x. | var(--tag-input-tag-padding-x, var(--spacing-2, 0.5rem)) |
--tag-input-slot-radius | Typing-slot corner rounding. Follows tag-radius. | var(--tag-input-tag-radius, var(--radius-full, 9999px)) |
--tag-input-slot-min-width | Typing-slot minimum width. The slot grows with what is typed. | 4rem |
The typing slot draws its dashed edge from --control-drop-edge-width and
--control-drop-edge-style, so edges: off turns it off with every other edge.
A 29px tag and slot with 9px corners, in a 40px field:
components:
tag-input:
min-height: "40px"
padding: "5px"
radius: "9px"
tag-height: "29px"
tag-padding-x: "10px"
tag-font-weight: "300"
tag-radius: "9px"<TagInput entryAs="slot" defaultValue={["techno", "house"]} />control
The one switch for form-control edges. Every form control — Input, Textarea,
Select, Checkbox, Switch, TagInput, Chip, FileUpload, EmptyState and the edged
Button faces — draws its resting edge from these, so edge-width: 0 removes
every resting edge at once, dashed edges included.
| Token | Binds | Falls back to |
|---|---|---|
--control-edge-width | The switch. Resting-edge weight for every form control; 0 turns every resting edge off. | 1px (Chip: var(--stroke-width-thin, 1px)) |
--control-edge-color | Resting-edge colour. A component's own --<component>-border override still wins over it. | var(--border-default, #e5e7eb) |
--control-edge-style | Resting-edge style for solid edges. | solid |
--control-drop-edge-width | Dashed-edge weight (FileUpload draws it at 2×). Follows edge-width unless set — set it alone to keep dashed edges while solid edges are off. | var(--control-edge-width, 1px) |
--control-drop-edge-style | Dashed-edge style for drop targets, empty states and the TagInput typing slot. | dashed |
--control-state-edge-width | Focus and invalid edge weight. Independent of edge-width, so focus and invalid stay visible with edges off. | 1px |
--control-invalid-ring-width | Width of the error-colour ring on a refused value. Unbound, Input draws a 1.5px inset ring and Textarea and Select draw it at state-edge-width. | 1.5px (Textarea, Select: var(--control-state-edge-width, 1px)) |
--control-invalid-halo-width | Width of the soft error-colour halo outside the ring. Unbound, Input draws none and Textarea and Select draw it at --focus-ring-width. | 0px (Textarea, Select: var(--focus-ring-width, 2px)) |
Turning every edge off is one line in the theme:
# my-theme.visor.yaml
edges: off # shorthand for components.control.edge-width: "0"Or set the token on any ancestor, for one part of an app:
.editor {
--control-edge-width: 0;
}How the switch holds up:
- No layout shift. The edge is an
outlinepulled inside the box with a matching negativeoutline-offset, over a transparent border that only holds the box's geometry. Flipping the switch moves nothing. - No
borderto fight. Nothing visible depends onborder-color, so a consumer never needs aborder-color: transparent !importantoverride. - Focus and invalid stay visible. They draw at
state-edge-width, which the switch does not touch. - The checkbox still reads as a box. With the edge off, an unchecked
checkbox takes a fill (
--checkbox-edgeless-bg), because the box is the control, not decoration. - Dashed edges can come back on their own. Set
--control-drop-edge-widthto keep drop-target and empty-state edges while solid edges stay off.
The refused-value look on Input, Textarea and Select is a ring in the error colour plus a soft halo. A 1px ring with a 3px halo on all three:
components:
control:
invalid-ring-width: "1px"
invalid-halo-width: "3px"hairline
The one switch for hairlines. Every hairline rule, divider and ring — the soft
separators and rings coloured by --hairline and --hairline-strong — reads its
weight from --hairline-width, so width: 0 removes all of them at once.
Independent of the control edge switch above: neither touches the other's
tokens.
| Token | Binds | Falls back to |
|---|---|---|
--hairline-width | The switch. Hairline weight for every --hairline / --hairline-strong rule; 0 turns every hairline off. Focus and invalid edges are untouched. | 1px (most components: var(--stroke-width-thin, 1px)) |
Turning every hairline off is one line in the theme:
# my-theme.visor.yaml
hairlines: off # shorthand for components.hairline.width: "0"Or set the token on any ancestor, for one part of an app:
.canvas {
--hairline-width: 0;
}How the switch holds up:
- Independent of
edges.hairlines: offemits only--hairline-width: 0andedges: offemits only--control-edge-width: 0. Set both, either, or neither. - No layout shift. Hairlines follow the same rule as the
controledge: the box keeps its geometry and only the paint changes. A ring (all four sides) is anoutlinepulled inside a transparent border of constant width (outline-offset: calc(-1 * var(--hairline-width))); a single rule (a rail, a header or footer rule, a divider) is a transparent border of constant width with the line painted byborder-imageat--hairline-width; a divider that is its own 1px box is an insetbox-shadow. Flipping the switch moves nothing. Rendering with it on is unchanged. - Two exceptions, both collapsed-border tables.
border-imageandoutlinedo not apply to aborder-collapse: collapsecell, so Table's cell hairline and MatrixTable's header and cell rules stay real borders. Table's cell rule shares its edge with the row rule, so removing it moves nothing; MatrixTable's header/body join has no second rule, so it tightens by 1px. - A bound border shorthand wins.
--tabs-line-border-bottomand--dialog-form-panel-bordertake a full border value. Bound, they draw that border instead of the painted hairline, and the switch no longer governs them. - Colour is still yours.
--hairlineand--hairline-strongare unchanged; the switch is the width, so a theme can still tune the colour while hairlines are on. - Focus and invalid stay visible. They draw at
--control-state-edge-width, which this switch does not touch.
field
Field label treatment — size, case, tracking and colour of FieldLabel, in both densities.
| Token | Binds | Falls back to |
|---|---|---|
--field-label-font-size | Label type size. Drives both the standard and editorial-density steps. | var(--font-size-sm, 0.875rem) · 13px |
--field-label-text-transform | Label casing. | revert-layer |
--field-label-letter-spacing | Label tracking. | revert-layer |
--field-label-color | Label colour. Drives both the standard and editorial-density steps. | var(--text-primary, currentColor) · var(--text-secondary) |
button
Button type treatment and rounding. The unprefixed keys set every size at once; a size key (dlg-radius) overrides one size.
| Token | Binds | Falls back to |
|---|---|---|
--button-text-transform | Casing for every size. | revert-layer |
--button-letter-spacing | Tracking for every size. | revert-layer |
--button-font-weight | Weight for every size. | var(--font-weight-medium, 500) · var(--font-weight-semibold, 600) |
--button-radius | Corner rounding for every size (999px for a pill). | var(--radius-md, 0.375rem) · var(--radius-lg, 0.5rem) |
--button-sm-font-size | sm type size. Drives both the standard and editorial-density steps. | var(--font-size-xs, 0.75rem) · 0.8125rem |
--button-sm-font-weight | sm weight. | var(--button-font-weight, var(--font-weight-medium, 500)) |
--button-sm-text-transform | sm casing. | var(--button-text-transform, revert-layer) |
--button-sm-letter-spacing | sm tracking. | var(--button-letter-spacing, revert-layer) |
--button-sm-radius | sm corner rounding. | var(--button-radius, var(--radius-md, 0.375rem)) |
--button-md-font-size | md type size. | var(--font-size-sm, 0.875rem) |
--button-md-font-weight | md weight. | var(--button-font-weight, var(--font-weight-medium, 500)) |
--button-md-text-transform | md casing. | var(--button-text-transform, revert-layer) |
--button-md-letter-spacing | md tracking. | var(--button-letter-spacing, revert-layer) |
--button-md-radius | md corner rounding. | var(--button-radius, var(--radius-md, 0.375rem)) |
--button-lg-font-size | lg type size. | var(--font-size-base, 1rem) |
--button-lg-font-weight | lg weight. | var(--button-font-weight, var(--font-weight-medium, 500)) |
--button-lg-text-transform | lg casing. | var(--button-text-transform, revert-layer) |
--button-lg-letter-spacing | lg tracking. | var(--button-letter-spacing, revert-layer) |
--button-lg-radius | lg corner rounding. | var(--button-radius, var(--radius-md, 0.375rem)) |
--button-dlg-font-size | dlg type size. | var(--font-size-2xs, 0.6875rem) |
--button-dlg-font-weight | dlg weight. | var(--button-font-weight, var(--font-weight-semibold, 600)) |
--button-dlg-text-transform | dlg casing. | var(--button-text-transform, revert-layer) |
--button-dlg-letter-spacing | dlg tracking. | var(--button-letter-spacing, revert-layer) |
--button-dlg-radius | dlg corner rounding. | var(--button-radius, var(--radius-lg, 0.5rem)) |
--button-icon-size | Icon-only (size="icon") glyph size. The square box is this plus icon-pad on every side. Also sizes the InlineEdit pencil and the SaveStatus retry mark. | 1rem |
--button-icon-pad | Icon-only hit-target pad around the glyph, on every side. Also the hit target of the InlineEdit pencil and the SaveStatus retry mark. | var(--spacing-2, 0.5rem) |
--button-icon-radius | Icon-only corner rounding (999px for a round mark). | var(--button-radius, var(--radius-md, 0.375rem)) |
--button-icon-ghost-color | Icon-only ghost ink at rest. Hover comes up to --text-primary. Also the ink of the InlineEdit pencil and the SaveStatus retry mark. | var(--text-secondary, #6b7280) |
text
Text type treatment, per size — set sm-font-size: 13px for a 13px step, or caps and tracking on xs for a state tag. Every key begins with a size.
| Token | Binds | Falls back to |
|---|---|---|
--text-xs-font-size | xs type size. | var(--font-size-xs, 0.75rem) |
--text-xs-text-transform | xs casing. | revert-layer |
--text-xs-letter-spacing | xs tracking. | revert-layer |
--text-sm-font-size | sm type size. | var(--font-size-sm, 0.875rem) |
--text-sm-text-transform | sm casing. | revert-layer |
--text-sm-letter-spacing | sm tracking. | revert-layer |
--text-md-font-size | md type size. | var(--font-size-base, 1rem) |
--text-md-text-transform | md casing. | revert-layer |
--text-md-letter-spacing | md tracking. | revert-layer |
--text-lg-font-size | lg type size. | var(--font-size-lg, 1.125rem) |
--text-lg-text-transform | lg casing. | revert-layer |
--text-lg-letter-spacing | lg tracking. | revert-layer |
--text-xl-font-size | xl type size. | var(--font-size-xl, 1.25rem) |
--text-xl-text-transform | xl casing. | revert-layer |
--text-xl-letter-spacing | xl tracking. | revert-layer |
switch
Switch track and knob dimensions (default size; size="sm" keeps its own) and colours. Set knob-inset to size the knob from the track: it sits that far from the track's outer edge on every side.
| Token | Binds | Falls back to |
|---|---|---|
--switch-track-width | Track width. | 2rem |
--switch-track-height | Track height. With knob-inset set, the knob is the track height less the inset on both sides. | 1.15rem |
--switch-knob-inset | Gap between the knob and the track's outer edge, on every side. Unset, the knob keeps its shipped 1rem size and position. | — (unset) |
--switch-track-bg | Unchecked track fill. | var(--border-default, #e5e7eb) |
--switch-edge-color | Track edge colour — transparent by default. Width and style come from the shared control edge, so control.edge-width: 0 turns it off. | transparent |
--switch-track-hover-bg | Unchecked track fill under the pointer. Bind it in a borderless theme, where --border-strong is transparent. | var(--border-strong, #d1d5db) |
--switch-track-hover-bg-checked | Checked track fill under the pointer. Unbound it is --border-strong, as it always was; bind it to keep the checked fill under the pointer. | var(--border-strong, #d1d5db) |
--switch-knob-bg | Unchecked knob fill. Bind it in a dark theme, where the page colour is a black knob on a dark track. | var(--surface-page, #ffffff) |
--switch-knob-bg-checked | Checked knob fill. Independent of knob-bg, so a light unchecked knob can sit beside a dark knob on a bright checked track. | var(--surface-page, #ffffff) |
Every hook in field, button, text and switch defaults to what shipped
before it existed. Case and tracking are declared in Visor's lowest cascade
layer (visor-base, where the reset lives) and fall back to revert-layer, so
while a hook is unset the element is exactly as it would be without it: your
own rules, a utility class, a reset, inheritance or the browser's <button>
default still decide. Set, the hook still yields to any rule of yours.
A compact caps pill for dialog actions, a 13px state line and a caps state tag:
# my-theme.visor.yaml
components:
button:
dlg-font-size: "10px"
dlg-text-transform: uppercase
dlg-letter-spacing: "0.06em"
dlg-radius: "999px"
text:
sm-font-size: "13px"
xs-font-size: "9px"
xs-text-transform: uppercase
xs-letter-spacing: "0.12em"
field:
label-font-size: "9px"
label-text-transform: uppercase
label-letter-spacing: "0.12em"
label-color: "var(--text-tertiary)"
switch:
track-width: "30px"
track-height: "17px"
knob-inset: "3px"None of these hooks draws an edge. The outline Button (at any size, dlg
included) and the Switch track take their edge from the control family, so
control.edge-width: 0 still turns them off.
save-status
SaveStatus readout — the fixed-width slot, its type, the Syncing ink and the Unsaved dot, so the readout never reflows its row.
| Token | Binds | Falls back to |
|---|---|---|
--save-status-width | Slot width. Unbound, the slot is as wide as the longest label in the current font plus the retry mark, the same in every state. Bind it to pin a width; a longer label then truncates inside it. | auto |
--save-status-syncing-color | Ink of the Syncing state (a first sync from an outside source). Unbound it reads --text-secondary. | var(--text-secondary, #6b7280) |
--save-status-font-size | Readout type size. | var(--font-size-xs, 0.75rem) |
--save-status-dot-size | Diameter of the status dot (dot), in every state but Unsaved. Also the Unsaved dot's, until unsaved-dot-size is set. The slot width does not change. | var(--spacing-2, 0.5rem) |
--save-status-unsaved-dot-size | Diameter of the dot that draws Unsaved — the status dot while Unsaved, and the dot alone of unsavedAs="dot". Follows dot-size. | var(--save-status-dot-size, var(--spacing-2, 0.5rem)) |
--save-status-font-family | Readout family (e.g. the theme's mono). | revert-layer |
--save-status-text-transform | Readout casing. | revert-layer |
--save-status-letter-spacing | Readout tracking. | revert-layer |
Family, case and tracking sit in visor-base and fall back to revert-layer,
like the type hooks above, so while they are unset the readout inherits from
its row and any rule of yours wins. A 78px caps mono readout:
components:
save-status:
width: "78px"
font-family: "var(--font-mono)"
text-transform: uppercase
letter-spacing: "0.06em"With dot, SaveStatus draws a status dot before the label in every state, in
the state's ink. A 9px readout with a 5px dot that grows to 6px while unsaved:
components:
save-status:
font-size: "9px"
dot-size: "5px"
unsaved-dot-size: "6px"The dot's box is as wide as the larger diameter, so the label never moves.
SaveStatus draws no edge, so there is nothing for control.edge-width to turn off.
segmented-control
SegmentedControl — the well, its corner radius, the sliding pill, the ink and the state edge on it. The resting edge is the shared control edge.
| Token | Binds | Falls back to |
|---|---|---|
--segmented-control-track-bg | The well the pill slides in. Default is the page nudged 12% toward the ink, so it reads as a well in light and dark. | color-mix(in srgb, var(--text-primary, #111827) 12%, var(--surface-page, #ffffff)) |
--segmented-control-radius | Track corner radius. Unset, each size keeps its ToggleGroup radius. | xs var(--radius-md, 0.375rem), sm and md var(--radius-lg, 0.5rem), lg var(--radius-xl, 0.75rem) |
--segmented-control-indicator-bg | Sliding active pill. Bind a lifted dark fill where the theme wants one. | var(--interactive-primary-bg, var(--primary, #111827)) |
--segmented-control-indicator-text | Active segment label ink, on the pill. | var(--interactive-primary-text, #f9fafb) |
--segmented-control-indicator-edge | Colour of the 1px state edge on the active pill. Set transparent where the fill already clears 3:1 against the well. | var(--text-primary, #111827) |
--segmented-control-inactive-text | Inactive segment label colour. | var(--text-primary, #111827) |
--segmented-control-active-weight | Active segment label weight. | var(--font-weight-medium, 500) |
The state edge is an inset ring at --control-state-edge-width (the width focus and invalid use), so
control.edge-width: 0 turns the resting edge off and leaves the state edge, with no layout shift. A theme
whose pill already clears 3:1 against the well, such as an accent fill on black, binds it to transparent:
components:
segmented-control:
indicator-edge: "transparent"A red pill with white ink, muted inactive labels, a bold active label and an 8px track:
components:
segmented-control:
indicator-bg: "#e51a1a"
indicator-text: "#ffffff"
inactive-text: "rgba(255, 255, 255, 0.46)"
active-weight: "600"
radius: "8px"SegmentedControl's resting edge is an outline from --control-edge-*, so
control.edge-width: 0 turns it off while focus stays visible.
toggle-group
ToggleGroup — the outline track's fill, the selected option's fill and ink, the unselected ink, and the compact (xs) size: its label size, option sides and corners. SegmentedControl takes the xs option keys too.
| Token | Binds | Falls back to |
|---|---|---|
--toggle-group-track-bg | The outline variant's track. Bind a well darker than its plane where the theme wants one. | transparent |
--toggle-group-selected-bg | The selected option's fill: the sliding indicator (outline, single) and the item itself (default, and outline multiple). | var(--interactive-primary-bg, var(--primary, #111827)) |
--toggle-group-selected-text | The selected option's label ink. | var(--interactive-primary-text, #f9fafb) |
--toggle-group-unselected-text | Every option that is not selected. | var(--text-primary, #111827) |
--toggle-group-xs-font-size | Compact (xs) label size. | var(--font-size-xs, 0.75rem) |
--toggle-group-xs-padding-x | Compact (xs) option sides. | var(--spacing-2, 0.5rem) |
--toggle-group-xs-track-radius | Compact (xs) outline track corner radius. | var(--radius-md, 0.375rem) |
--toggle-group-xs-item-radius | Compact (xs) option and indicator corner radius. Concentric with the track is the track radius minus its 2px inset. | var(--radius-sm, 0.25rem) |
The track fill is set at zero specificity, so a consumer class that sets the group's background still wins.
SegmentedControl keeps its own track, pill and inks (segmented-control); only the xs option keys reach it.
A dark theme that wants a compact group on a well, with the selected option in a soft accent wash:
components:
toggle-group:
track-bg: "rgba(0, 0, 0, 0.65)"
selected-bg: "rgba(44, 224, 230, 0.09)"
selected-text: "#2ce0e6"
unselected-text: "rgba(255, 255, 255, 0.46)"
xs-font-size: 10px
xs-padding-x: 11px
xs-track-radius: 8px
xs-item-radius: 6pxtooltip
Tooltip ground and ink, so a dark theme can keep the label legible without retuning the shared inverse pair other components read.
| Token | Binds | Falls back to |
|---|---|---|
--tooltip-bg | Tooltip ground. | var(--surface-overlay, #111827) |
--tooltip-text | Tooltip label colour. | var(--text-inverse, #ffffff) |
Unbound, the tooltip reads --surface-overlay under --text-inverse, as it
always has. In a dark theme both can resolve dark, and --text-inverse cannot
be retuned for the tooltip alone: it is the dark ink Calendar, Badge and Chip
set on light and accent fills. Bind the pair in dark mode instead:
components:
tooltip:
bg:
dark: "rgba(0, 0, 0, 0.8)"
text:
dark: "#fafafa"A flat overrides.dark.tooltip-bg / tooltip-text pair emits the same two
properties through the brand pass-through.
action-row
ActionRow state fills, edge and geometry: rest, hover, open, selected and disabled each read their own token.
| Token | Binds | Falls back to |
|---|---|---|
--action-row-min-height | Row floor height; the whole row is the target. | 2.75rem |
--action-row-padding | Row inset, kept inside the hover and selected fills. | var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem) |
--action-row-gap | Gap between the leading slot, the title block and the trailing slot. | var(--spacing-3, 0.75rem) |
--action-row-radius | Row corner rounding (the hover and selected fill). | var(--radius-md, 0.375rem) |
--action-row-hover-bg | Hover fill. | var(--surface-interactive-hover, #f3f4f6) |
--action-row-open-bg | Fill of a row that has opened a picker or popover (aria-expanded). | var(--surface-interactive-hover, #f3f4f6) |
--action-row-selected-bg | Selected fill. A lifted fill, never white. | var(--surface-interactive-active, #e5e7eb) |
--action-row-selected-edge-color | Selected row's inset edge colour. Its width is --control-state-edge-width, so edges: off keeps it. | var(--text-secondary, #6b7280) |
--action-row-disabled-opacity | Disabled row opacity. | var(--opacity-50, 0.5) |
--action-row-title-weight | Title weight. | var(--font-weight-medium, 500) |
--action-row-line-color | Second-line colour. | var(--text-secondary, #6b7280) |
--action-row-leading-color | Leading icon colour. | var(--text-secondary, #6b7280) |
--action-row-leading-size | Leading icon size. | 1.25rem |
--action-row-trailing-color | Trailing value or caret colour. | var(--text-secondary, #6b7280) |
Unbound, the selected row is --surface-interactive-active (a neutral lift, not the accent --surface-selected) with a --text-secondary
inset edge. The row has no resting edge, and the selected edge is a state edge
at --control-state-edge-width, like focus and invalid: edges: off leaves it
(WCAG 1.4.11 asks 3:1 for a state indicator). The list's hairlines between rows
read --hairline-width (hairlines: off). Focus stays visible either way. A dark theme that wants the
lifted dark fill with a light edge binds the two selected tokens:
components:
action-row:
selected-bg:
dark: "#26262b"
selected-edge-color:
dark: "rgba(255, 255, 255, 0.24)"input-affix
Input and NumberInput prefix and suffix: the gap between a fixed affix and the value, and the affix's ink.
| Token | Binds | Falls back to |
|---|---|---|
--input-affix-gap | Space between a prefix or suffix and the value. | var(--spacing-2, 0.5rem) |
--input-affix-color | Prefix and suffix ink. | var(--text-secondary, #6b7280) |
The affix draws no edge of its own. The well keeps its one edge from the
control tokens, around the affix and the value together, so
control.edge-width: 0 turns it off and focus and invalid stay visible.
components:
input-affix:
gap: "12px"
color:
dark: "#a3a3a3"separator
The line a Separator draws, bare or on both sides of a label.
| Token | Binds | Falls back to |
|---|---|---|
--separator-color | Rule colour. | var(--border-default, #e5e7eb) |
A borderless theme sets border-default to transparent, which hides the rule.
Bind separator.color to keep it visible without bringing structural borders back.
components:
separator:
color:
dark: "#2c2c2c"alert
Alert's soft appearance: the inline alert under a control or in a dialog. Every key is soft-only, so binding the family never moves an edged Alert.
| Token | Binds | Falls back to |
|---|---|---|
--alert-icon-size | Soft icon size for the destructive, warning and success tones; the content is at least this tall. | 2rem |
--alert-icon-size-note | Soft icon size for the neutral note (default and info). | 1.5rem |
--alert-soft-padding | Soft alert inset. | var(--spacing-3, 0.75rem) calc(var(--spacing-3, 0.75rem) + var(--spacing-1, 0.25rem) / 2) |
--alert-gap | Gap between the soft icon and its text. | var(--spacing-3, 0.75rem) |
--alert-radius | Soft alert corner rounding. | var(--radius-lg, 0.5rem) |
--alert-font-size | Soft alert type size. | var(--font-size-sm, 0.875rem) |
--alert-lead-font-weight | Weight of the inline AlertLead sentence. | var(--font-weight-bold, 700) |
The defaults are Animal Booking's build. Blacklight's door sets a smaller, lighter type:
components:
alert:
font-size: "13px"
lead-font-weight: "600"admin-ui
Structural roles the admin-ui pattern owns. marquee-family is the display face for KPI hero figures and marquee page titles — the role the admin-ui portability audit named as the one piece of ENTR brand coupling a new theme must rebind.
| Token | Binds | Falls back to |
|---|---|---|
--admin-ui-marquee-family | Marquee / hero-figure display family. | var(--font-display, var(--font-family-heading, inherit)) |
Emit-only roles
Two entries above have no Visor component reading them. They exist so a
generated theme can carry the admin-ui pattern's Tier-2 treatment layer
rather than leaving it as prose in a design audit — the pattern shell
(design-prototypes/admin-ui/tokens.css) is the consumer. --surface-screen is
the deepest chrome tier (the sidebar rail); bind it to the theme's deep overlay,
never to a literal.
See also
- Token Rules — the rules every token and fallback must satisfy
- Creating Themes — the full
.visor.yamlsurface - Theming — how a theme is applied and switched
Token Rules
The 9 prescriptive rules for authoring Visor components — fallbacks, shadows, spacing, motion, overlays, focus rings, color formats, theme structure, and no magic numbers.
Theme Creator
Interactive tool for building custom Visor themes — adjust colors, typography, and design tokens, then export a ready-to-use CSS file.