Color
Single-property color variants
No component ever holds a hex: one custom property carries the hue, a base rule derives the border, fill, spine, and dot from it at fixed strengths, and each variant sets that one property — keyed by class for semantic tones or by data attribute for entity identity.
Reach for it when building
- category and status badges
- label systems in issue trackers
- callout and admonition variants
- legend chips beside charts
- per-author coloring in collaborative tools
- calendar owners and participants
- multi-party conversation views
- series or project identity colors
- color
- color-mix
- custom-properties
- theming
- data-attributes
- badges
<div class="spcv-demo">
<section class="spcv-block">
<p class="spcv-label">Keyed by class — a fixed set of meanings</p>
<div class="spcv-row">
<span class="spcv-chip spcv-breaking"><i></i>Breaking change</span>
<span class="spcv-chip spcv-fixed"><i></i>Fixed</span>
<span class="spcv-chip spcv-reference"><i></i>Reference</span>
<span class="spcv-chip spcv-series"><i></i>Series</span>
<span class="spcv-chip"><i></i>Note</span>
</div>
</section>
<section class="spcv-block">
<p class="spcv-label">Keyed by data attribute — an open set of entities</p>
<div class="spcv-row">
<span class="spcv-chip" data-tone="0"><i></i>Ayako Rin</span>
<span class="spcv-chip" data-tone="1"><i></i>The Harbour Lights</span>
<span class="spcv-chip" data-tone="2"><i></i>Nils Aker</span>
<span class="spcv-chip" data-tone="9"><i></i>Deleted artist</span>
</div>
<table class="spcv-table">
<tbody>
<tr data-tone="0"><td>Ayako Rin</td><td>12 albums</td><td>added May</td></tr>
<tr data-tone="1"><td>The Harbour Lights</td><td>31 albums</td><td>added August</td></tr>
<tr data-tone="2"><td>Nils Aker</td><td>7 albums</td><td>added June</td></tr>
</tbody>
</table>
</section>
</div>Colors come from shared theme tokens — --surface, --ink, --border, --accent and friends — so this CSS carries no palette
of its own. Use Runnable file to copy the tokens along with it.
.spcv-demo { display: flex; flex-direction: column; gap: 1.5rem; max-width: 520px; }
.spcv-block { display: flex; flex-direction: column; gap: 0.6rem; }
.spcv-label {
margin: 0;
font-family: var(--mono);
font-size: 0.8125rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--dim);
}
.spcv-row { display: flex; gap: 0.75rem; flex-wrap: wrap; }
/* The entire system. Every colored property derives from --tone at a fixed
strength, so retuning the whole scheme is one edit here and adding a
variant is one declaration below. Text stays at the page ink — the tone
lives in the frame and the dot, never under the words, which is what
keeps every variant readable without per-variant contrast checking. */
.spcv-chip {
--tone: var(--dim);
display: inline-flex;
align-items: center;
gap: 0.45rem;
font-size: 0.9375rem;
font-weight: 600;
padding: 0.35rem 0.8rem;
border-radius: calc(var(--radius) - 2px);
border: 1.5px solid color-mix(in srgb, var(--tone) 50%, transparent);
background: color-mix(in srgb, var(--tone) 12%, var(--surface));
color: var(--ink);
}
.spcv-chip i {
width: 0.7rem;
height: 0.7rem;
border-radius: 50%;
background: var(--tone);
flex: 0 0 auto;
}
/* Keying one: a class per meaning. Each is exactly one declaration. */
.spcv-breaking { --tone: var(--bad); }
.spcv-fixed { --tone: var(--ok); }
.spcv-reference { --tone: var(--accent); }
.spcv-series { --tone: var(--signal); }
/* Keying two: a numbered slot per entity. The bare attribute selector is
the neutral fallback — an unknown, deleted, or overflowed entity lands on
the muted default rather than breaking or stealing slot 0's identity. */
[data-tone] { --tone: var(--dim); }
[data-tone='0'] { --tone: var(--accent); }
[data-tone='1'] { --tone: var(--signal); }
[data-tone='2'] { --tone: var(--ok); }
.spcv-table {
border-collapse: collapse;
width: 100%;
font-size: 0.9375rem;
}
.spcv-table td {
padding: 0.55rem 0.75rem;
border-bottom: 1px solid var(--border);
color: var(--ink);
}
/* The identity spine is an inset box-shadow, not a border: border-collapse
would merge a real border with the row rule. */
.spcv-table td:first-child {
box-shadow: inset 4px 0 0 var(--tone);
font-weight: 600;
color: var(--tone);
}Paste this into an agent to rebuild the pattern from scratch.
Build a color-variant system where no component ever holds a hex value. One custom property carries the hue, a single base rule derives every colored property from it at fixed strengths, and each variant is exactly one declaration.
The base rule declares the property with a neutral default — call it --tone — then derives the treatment from it and nothing else: the border via color-mix(in srgb, var(--tone) 50%, transparent), the fill via color-mix(in srgb, var(--tone) 12%, var(--surface)), and a small solid dot or spine at full strength. Keep the text itself at the page's ink color. The tone lives in the frame and the dot, never under the words, and that is what lets every variant be readable without checking contrast one variant at a time.
Adding a hue is then one line, and retuning the whole system's strength is one edit to the base rule. Compare the usual approach — a border color, a background, and an icon color hand-written per variant — where the strengths drift apart the third time someone adds one.
Point every tone at the theme's own semantic tokens (danger, success, accent, signal), never a raw hex, so each variant means one thing everywhere it appears and gets a correct light and dark answer for free with no per-theme rules in the file at all.
There are two ways to key the variants, and the choice follows from whether the set is closed.
**A class per variant, when the set of meanings is fixed.** Status badges, callout kinds, log-row types: these are a vocabulary the design owns, so a named class reads correctly in the markup and the CSS is self-documenting.
**A numbered data attribute, when the set is open.** Authors, projects, calendar owners, conversation participants: entities arrive at runtime and there is no fixed vocabulary to name. Declare the tone on the bare [data-tone] attribute selector first so it resolves to the neutral default, then override per slot. That fallback is the whole reason to prefer the attribute form — the failure mode for an unknown, deleted, or overflowed entity is a muted chip rather than a broken style or a stolen identity.
Assign those slots in application code as (entityId - 1) % slotCount from a stable id, never from a list index, so an entity keeps its color across re-sorts, filters, and pagination. Nothing is more obviously broken than a person changing color because someone sorted the table.
Keep the dot. It makes the hue legible when the 12% fill is too subtle to name, and it gives a colorblind reader a second, denser sample of the same color to judge. In tables using border-collapse: collapse, draw an entity's identity spine on the first cell with box-shadow: inset 4px 0 0 var(--tone) rather than a left border, which the collapse algorithm would merge into the row rule.
The one-property contract generalizes past chips — callout boxes, log-row icons, legend swatches, table spines. Share the same property name across all of them and the system stays coherent; give each component its own property and you are back to hand-tuning.
Every color comes from theme custom properties, and every tone has to stay distinguishable from the neutral default, and from its neighbours, in both light and dark themes.