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.var(--hairline-width, 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-borderOutlined-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-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 edge 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)
--badge-bgBadge ground in every variant (an accent tint, an on-image scrim). Unset, each variant keeps its own surface.The variant's own surface
--badge-inkBadge 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.

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 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-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)
--empty-state-error-colorIcon, chip glyph and heading colour for intent="error".var(--destructive, #dc2626)

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 (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-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 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-bgUnchecked 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-checkedChecked fill.var(--interactive-primary-bg, var(--primary, #111827))
--checkbox-border-checkedChecked 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.

TokenBindsFalls back to
--input-md-heightmd height. Unbound, the height follows the padding and the line. Bind md-padding with it.var(--input-height-md, auto)
--input-md-paddingmd 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-radiusmd 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.

TokenBindsFalls back to
--textarea-paddingmd padding.var(--spacing-3_5, 0.875rem) var(--spacing-4, 1rem)
--textarea-radiusmd corner rounding.var(--radius-sm, 0.5rem)

select

Select trigger shape at the default md size.

TokenBindsFalls back to
--select-heightmd trigger height. Unbound, the height follows the padding and the line. Bind padding with it.auto
--select-paddingmd trigger padding.var(--spacing-3_5, 0.875rem) var(--spacing-4, 1rem)
--select-radiusmd 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.

TokenBindsFalls back to
--tag-input-min-heightField minimum height. The field grows as tags wrap.2.25rem
--tag-input-paddingField padding around the tags.var(--spacing-1, 0.25rem) var(--spacing-2, 0.5rem)
--tag-input-radiusField corner rounding.var(--radius-md, 0.375rem)
--tag-input-tag-heightHeld-tag height. Unbound, the tag is as tall as its line.auto
--tag-input-tag-padding-xHeld-tag horizontal padding.var(--spacing-2, 0.5rem)
--tag-input-tag-font-sizeHeld-tag type size. The typing slot types at it too.var(--font-size-xs, 0.75rem)
--tag-input-tag-font-weightHeld-tag weight. Unbound, the tag inherits its row's weight.revert-layer
--tag-input-tag-radiusHeld-tag corner rounding (a pill today).var(--radius-full, 9999px)
--tag-input-slot-heightTyping-slot height. Follows tag-height.var(--tag-input-tag-height, 1.375rem)
--tag-input-slot-padding-xTyping-slot horizontal padding. Follows tag-padding-x.var(--tag-input-tag-padding-x, var(--spacing-2, 0.5rem))
--tag-input-slot-radiusTyping-slot corner rounding. Follows tag-radius.var(--tag-input-tag-radius, var(--radius-full, 9999px))
--tag-input-slot-min-widthTyping-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.

TokenBindsFalls back to
--control-edge-widthThe switch. Resting-edge weight for every form control; 0 turns every resting edge off.1px (Chip: var(--stroke-width-thin, 1px))
--control-edge-colorResting-edge colour. A component's own --<component>-border override still wins over it.var(--border-default, #e5e7eb)
--control-edge-styleResting-edge style for solid edges.solid
--control-drop-edge-widthDashed-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-styleDashed-edge style for drop targets, empty states and the TagInput typing slot.dashed
--control-state-edge-widthFocus and invalid edge weight. Independent of edge-width, so focus and invalid stay visible with edges off.1px
--control-invalid-ring-widthWidth 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-widthWidth 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 outline pulled inside the box with a matching negative outline-offset, over a transparent border that only holds the box's geometry. Flipping the switch moves nothing.
  • No border to fight. Nothing visible depends on border-color, so a consumer never needs a border-color: transparent !important override.
  • 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-width to 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.

TokenBindsFalls back to
--hairline-widthThe 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: off emits only --hairline-width: 0 and edges: off emits only --control-edge-width: 0. Set both, either, or neither.
  • No layout shift. Hairlines follow the same rule as the control edge: the box keeps its geometry and only the paint changes. A ring (all four sides) is an outline pulled 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 by border-image at --hairline-width; a divider that is its own 1px box is an inset box-shadow. Flipping the switch moves nothing. Rendering with it on is unchanged.
  • Two exceptions, both collapsed-border tables. border-image and outline do not apply to a border-collapse: collapse cell, 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-bottom and --dialog-form-panel-border take 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. --hairline and --hairline-strong are 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.

TokenBindsFalls back to
--field-label-font-sizeLabel type size. Drives both the standard and editorial-density steps.var(--font-size-sm, 0.875rem) · 13px
--field-label-text-transformLabel casing.revert-layer
--field-label-letter-spacingLabel tracking.revert-layer
--field-label-colorLabel 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.

TokenBindsFalls back to
--button-text-transformCasing for every size.revert-layer
--button-letter-spacingTracking for every size.revert-layer
--button-font-weightWeight for every size.var(--font-weight-medium, 500) · var(--font-weight-semibold, 600)
--button-radiusCorner rounding for every size (999px for a pill).var(--radius-md, 0.375rem) · var(--radius-lg, 0.5rem)
--button-sm-font-sizesm type size. Drives both the standard and editorial-density steps.var(--font-size-xs, 0.75rem) · 0.8125rem
--button-sm-font-weightsm weight.var(--button-font-weight, var(--font-weight-medium, 500))
--button-sm-text-transformsm casing.var(--button-text-transform, revert-layer)
--button-sm-letter-spacingsm tracking.var(--button-letter-spacing, revert-layer)
--button-sm-radiussm corner rounding.var(--button-radius, var(--radius-md, 0.375rem))
--button-md-font-sizemd type size.var(--font-size-sm, 0.875rem)
--button-md-font-weightmd weight.var(--button-font-weight, var(--font-weight-medium, 500))
--button-md-text-transformmd casing.var(--button-text-transform, revert-layer)
--button-md-letter-spacingmd tracking.var(--button-letter-spacing, revert-layer)
--button-md-radiusmd corner rounding.var(--button-radius, var(--radius-md, 0.375rem))
--button-lg-font-sizelg type size.var(--font-size-base, 1rem)
--button-lg-font-weightlg weight.var(--button-font-weight, var(--font-weight-medium, 500))
--button-lg-text-transformlg casing.var(--button-text-transform, revert-layer)
--button-lg-letter-spacinglg tracking.var(--button-letter-spacing, revert-layer)
--button-lg-radiuslg corner rounding.var(--button-radius, var(--radius-md, 0.375rem))
--button-dlg-font-sizedlg type size.var(--font-size-2xs, 0.6875rem)
--button-dlg-font-weightdlg weight.var(--button-font-weight, var(--font-weight-semibold, 600))
--button-dlg-text-transformdlg casing.var(--button-text-transform, revert-layer)
--button-dlg-letter-spacingdlg tracking.var(--button-letter-spacing, revert-layer)
--button-dlg-radiusdlg corner rounding.var(--button-radius, var(--radius-lg, 0.5rem))
--button-icon-sizeIcon-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-padIcon-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-radiusIcon-only corner rounding (999px for a round mark).var(--button-radius, var(--radius-md, 0.375rem))
--button-icon-ghost-colorIcon-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.

TokenBindsFalls back to
--text-xs-font-sizexs type size.var(--font-size-xs, 0.75rem)
--text-xs-text-transformxs casing.revert-layer
--text-xs-letter-spacingxs tracking.revert-layer
--text-sm-font-sizesm type size.var(--font-size-sm, 0.875rem)
--text-sm-text-transformsm casing.revert-layer
--text-sm-letter-spacingsm tracking.revert-layer
--text-md-font-sizemd type size.var(--font-size-base, 1rem)
--text-md-text-transformmd casing.revert-layer
--text-md-letter-spacingmd tracking.revert-layer
--text-lg-font-sizelg type size.var(--font-size-lg, 1.125rem)
--text-lg-text-transformlg casing.revert-layer
--text-lg-letter-spacinglg tracking.revert-layer
--text-xl-font-sizexl type size.var(--font-size-xl, 1.25rem)
--text-xl-text-transformxl casing.revert-layer
--text-xl-letter-spacingxl 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.

TokenBindsFalls back to
--switch-track-widthTrack width.2rem
--switch-track-heightTrack height. With knob-inset set, the knob is the track height less the inset on both sides.1.15rem
--switch-knob-insetGap 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-bgUnchecked track fill.var(--border-default, #e5e7eb)
--switch-edge-colorTrack 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-bgUnchecked track fill under the pointer. Bind it in a borderless theme, where --border-strong is transparent.var(--border-strong, #d1d5db)
--switch-track-hover-bg-checkedChecked 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-bgUnchecked 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-checkedChecked 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.

TokenBindsFalls back to
--save-status-widthSlot 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-colorInk of the Syncing state (a first sync from an outside source). Unbound it reads --text-secondary.var(--text-secondary, #6b7280)
--save-status-font-sizeReadout type size.var(--font-size-xs, 0.75rem)
--save-status-dot-sizeDiameter 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-sizeDiameter 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-familyReadout family (e.g. the theme's mono).revert-layer
--save-status-text-transformReadout casing.revert-layer
--save-status-letter-spacingReadout 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.

TokenBindsFalls back to
--segmented-control-track-bgThe 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-radiusTrack 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-bgSliding active pill. Bind a lifted dark fill where the theme wants one.var(--interactive-primary-bg, var(--primary, #111827))
--segmented-control-indicator-textActive segment label ink, on the pill.var(--interactive-primary-text, #f9fafb)
--segmented-control-indicator-edgeColour 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-textInactive segment label colour.var(--text-primary, #111827)
--segmented-control-active-weightActive 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.

TokenBindsFalls back to
--toggle-group-track-bgThe outline variant's track. Bind a well darker than its plane where the theme wants one.transparent
--toggle-group-selected-bgThe 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-textThe selected option's label ink.var(--interactive-primary-text, #f9fafb)
--toggle-group-unselected-textEvery option that is not selected.var(--text-primary, #111827)
--toggle-group-xs-font-sizeCompact (xs) label size.var(--font-size-xs, 0.75rem)
--toggle-group-xs-padding-xCompact (xs) option sides.var(--spacing-2, 0.5rem)
--toggle-group-xs-track-radiusCompact (xs) outline track corner radius.var(--radius-md, 0.375rem)
--toggle-group-xs-item-radiusCompact (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: 6px

tooltip

Tooltip ground and ink, so a dark theme can keep the label legible without retuning the shared inverse pair other components read.

TokenBindsFalls back to
--tooltip-bgTooltip ground.var(--surface-overlay, #111827)
--tooltip-textTooltip 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.

TokenBindsFalls back to
--action-row-min-heightRow floor height; the whole row is the target.2.75rem
--action-row-paddingRow inset, kept inside the hover and selected fills.var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem)
--action-row-gapGap between the leading slot, the title block and the trailing slot.var(--spacing-3, 0.75rem)
--action-row-radiusRow corner rounding (the hover and selected fill).var(--radius-md, 0.375rem)
--action-row-hover-bgHover fill.var(--surface-interactive-hover, #f3f4f6)
--action-row-open-bgFill of a row that has opened a picker or popover (aria-expanded).var(--surface-interactive-hover, #f3f4f6)
--action-row-selected-bgSelected fill. A lifted fill, never white.var(--surface-interactive-active, #e5e7eb)
--action-row-selected-edge-colorSelected row's inset edge colour. Its width is --control-state-edge-width, so edges: off keeps it.var(--text-secondary, #6b7280)
--action-row-disabled-opacityDisabled row opacity.var(--opacity-50, 0.5)
--action-row-title-weightTitle weight.var(--font-weight-medium, 500)
--action-row-line-colorSecond-line colour.var(--text-secondary, #6b7280)
--action-row-leading-colorLeading icon colour.var(--text-secondary, #6b7280)
--action-row-leading-sizeLeading icon size.1.25rem
--action-row-trailing-colorTrailing 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.

TokenBindsFalls back to
--input-affix-gapSpace between a prefix or suffix and the value.var(--spacing-2, 0.5rem)
--input-affix-colorPrefix 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.

TokenBindsFalls back to
--separator-colorRule 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.

TokenBindsFalls back to
--alert-icon-sizeSoft icon size for the destructive, warning and success tones; the content is at least this tall.2rem
--alert-icon-size-noteSoft icon size for the neutral note (default and info).1.5rem
--alert-soft-paddingSoft alert inset.var(--spacing-3, 0.75rem) calc(var(--spacing-3, 0.75rem) + var(--spacing-1, 0.25rem) / 2)
--alert-gapGap between the soft icon and its text.var(--spacing-3, 0.75rem)
--alert-radiusSoft alert corner rounding.var(--radius-lg, 0.5rem)
--alert-font-sizeSoft alert type size.var(--font-size-sm, 0.875rem)
--alert-lead-font-weightWeight 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.

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