VisorVisor
Themes

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

  1. 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 no components: block is pixel-identical to a theme from before this contract, and the engine emits no extra CSS at all.
  2. 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.

TokenBindsFalls back to
--table-font-sizeBase table type size.var(--font-size-sm, 0.875rem)
--table-text-colorBase table text colour.var(--text-primary, #111827)
--table-head-heightHeader row height.3rem
--table-head-padding-xHeader cell horizontal padding.var(--spacing-3, 0.75rem)
--table-head-font-familyHeader cell family — set a mono/condensed face for a chrome-style header.inherit
--table-head-font-sizeHeader cell type size.inherit
--table-head-font-weightHeader cell weight.var(--font-weight-medium, 500)
--table-head-letter-spacingHeader cell tracking.inherit
--table-head-text-transformHeader cell casing — uppercase for the editorial admin header.inherit
--table-head-colorHeader cell text colour.var(--text-primary, #111827)
--table-head-bgHeader cell fill. Sits above the shared --dt-header-bg surface role.var(--dt-header-bg, transparent)
--table-row-borderRow separator (width, style and colour).1px solid var(--border-default, #e5e7eb)
--table-row-hover-bgRow hover fill.var(--surface-muted, #f3f4f6)
--table-row-selected-bgSelected-row fill.var(--surface-muted, #f3f4f6)
--table-cell-paddingBody cell padding.var(--spacing-3, 0.75rem)
--table-cell-font-sizeBody cell type size.inherit
--table-cell-colorBody cell text colour.var(--text-primary, #111827)
--table-cell-bgBody cell fill. Sits above the shared --dt-row-bg surface role.var(--dt-row-bg, transparent)
--table-cell-border-topCell hairline.1px solid var(--hairline, transparent)
--table-footer-bgFooter row fill.var(--surface-muted, #f3f4f6)
--table-caption-colorCaption 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.

TokenBindsFalls back to
--dt-header-bgHeader-row surface tier.transparent
--dt-row-bgData-row surface tier — a hair above the page so rows track any palette.transparent
--dt-container-radiusTable container rounding — 0 lets the consumer shell own the corners.var(--radius-lg, 0.5rem)
--dt-container-shadowTable container elevation.var(--shadow-sm)
--dt-row-pyRow vertical rhythm (the density axis).var(--spacing-3, 0.75rem)
--dt-cell-pxCell horizontal inset.var(--spacing-5, 1.25rem)
--dt-header-font-sizeSort-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.

TokenBindsFalls back to
--chip-radiusChip corner rounding.var(--radius-full, 9999px)
--chip-borderChip outline.var(--stroke-width-thin, 1px) solid var(--border-default, #e5e7eb)
--chip-bgResting chip fill.var(--surface-card, #ffffff)
--chip-text-colorChip label colour.var(--text-primary, #111827)
--chip-font-familyChip label family — bind a mono face for token/ID chips.var(--font-body, inherit)
--chip-font-weightChip label weight.var(--font-weight-medium, 500)
--chip-letter-spacingChip label tracking.inherit
--chip-text-transformChip label casing.inherit
--chip-sm-heightSmall chip height.1.5rem
--chip-sm-padding-xSmall chip horizontal padding.var(--spacing-2, 0.5rem)
--chip-sm-font-sizeSmall chip type size.var(--font-size-xs, 0.75rem)
--chip-md-heightDefault chip height.2rem
--chip-md-padding-xDefault chip horizontal padding.var(--spacing-3, 0.75rem)
--chip-md-font-sizeDefault chip type size.var(--font-size-sm, 0.875rem)
--chip-lg-heightLarge chip height.2.5rem
--chip-lg-padding-xLarge chip horizontal padding.var(--spacing-4, 1rem)
--chip-lg-font-sizeLarge chip type size.var(--font-size-base, 1rem)
--chip-selected-bgSelected chip fill.var(--surface-accent-subtle, #eff6ff)
--chip-selected-border-colorSelected chip outline colour.var(--surface-accent-default, #3b82f6)
--chip-selected-text-colorSelected 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.

TokenBindsFalls back to
--badge-radiusBadge corner rounding.var(--radius-full, 9999px)
--badge-borderBadge outline.1px solid transparent
--badge-font-familyBadge label family.inherit
--badge-font-weightBadge label weight (Tier-2: 600).var(--font-weight-medium, 500)
--badge-text-transformBadge casing (Tier-2: uppercase).none
--badge-letter-spacingBadge tracking (Tier-2: 0.04em).normal
--badge-font-sizeDefault badge type size (Tier-2: 11px). Drives both the standard and editorial-density steps.var(--font-size-xs, 0.75rem)
--badge-md-paddingDefault badge padding.calc(var(--spacing-1, 0.25rem) / 2) var(--spacing-2, 0.5rem)
--badge-md-gapDefault badge icon gap.var(--spacing-1, 0.25rem)

status-badge

Status-badge indicator dot and the mono readout label. Chrome is inherited from the Badge family.

TokenBindsFalls back to
--status-badge-dot-sizeIndicator dot diameter.0.5rem
--status-badge-dot-radiusIndicator dot rounding — square it for a Linear-style status marker.var(--radius-full, 9999px)
--status-badge-mono-font-familyMono-label family.var(--font-mono, "SF Mono", "Fira Code", "Fira Mono", monospace)
--status-badge-mono-font-sizeMono-label type size.var(--text-11, 0.6875rem)
--status-badge-mono-letter-spacingMono-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."

TokenBindsFalls back to
--filter-bar-bgFilter-bar fill.var(--surface-card, #ffffff)
--filter-bar-borderFilter-bar frame.1px solid var(--border-default, #e5e7eb)
--filter-bar-radiusFilter-bar corner rounding.var(--radius-lg, 0.75rem)
--filter-bar-paddingFilter-bar inset.var(--spacing-3, 0.75rem) var(--spacing-4, 1rem)
--filter-bar-gapVertical rhythm between the control row and the chip row.var(--spacing-3, 0.75rem)
--filter-bar-text-colorFilter-bar text colour.var(--text-primary, #111827)
--filter-bar-dense-paddingDense-variant inset.var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem)
--filter-bar-dense-gapDense-variant rhythm.var(--spacing-2, 0.5rem)
--filter-bar-control-radiusRounding of the search input and filter triggers inside the bar.var(--radius-md, 0.25rem)
--filter-bar-results-font-sizeResults-count type size.var(--font-size-sm, 0.875rem)
--filter-bar-results-colorResults-count colour.var(--text-secondary, #6b7280)

Page-header lockup. The retro found the page title rendered five ways across one app.

TokenBindsFalls back to
--page-header-gapHeader stack rhythm.var(--spacing-4, 1rem)
--page-header-text-colorHeader text colour.var(--text-primary, #111827)
--page-header-leading-gapGap between the leading media slot and the text stack.var(--spacing-3, 0.75rem)
--page-header-eyebrow-font-sizeEyebrow type size.var(--font-size-xs, 0.75rem)
--page-header-eyebrow-font-weightEyebrow weight.var(--font-weight-semibold, 600)
--page-header-eyebrow-letter-spacingEyebrow tracking.var(--letter-spacing-wide, 0.05em)
--page-header-eyebrow-text-transformEyebrow casing.uppercase
--page-header-eyebrow-colorEyebrow colour.var(--text-tertiary, #6b7280)
--page-header-title-familyMarquee 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-sizeMarquee title size (titleSize="marquee").3.5rem
--page-header-title-leadingTitle leading.var(--line-height-tight, 1.2)
--page-header-title-font-sizeStandard title size.var(--font-size-2xl, 1.5rem)
--page-header-title-font-weightTitle weight.var(--font-weight-semibold, 600)
--page-header-title-letter-spacingTitle tracking.var(--letter-spacing-tight, -0.01em)
--page-header-title-colorTitle colour.var(--text-primary, #111827)
--page-header-description-font-sizeDescription type size.var(--font-size-sm, 0.875rem)
--page-header-description-colorDescription colour.var(--text-secondary, #6b7280)
--page-header-actions-gapGap between header action buttons.var(--spacing-2, 0.5rem)

empty-state

Empty-state placard — surface, icon chip and copy scale.

TokenBindsFalls back to
--empty-state-radiusPlacard corner rounding.var(--radius-lg, 0.75rem)
--empty-state-text-colorPlacard base text colour.var(--text-secondary, #6b7280)
--empty-state-gapPlacard stack rhythm.var(--spacing-2, 0.5rem)
--empty-state-paddingDefault-size placard inset.var(--spacing-8, 2rem) var(--spacing-5, 1.25rem)
--empty-state-bgDefault-tone fill.var(--surface-muted, #f9fafb)
--empty-state-borderDefault-tone frame (dashed by default).1px dashed var(--border-default, #e5e7eb)
--empty-state-icon-sizeIcon-chip diameter.72px
--empty-state-icon-radiusIcon-chip rounding.var(--radius-full, 9999px)
--empty-state-icon-bgIcon-chip fill.var(--surface-subtle, #f5f5f6)
--empty-state-icon-colorIcon-chip glyph colour.var(--text-tertiary, #6b7280)
--empty-state-heading-font-familyHeading family.var(--font-family-heading, inherit)
--empty-state-heading-font-sizeDefault-size heading type size.var(--font-size-base, 1rem)
--empty-state-heading-font-weightHeading weight.var(--font-weight-semibold, 600)
--empty-state-heading-colorHeading colour.var(--text-primary, #111827)
--empty-state-description-font-sizeDescription type size.var(--font-size-sm, 0.875rem)
--empty-state-description-colorDescription colour.var(--text-secondary, #6b7280)
--empty-state-actions-gapGap between placard actions.var(--spacing-2, 0.5rem)

Full-width notice bar. The retro found six separate banner systems across ~180 instances in one app; binding this family collapses them to one.

TokenBindsFalls back to
--banner-paddingBanner inset.var(--spacing-3, 0.75rem) var(--spacing-4, 1rem)
--banner-gapGap between icon, content and actions.var(--spacing-3, 0.75rem)
--banner-font-sizeBanner base type size.var(--font-size-sm, 0.875rem)
--banner-radiusBanner corner rounding — 0 keeps the full-bleed bar.0
--banner-border-widthBanner rule weight.1px
--banner-shadowBanner elevation.var(--shadow-sm)
--banner-title-font-weightBanner title weight.var(--font-weight-semibold, 600)
--banner-description-font-sizeBanner description type size.var(--font-size-sm, 0.875rem)
--banner-info-bgInfo-intent fill.var(--surface-info-subtle, transparent)
--banner-info-text-colorInfo-intent text colour.var(--text-info, currentColor)
--banner-info-border-colorInfo-intent rule colour.var(--border-info, currentColor)
--banner-warning-bgWarning-intent fill.var(--surface-warning-subtle, transparent)
--banner-warning-text-colorWarning-intent text colour.var(--text-warning, currentColor)
--banner-warning-border-colorWarning-intent rule colour.var(--border-warning, currentColor)
--banner-error-bgError-intent fill.var(--surface-error-subtle, transparent)
--banner-error-text-colorError-intent text colour.var(--text-error, currentColor)
--banner-error-border-colorError-intent rule colour.var(--border-error, currentColor)
--banner-success-bgSuccess-intent fill.var(--surface-success-subtle, transparent)
--banner-success-text-colorSuccess-intent text colour.var(--text-success, currentColor)
--banner-success-border-colorSuccess-intent rule colour.var(--border-success, currentColor)

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.

TokenBindsFalls back to
--sidebar-bgSidebar rail fill. Defaulted by visor-core.visor-core default
--sidebar-textSidebar text colour. Defaulted by visor-core.visor-core default
--sidebar-text-mutedSidebar group-label colour. Defaulted by visor-core.visor-core default
--sidebar-borderSidebar separator + rail colour. Defaulted by visor-core.visor-core default
--sidebar-accent-bgActive / hover nav-item fill. Defaulted by visor-core.visor-core default
--sidebar-accent-textActive / hover nav-item text. Defaulted by visor-core.visor-core default
--sidebar-header-paddingSidebar header inset.var(--spacing-2, 0.5rem)
--sidebar-footer-paddingSidebar footer inset.var(--spacing-2, 0.5rem)
--sidebar-content-paddingScrollable nav-area inset.var(--spacing-1, 0.25rem)
--sidebar-menu-gapGap between nav items.var(--spacing-1, 0.25rem)
--sidebar-group-label-heightNav group-label height.2rem
--sidebar-group-label-font-sizeNav group-label type size.var(--font-size-xs, 0.75rem)
--sidebar-group-label-font-weightNav group-label weight.var(--font-weight-medium, 500)
--sidebar-group-label-letter-spacingNav group-label tracking.inherit
--sidebar-group-label-text-transformNav group-label casing.inherit
--sidebar-item-radiusNav-item corner rounding.var(--radius-md, 0.375rem)
--sidebar-item-gapNav-item icon gap.var(--spacing-2, 0.5rem)
--sidebar-item-font-sizeNav-item type size.var(--font-size-sm, 0.875rem)
--sidebar-item-font-weightNav-item resting weight.inherit
--sidebar-item-letter-spacingNav-item tracking.inherit
--sidebar-item-text-transformNav-item casing.inherit
--sidebar-item-heightDefault nav-item height.2.25rem
--sidebar-item-paddingDefault nav-item inset.0 var(--spacing-3, 0.75rem)
--sidebar-item-active-font-weightActive nav-item weight.var(--font-weight-medium, 500)

tabs

Tab rail and trigger treatment, for both the segmented (default) and underlined (line) variants.

TokenBindsFalls back to
--tabs-list-radiusTab-rail corner rounding.var(--radius-full, 9999px)
--tabs-list-heightTab-rail height.2.25rem
--tabs-list-paddingTab-rail inset.calc(var(--spacing-1, 0.25rem) * 0.75)
--tabs-list-colorTab-rail base text colour.var(--text-secondary, #6b7280)
--tabs-list-bgSegmented-variant rail fill.var(--surface-muted, #f3f4f6)
--tabs-line-border-bottomUnderlined-variant rail rule.1px solid var(--hairline, var(--border-default, #e5e7eb))
--tabs-trigger-radiusTrigger corner rounding.var(--radius-md, 0.375rem)
--tabs-trigger-paddingTrigger inset.var(--spacing-1, 0.25rem) var(--spacing-2, 0.5rem)
--tabs-trigger-font-sizeTrigger type size.var(--font-size-sm, 0.875rem)
--tabs-trigger-font-weightTrigger weight.var(--font-weight-medium, 500)
--tabs-trigger-letter-spacingTrigger tracking.inherit
--tabs-trigger-text-transformTrigger casing.inherit
--tabs-trigger-colorResting trigger colour.var(--text-secondary, #6b7280)
--tabs-trigger-active-bgActive trigger fill.var(--surface-page, #ffffff)
--tabs-trigger-active-colorActive trigger colour.var(--text-primary, #111827)
--tabs-trigger-active-shadowActive trigger elevation.var(--shadow-sm)
--tabs-indicator-heightUnderline indicator thickness.2px
--tabs-indicator-colorUnderline indicator colour.var(--text-primary, #111827)

skeleton

Loading-placeholder shimmer and the content-shape geometry the admin list/table/detail skeletons use.

TokenBindsFalls back to
--skeleton-fromShimmer gradient start.#f3f4f6
--skeleton-toShimmer gradient peak.#e5e7eb
--skeleton-radiusDefault placeholder rounding.var(--radius-md, 0.375rem)
--skeleton-durationShimmer cycle length.1.5s
--skeleton-logo-widthLogo-plate placeholder width.104px
--skeleton-logo-heightLogo-plate placeholder height.24px
--skeleton-pill-widthBadge-pill placeholder width.64px
--skeleton-pill-heightBadge-pill placeholder height.18px
--skeleton-line-h1-heightDisplay-line placeholder height.24px
--skeleton-line-heading-heightHeading-line placeholder height.18px
--skeleton-line-body-heightBody-line placeholder height.14px
--skeleton-avatar-sizeAvatar placeholder diameter.40px
--skeleton-avatar-lg-sizeLarge avatar placeholder diameter.64px
--skeleton-badge-widthRow-badge placeholder width.72px
--skeleton-badge-heightRow-badge placeholder height.22px
--skeleton-row-gapSkeleton row gap.var(--spacing-3, 0.75rem)
--skeleton-row-paddingSkeleton row inset.var(--spacing-3, 0.75rem) 0
--skeleton-row-border-bottomSkeleton row separator.1px solid var(--border-default, #e5e7eb)

spinner

Inline loading ring — track, leading edge, per-size geometry and cycle length.

TokenBindsFalls back to
--spinner-track-colorRing track colour.var(--border-default, #e5e7eb)
--spinner-edge-colorLeading-edge colour (default tone).var(--text-tertiary, #6b7280)
--spinner-primary-edge-colorLeading-edge colour (primary tone).var(--primary, #111827)
--spinner-radiusRing rounding.var(--radius-full, 9999px)
--spinner-durationRotation cycle length.var(--motion-duration-1500, 1500ms)
--spinner-xs-sizeExtra-small ring diameter.12px
--spinner-xs-border-widthExtra-small ring stroke.var(--stroke-width-thin, 1px)
--spinner-sm-sizeSmall ring diameter.16px
--spinner-sm-border-widthSmall ring stroke.var(--stroke-width-regular, 1.5px)
--spinner-md-sizeDefault ring diameter.24px
--spinner-md-border-widthDefault 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.

TokenBindsFalls back to
--checkbox-sizeCheckbox box size.1rem
--checkbox-radiusCheckbox corner rounding — square it for the editorial admin look.var(--radius-sm, 0.25rem)
--checkbox-bgUnchecked fill.transparent
--checkbox-borderUnchecked outline.1px solid var(--border-default, #e5e7eb)
--checkbox-bg-checkedChecked fill.var(--interactive-primary-bg, var(--primary, #111827))
--checkbox-border-checkedChecked outline colour.var(--interactive-primary-bg, var(--primary, #111827))

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.

TokenBindsFalls back to
--admin-ui-marquee-familyMarquee / 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