Status: Proposed (canonical CEM spec)
Last updated: December 19, 2025
Taxonomy placement: D5. Stroke & Separation (part of the 7-dimensional CEM token framework)
Companion specs:
cem-colors) — defines separator/focus colors; D5 defines thickness and geometrycem-dimension) — inset/spacing rules that keep strokes readable without crowdingcem-coupling) — density modes that affect when thin strokes must become strongercem-shape) — corner radii and rounding rules that strokes must followcem-layering) — when to prefer shadow separation vs stroke separationcem-timing) — animation timing for focus/selection transitionsD5 defines the geometry of separation:
D5 does not define:
Canvas, CanvasText, SelectedItem, emotional palettes, etc.).Tokens must express what the stroke means to the consumer, not how it is implemented:
Stroke systems can explode into per-component and per-state width tokens. CEM avoids that by defining:
Changing border width on focus is a common source of layout shift (e.g., label jumps in outlined text fields). Focus and selection indicators should be drawn using outline or box-shadow, or on a pseudo-element, rather than changing border-box geometry.
D5 must support modern focus-indicator requirements (including minimum visible area/thickness and sufficient contrast in high-contrast themes). See §8.
CEM D5 uses a 4-step stroke basis that covers almost all UI needs without a large token surface.
These tokens are foundational. Semantic endpoints map to them.
:root {
/* Basis: keep small and stable */
--cem-stroke-none: 0px; /* no stroke */
--cem-stroke-hair: 1px; /* hairline separators, subtle boundaries */
--cem-stroke-standard: 2px; /* focus/selection indicators; high legibility */
--cem-stroke-strong: 3px; /* high-emphasis boundaries; contrast themes */
}
Notes
1px remains device-independent (CSS px), and is appropriate for dividers and subtle component boundaries.
2px is the default for focus visibility and state distinction.
3px exists primarily for high contrast and “hard separation” contexts.
Material Design component specs frequently use 1–2dp stroke thickness for outlines and focus indicators; in web CEM this maps cleanly onto 1px hairlines and 2px indicators (see References for representative component spec links and issue discussions).
| Token | Value | Description | tier |
|---|---|---|---|
--cem-stroke-none |
0px |
No stroke | required |
--cem-stroke-hair |
1px |
Hairline separators / subtle boundaries | required |
--cem-stroke-standard |
2px |
Focus / selection / target indicator thickness | required |
--cem-stroke-strong |
3px |
High-emphasis boundaries / contrast themes | required |
Stroke thickness is allowed to vary by density mode as a non-breaking adjustment, as long as semantics stay intact:
These are the canonical semantic endpoints that components consume.
:root {
/* Canonical boundaries */
--cem-stroke-boundary: var(--cem-stroke-hair);
/* Canonical separators */
--cem-stroke-divider: var(--cem-stroke-hair);
/* Canonical indicators */
--cem-stroke-focus: var(--cem-stroke-standard);
--cem-stroke-selected: var(--cem-stroke-standard);
--cem-stroke-target: var(--cem-stroke-standard);
/* Placement */
--cem-stroke-indicator-offset: 2px; /* distance outside the edge, not thickness */
/* Optional convenience endpoints (aliases, not required by contract) */
--cem-stroke-grid: var(--cem-stroke-divider); /* tables / data grids */
--cem-stroke-boundary-strong: var(--cem-stroke-standard); /* dialogs / sheets when needed */
}
| Token | Value | Description | tier |
|---|---|---|---|
--cem-stroke-boundary |
var(--cem-stroke-hair) |
Default control container edge (text fields, chips, cards) | required |
--cem-stroke-boundary-strong |
var(--cem-stroke-standard) |
Strong boundary when elevation/shadow cannot carry separation | recommended |
--cem-stroke-divider |
var(--cem-stroke-hair) |
Sibling separators (list rows, table rows, sections) | required |
--cem-stroke-grid |
var(--cem-stroke-divider) |
Tables / data-grid divider alias | recommended |
--cem-stroke-focus |
var(--cem-stroke-standard) |
Keyboard focus indicator thickness | required |
--cem-stroke-selected |
var(--cem-stroke-standard) |
Selection indicator thickness | required |
--cem-stroke-target |
var(--cem-stroke-standard) |
Deep-link target indicator thickness | required |
--cem-stroke-indicator-offset |
2px |
External ring/outline offset (distance, not thickness) | required |
--cem-stroke-boundary is the default edge for control containers (text fields, chips, cards, tiles).--cem-stroke-divider separates siblings (list rows, table rows, sections).--cem-stroke-focus is for keyboard focus (and must meet §8).--cem-stroke-selected marks selection where background does not carry the whole burden of state (CEM preference).--cem-stroke-target marks a deep-linked / navigated-to target (e.g., URL fragment target, “jump to result”).--cem-stroke-indicator-offset prevents rings/indicators from visually merging with the component edge.Optional convenience endpoints (aliases):
--cem-stroke-grid is a readability alias for “divider inside structured content” (tables / data grids).--cem-stroke-boundary-strong is a stronger boundary used only when elevation/shadow is unavailable or insufficient.The D5 contract intentionally avoids per-component thickness tokens (e.g., --cem-input-outline). Instead:
Rule of thumb: color is D0, thickness is D5, corner curvature is D3, spacing/insets is D1, density-mode overrides are D2.
| Component family | Primary D5 endpoint(s) | Typical stroke usage | Notes |
|---|---|---|---|
| Dividers (horizontal/vertical) | --cem-stroke-divider |
1px hairline between siblings | Prefer inset patterns for lists; increase strength in dense or low-contrast contexts. |
| List item separators | --cem-stroke-divider (or --cem-stroke-grid) |
Row separators | Treat dense “data-list” layouts as a grid for readability. |
| Table borders / grid lines | --cem-stroke-grid |
Cell/row separators | Use grid to distinguish “structured separators” from generic dividers; same thickness by default. |
| Text field outline (outlined pattern) | --cem-stroke-boundary |
Control container edge | Focus indication should be a ring (--cem-stroke-focus) rather than mutating border width. |
| Text field underline (filled pattern) | --cem-stroke-boundary or --cem-stroke-divider |
Baseline boundary | Underline is a boundary of the control, not a sibling divider; map to divider only when visually used as “row separation”. |
| Outlined button border | --cem-stroke-boundary |
Variant boundary | Use boundary-strong only for “hard separation” themes (e.g., low-elevation UIs). |
| Card / tile border | --cem-stroke-boundary (often --cem-stroke-none) |
Optional edge | Prefer D4 elevation or D0 surface contrast; use boundary only when needed for scannability. |
| Checkbox / radio container border | --cem-stroke-boundary |
Control boundary | The checkmark/dot is not a stroke token concern; it is icon geometry (D3) + color (D0). |
| Switch track outline (if any) | --cem-stroke-boundary |
Track boundary | Track height is D2/D1, not D5; D5 only provides line weight if outlined. |
| Chip / badge border (bordered variant) | --cem-stroke-boundary |
Subtle outline | In dense chips, consider mapping boundary → standard via D2 coupling if needed for separation. |
| Tabs / segmented control indicator | --cem-stroke-selected |
Active underline/bar thickness | Avoid using divider for active indicators; indicators communicate state, not separation. |
| Focus ring (all controls) | --cem-stroke-focus + --cem-stroke-indicator-offset |
External ring/outline | Must satisfy WCAG 2.2 focus appearance (§8.1). |
| Target highlight (deep link / “jump to”) | --cem-stroke-target |
Temporary outline/ring | Distinct from focus: target is navigational, focus is interactive. |
| Menus / dropdowns / popovers (edge) | --cem-stroke-boundary (often none) |
Optional border | Prefer D4 shadow separation; use boundary/boundary-strong in forced-colors or shadow-suppressed contexts. |
| Progress/slider tracks | (usually none) / --cem-stroke-boundary |
Outline only if required | Track thickness (e.g., 8px) is a dimension; treat that as D2/D1, not D5. |
CEM uses an outline-driven indicator model for states that must remain visible across backgrounds:
:target)theme-data.xhtml)The current theme implementation uses a zebra ring composed of stacked outside strokes using box-shadow and zebra color variables:
--cem-zebra-strip-size--cem-zebra-color-0 … --cem-zebra-color-3--cem-action-box-shadow (used by .action components)In normal themes, a 3-stripe ring is used; in contrast themes, a 4-stripe ring is used (intent + focus + selected + target).
Ownership split (R-D5-1 resolved): --cem-zebra-strip-size and --cem-zebra-color-{0..3} are owned by D0
(cem-colors) and emitted by cem-colors.html because they ship with full theme-mode coverage
(.cem-theme-{light,dark,contrast-light,contrast-dark,native}). D5 owns:
--cem-stroke-{none,hair,standard,strong})--cem-stroke-{boundary,divider,focus,selected,target,…} and --cem-stroke-indicator-offset)--cem-ring-zebra-3, --cem-ring-zebra-4)--cem-zebra-angle (gradient-mode geometry only)D5 ring recipes reference D0-owned zebra tokens via var(); D5 does NOT redeclare them.
D5 treats these as canonical indicator-pattern tokens:
:root {
--cem-zebra-strip-size: 2px; /* thickness per stripe */
--cem-zebra-angle: 45deg; /* if stripes are rendered as gradients */
--cem-zebra-color-0: Canvas; /* intent / base stripe (contrast themes) */
--cem-zebra-color-1: CanvasText; /* focus stripe */
--cem-zebra-color-2: SelectedItem; /* selected stripe */
--cem-zebra-color-3: SelectedItem; /* target stripe (or themed alternative) */
}
--cem-zebra-strip-size and --cem-zebra-color-{0..3} are owned by D0 (cem-colors) and emitted by
cem-colors.html. D5 only declares --cem-zebra-angle and the ring composition recipes below.
| Token | Value | Description | tier |
|---|---|---|---|
--cem-zebra-angle |
45deg |
Stripe angle for gradient-mode zebra | recommended |
/* 3-stripe ring: focus/selected/target (normal themes) */
--cem-ring-zebra-3:
0 0 0 calc(1 * var(--cem-zebra-strip-size)) var(--cem-zebra-color-1),
0 0 0 calc(2 * var(--cem-zebra-strip-size)) var(--cem-zebra-color-2),
0 0 0 calc(3 * var(--cem-zebra-strip-size)) var(--cem-zebra-color-3);
/* 4-stripe ring: intent + focus + selected + target (contrast themes) */
--cem-ring-zebra-4:
0 0 0 calc(1 * var(--cem-zebra-strip-size)) var(--cem-zebra-color-0),
0 0 0 calc(2 * var(--cem-zebra-strip-size)) var(--cem-zebra-color-1),
0 0 0 calc(3 * var(--cem-zebra-strip-size)) var(--cem-zebra-color-2),
0 0 0 calc(4 * var(--cem-zebra-strip-size)) var(--cem-zebra-color-3);
Why box-shadow?
outline in forced-colors contexts (§8.2).| Token | Value | Description | tier |
|---|---|---|---|
--cem-ring-zebra-3 |
0 0 0 calc(1 * var(--cem-zebra-strip-size)) var(--cem-zebra-color-1), 0 0 0 calc(2 * var(--cem-zebra-strip-size)) var(--cem-zebra-color-2), 0 0 0 calc(3 * var(--cem-zebra-strip-size)) var(--cem-zebra-color-3) |
3-stripe focus/selected/target ring | recommended |
--cem-ring-zebra-4 |
0 0 0 calc(1 * var(--cem-zebra-strip-size)) var(--cem-zebra-color-0), 0 0 0 calc(2 * var(--cem-zebra-strip-size)) var(--cem-zebra-color-1), 0 0 0 calc(3 * var(--cem-zebra-strip-size)) var(--cem-zebra-color-2), 0 0 0 calc(4 * var(--cem-zebra-strip-size)) var(--cem-zebra-color-3) |
4-stripe ring (contrast themes) | recommended |
| Token | Value |
|---|---|
--cem-ring-zebra-3 |
0 0 0 var(--cem-stroke-focus) Highlight |
--cem-ring-zebra-4 |
0 0 0 var(--cem-stroke-focus) Highlight |
cem-stroke-rings-forced carries forced-colors fallback values for the rings (Principle P4). Generator-only — no
new tokens. The same ring names are redeclared inside @media (forced-colors: active) :root { … }.
Prefer :focus-visible semantics. If the platform needs a polyfilled focus-ring behavior, align with heuristics similar to Material Web’s focus ring utilities (see references).
CEM recognizes three common divider roles (same thickness basis, different usage):
All three should normally map to --cem-stroke-divider (hairline). Promote to --cem-stroke-boundary-strong when:
Avoid creating many inset tokens. Use a single inset rule:
Spacing and inset should use D1 tokens.
Indicator rings should follow component rounding:
border-radius: var(--cem-bend-control);
box-shadow: var(--cem-ring-zebra-3);
If the ring is outside, it may require a slightly larger radius:
border-radius: calc(var(--cem-bend-control) + var(--cem-stroke-indicator-offset));
Where indicators sit outside the edge, ensure surrounding layout provides enough breathing room. Prefer:
--cem-coupling-guard-min
and --cem-coupling-haloTreat --cem-coupling-guard-min as the default clearance budget between adjacent operable zones.
If you increase any of the following, you must also increase surrounding D1 spacing and/or D2 guard/halo
(or accept overlap):
3 * --cem-zebra-strip-size (normal) / 4 * --cem-zebra-strip-size (contrast)--cem-stroke-indicator-offset + --cem-stroke-focusSee the D2 compatibility rule in cem-coupling §4.1.1.
Elevation and stroke are substitutes:
The focus indicator must be large/visible enough to be reliably perceived. A robust default is a 2px perimeter (or equivalent area) around the focused component, which D5’s --cem-stroke-focus supports.
In forced-colors: active contexts:
outline (system colors) and avoid relying on subtle shadowsCanvasText / Highlight as appropriate in D0 mappingSuggested baseline:
@media (forced-colors: active) {
.cem-focusable:focus-visible {
outline: var(--cem-stroke-focus) solid CanvasText;
outline-offset: var(--cem-stroke-indicator-offset);
box-shadow: none;
}
}
Avoid
/* Causes layout shift */
.control:focus { border-width: 2px; }
Prefer
.control:focus-visible {
outline: var(--cem-stroke-focus) solid currentColor;
outline-offset: var(--cem-stroke-indicator-offset);
}
or (for rounded geometry / zebra):
.control:focus-visible {
box-shadow: var(--cem-ring-zebra-3);
}
If multiple indicators can apply, compose in a stable order:
In zebra, this is naturally expressed as concentric stripes.
Current implementation uses:
.action { box-shadow: var(--cem-action-box-shadow); }
.action:hover { --cem-action-box-shadow: var(--cem-action-box-shadow-hover); }
.action:active { --cem-action-box-shadow: var(--cem-action-box-shadow-active); }
In contrast themes, --cem-action-box-shadow becomes the zebra ring.
This section is non-normative. It documents how to map framework tokens/variables into the CEM D5 semantic endpoints.
Common MDC variables (examples seen in Angular Material customization workflows):
--mdc-outlined-text-field-outline-width → --cem-stroke-boundary--mdc-outlined-text-field-focus-outline-width → --cem-stroke-focus (or --cem-stroke-boundary-strong)--mdc-filled-text-field-active-indicator-height → --cem-stroke-divider--mdc-filled-text-field-focus-active-indicator-height → --cem-stroke-focus--mdc-outlined-card-outline-width → --cem-stroke-boundaryAngular Material also exposes higher-level variables in some setups, e.g.:
--mat-form-field-outlined-focus-outline-width → --cem-stroke-focusGuidance: map these variables from CEM tokens; do not treat them as canonical tokens in CEM.
MUI System
sx={{ border: 1 }} expresses a border thickness via the theme’s borders scale.border: 1 (typical) to --cem-stroke-boundary (hairline).MUI Joy UI Joy exposes per-component CSS variables for focus ring geometry (examples):
--Input-focusedThickness → --cem-stroke-focus--Input-focusedInset → whether focus is inset or outset (maps to indicator placement policy)--Input-focusedOffset → --cem-stroke-indicator-offset| Token | Category | Required | Meaning |
|---|---|---|---|
--cem-stroke-none |
Basis | ✓ | No stroke |
--cem-stroke-hair |
Basis | ✓ | 1px hairline |
--cem-stroke-standard |
Basis | ✓ | 2px standard indicator |
--cem-stroke-strong |
Basis | ✓ | 3px strong separation |
--cem-stroke-boundary |
Semantic | ✓ | Default component boundary |
--cem-stroke-boundary-strong |
Semantic | Optional | Strong boundary for cases where elevation/shadow cannot carry separation |
--cem-stroke-divider |
Semantic | ✓ | Default separator/divider |
--cem-stroke-grid |
Semantic | Optional | Divider alias for structured content (tables/grids) |
--cem-stroke-focus |
Semantic | ✓ | Focus-visible indicator width |
--cem-stroke-selected |
Semantic | ✓ | Selection indicator width |
--cem-stroke-target |
Semantic | ✓ | Target indicator width |
--cem-stroke-indicator-offset |
Placement | ✓ | Ring/outline offset distance |
--cem-zebra-strip-size |
Pattern | Optional | Stripe thickness for zebra indicators |
--cem-zebra-angle |
Pattern | Optional | Stripe angle for gradient zebra |
--cem-zebra-color-0..3 |
Pattern | Optional | Concentric zebra stripe colors (intent/focus/selected/target) |
--cem-action-box-shadow |
Adapter hook | Optional | Existing action indicator/shadow hook (implementation detail) |
Treat as major (breaking) if you:
--cem-stroke-focus no longer representing focus thickness).Treat as minor/patch if you:
2px → 3px in contrast theme only).| Source table | Section | Description |
|---|---|---|
cem-stroke-basis |
§3.1 | Stroke basis: --cem-stroke-{none,hair,standard,strong} |
cem-stroke-semantic |
§4.1 | Semantic endpoints: --cem-stroke-{boundary,boundary-strong,divider,grid,focus,selected,target,indicator-offset} |
cem-stroke-zebra-pattern |
§5.2 | --cem-zebra-angle (gradient-mode geometry) |
cem-stroke-rings |
§5.3 | Ring composition recipes: --cem-ring-zebra-3, --cem-ring-zebra-4 |
cem-stroke-rings-forced |
§5.3 | Forced-colors fallback values for the rings (generator-only; no new tokens) |
Generator derivation rules:
cem-stroke-basis, cem-stroke-semantic, cem-stroke-zebra-pattern, cem-stroke-rings → token list (tier in
last column).cem-stroke-rings-forced → override data emitted inside @media (forced-colors: active) :root { … }; no new
tokens.--cem-zebra-strip-size and --cem-zebra-color-{0..3} are owned by D0 (cem-colors) and NOT emitted here;
the ring recipes reference them via var() (R-D5-1 resolution).--cem-action-box-shadow is an existing implementation detail of the action stylesheet, not part of the D5
manifest contract; this generator does not emit it.W3C WCAG 2.2 Understanding: Focus Appearance (Minimum) — https://www.w3.org/WAI/WCAG22/Understanding/focus-appearance.html
MDN: :focus-visible — https://developer.mozilla.org/en-US/docs/Web/CSS/:focus-visible
Material Web focus ring documentation — https://github.com/material-components/material-web/blob/main/docs/components/focus-ring
Material Design 3 component specs (stroke/focus indicator values are listed per component):
Google issue tracker example referencing OutlinedTextField outline thickness in specs — https://issuetracker.google.com/issues/191543915
Angular Material customization guidance — https://v12.material.angular.io/guide/customizing-component-styles
Angular Material outlined form-field focus border width discussion (example) — https://github.com/angular/components/issues/28023
Angular Material customization examples showing outline width variables (examples):
MUI System borders utilities — https://mui.com/system/borders/
MUI Joy UI Input variables (focus thickness/offset) — https://mui.com/joy-ui/react-input/