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, by data attribute for entity identity, or derived from the name once the entity set has no end.
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>
<section class="spcv-block">
<p class="spcv-label">Past the end of the palette — a hue derived from the name</p>
<label class="spcv-field" for="spcv-name">
<span>Artist</span>
<input type="text" id="spcv-name" value="The Harbour Lights" autocomplete="off" />
</label>
<div class="spcv-marks" id="spcv-marks"></div>
<p class="spcv-note" id="spcv-note"></p>
</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);
}
/* ---------------------------------------------------------------
Past the end of the palette - the entity set is unbounded, so the
slot is computed from the name rather than stored.
--------------------------------------------------------------- */
.spcv-field { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.9375rem; color: var(--dim); }
.spcv-field input {
font: inherit;
min-height: 44px;
padding: 0.45rem 0.6rem;
border-radius: calc(var(--radius) - 4px);
border: 1px solid var(--border);
background: var(--surface);
color: var(--ink);
width: 100%;
}
.spcv-marks { display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem; margin-top: 0.6rem; }
/* Only the hue varies. Saturation and lightness are pinned, which is what
keeps the white initials legible across all 360 outcomes. */
.spcv-mark {
width: 40px;
height: 40px;
border-radius: 10px;
display: grid;
place-items: center;
color: #ffffff;
font-weight: 700;
flex: none;
}
.spcv-note { margin: 0.5rem 0 0; font-size: 0.9375rem; color: var(--dim); }
const SAMPLES = ['Ayako Rin', 'Nils Aker', 'Bramble & Vine', 'Quiet Machines'];
const nameInput = document.getElementById('spcv-name');
const marks = document.getElementById('spcv-marks');
const note = document.getElementById('spcv-note');
// The hash picks a hue and nothing else. Hashing saturation or lightness too is
// how a pale yellow avatar with invisible initials turns up once a month.
function hueFor(text) {
let hash = 0;
for (let index = 0; index < text.length; index += 1) {
hash = (hash * 31 + text.charCodeAt(index)) % 360;
}
return hash;
}
function initialsFor(text) {
const words = text.trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return '?';
return (words[0].charAt(0) + (words[1] ? words[1].charAt(0) : '')).toUpperCase();
}
function markFor(text) {
const mark = document.createElement('span');
mark.className = 'spcv-mark';
mark.style.background = 'hsl(' + hueFor(text) + ' 55% 42%)';
mark.textContent = initialsFor(text);
mark.title = text;
mark.setAttribute('aria-label', text);
return mark;
}
function render() {
const typed = nameInput.value || 'Unnamed';
marks.innerHTML = '';
marks.append(markFor(typed));
const label = document.createElement('strong');
label.textContent = typed;
marks.append(label);
for (const sample of SAMPLES) {
marks.append(markFor(sample));
}
note.textContent =
'Hue ' + hueFor(typed) + '°, saturation 55%, lightness 42% — the last two never vary, so the white initials hold at every hue.';
}
nameInput.addEventListener('input', render);
render();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.
**Past the end of the palette, derive the hue and nothing else.** A fixed set of entities gets numbered slots. An unbounded one — every artist, every sender, every uploader — has to compute its colour, and the rule is that the hash picks the hue while saturation and lightness stay pinned. Hashing all three is how a pale yellow avatar with invisible white initials turns up about once a month, with no way to reproduce it.
Hash a stable identifier, not the display name, wherever one exists: a renamed entity that changes colour looks like a different entity. Where only the name is available, say so in a comment, because that is the tradeoff being accepted.
Order a numbered palette by how distinct *consecutive* entries are, since dense ids land on consecutive slots. An unknown entity gets no attribute at all rather than a null one, which matches nothing while still looking deliberate.
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.