Here's a limitation most CSS frameworks share and rarely say out loud: if you load them from a CDN, you can't really change the colors. The palette was baked in at build time. To make it match your brand you have to install the toolchain, edit a config, and recompile. Farvist's v1.3 release was about removing that constraint entirely. This is how it works β including the two places where the translation is not as clean as the marketing line suggests.
The problem with baked colors
A framework built in Sass typically computes derived shades at compile time. A button's hover state is color.scale($primary, $lightness: 8%); a soft badge is rgba($primary, 0.16); a neon glow is a shadow tuned to the brand. All of that math runs once, during the build, and freezes into hex values in the shipped CSS. Change $primary and you have to run the build again to propagate it.
That's fine if you have a build. But a CDN user β or an AI assistant writing CSS in a sandbox β doesn't. For them, the palette is effectively read-only.
Step one: mirror every token to a custom property
The first move is to stop consuming Sass variables directly in component rules and instead consume CSS custom properties that are emitted from those Sass variables. The Sass tokens become configuration; the custom properties become the live wiring. In scss/base/_root.scss that's a loop over the same maps the utility generators use:
:root {
// Theme colors β --fv-primary, --fv-accent, --fv-danger, β¦
@each $name, $value in $theme-colors { --fv-#{$name}: #{$value}; }
@each $key, $value in $grays { --fv-gray-#{$key}: #{$value}; }
// β¦surfaces, glass, typography, shadows, glows, gradients
}
/* components read the variable, never the Sass value */
.text-primary { color: var(--fv-primary); }
That block compiles to 55 custom properties on :root, and dist/farvist.css refers back to them 942 times. Overriding --fv-primary recolors anything that references it β instantly, at runtime, with no rebuild. That alone handles flat colors. The hard part is the derived ones.
Step two: derive shades with color-mix() instead of Sass
color-mix() does at runtime what Sass color functions do at build time β and it accepts custom properties as inputs. Four translations cover essentially every derivation in the framework:
/* build-time Sass β runtime CSS */
rgba($primary, .16) β color-mix(in srgb, var(--fv-primary) 16%, transparent)
color.mix($white, $primary, 45%) β color-mix(in srgb, white 45%, var(--fv-primary))
color.scale($primary, $lightness: 8%) β color-mix(in srgb, var(--fv-primary) 92%, white)
color.scale($primary, $lightness: -10%) β color-mix(in srgb, var(--fv-primary) 90%, black)
The first row is exact, and it is exact for a non-obvious reason: color-mix() interpolates in premultiplied alpha. Mixing a color at 16% with transparent β which is rgba(0,0,0,0) β yields alpha 0.16 and, once un-premultiplied, exactly the original color. Had the spec interpolated straight channels instead, you'd get the color dragged 84% of the way toward black and then made translucent, which is a completely different swatch. The whole soft-badge/alert/chip layer rests on that detail.
The second row is exact too: Sass's legacy color.mix() is a plain per-channel sRGB lerp for opaque colors, and so is color-mix(in srgb, β¦). color.mix(#ffffff, #6d4af5, 45%) and the hand-written lerp both give rgb(175, 155, 250).
So the same declarations now follow a re-brand. Gradients, the frosted-glass mesh backdrops, the body's ambient orbs, the .bg-spotlight-* washes, hover and active states, focus rings β dist/farvist.css contains 223 color-mix() calls, and they resolve through var(--fv-*). A compiled button variant is nothing but a block of them:
.btn-primary {
--fv-btn-bg: var(--fv-primary);
--fv-btn-color: var(--fv-primary-contrast, #ffffff);
--fv-btn-border: var(--fv-primary);
--fv-btn-hover-bg: color-mix(in srgb, var(--fv-primary) 92%, white);
--fv-btn-hover-border: color-mix(in srgb, var(--fv-primary) 88%, white);
--fv-btn-active-bg: color-mix(in srgb, var(--fv-primary) 90%, black);
--fv-btn-focus-ring: color-mix(in srgb, var(--fv-primary) 45%, transparent);
--fv-btn-glow: 0 0 24px color-mix(in srgb, var(--fv-primary) 55%, transparent);
}
Where the translation is exact β and where it quietly isn't
Rows three and four of that table are the ones worth being careful about, because color.scale() and color-mix() are not the same operation. color.scale($lightness: p) moves HSL lightness a fraction of the way to the endpoint (L' = L + (1 β L)Β·p going up). color-mix(in srgb, C 92%, white) is a straight per-channel lerp in sRGB. Different spaces, different math.
They coincide under a condition. Mixing toward white scales every channel by (1 β p) and adds p, so HSL lightness lands on L(1 β p) + p β algebraically the same place color.scale puts it. Saturation, on the branch HSL uses when L β₯ 0.5, is delta / (2(1 β L)); numerator and denominator both pick up the same (1 β p) factor and it cancels. Hue is untouched. So for any color at or above 50% HSL lightness, mixing toward white in sRGB is byte-identical to scaling lightness up. Mixing toward black is the mirror image β exact only when L β€ 0.5, where the saturation branch is delta / 2L and the factor cancels the same way.
Compiled through Dart Sass and rounded to 8-bit, for the default $primary: #6d4af5 (HSL lightness 62.5%):
color.scale(+8%) β rgb(121, 88, 246) mix 92% + white β rgb(121, 88, 246) identical
color.scale(+28%) β rgb(150, 125, 248) mix 72% + white β rgb(150, 125, 248) identical
color.scale(β10%) β rgb( 85, 44, 243) mix 90% + black β rgb( 98, 67, 221) drift
Eight of the nine theme colors sit above 50% lightness, so for those eight every :hover shade survived the port exactly and the :active fill is where the default look actually moved: sRGB darkening pulls a color toward grey, while HSL scaling holds its saturation β 0.89 before, 0.69 after, for primary. $dark (#0b0f1d, lightness 7.8%) is the mirror case: its :active is exact and its :hover is the one that drifts.
We took that trade β it's a fill that only shows while the button is held β but "mathematically equivalent" is only true in one direction per color, and it's worth knowing which direction you're in before you port your own framework this way.
The one token that stayed baked
Of the 55 properties :root emits, 54 either hold a literal you'd want to override or a color-mix() expression that follows one. One does not:
--fv-primary-text: rgb(58.7764705882%, 48.8941176471%, 97.1764705882%);
That's color.scale($primary, $lightness: 28%) frozen at build time β a lighter violet for readable text on the dark surface. It exists because the brand primary only reaches 3.74:1 against the #060912 body background and fails AA outright; the lightened tint reaches 6.22:1.
Because it's a literal, it does not follow --fv-primary. And it is not a niche token β in the compiled CSS it colors every bare <a>, .btn-link, the resting text of .btn-outline-primary, the .streaming caret, .cite chips, and the active command-palette icon. A "re-brand" that sets only --fv-primary leaves all your links the old violet. That is the real reason the documented snippet is five lines and not three, and why all five built-in skins set primary-text explicitly.
The asymmetry is worth noting: primary is the only color with a -text token defined in :root. .btn-outline-success compiles to var(--fv-success-text, var(--fv-success)), and since nothing defines --fv-success-text, it falls through to the live brand color and re-brands correctly on its own. Only the primary path has a baked default sitting in the way.
The escape hatch: contrast
One thing genuinely doesn't survive the move to runtime: WCAG contrast math. Whether white or black text is readable on a fill depends on the fill's relative luminance, and there's no pure-CSS way to compute "the accessible text color for this variable." Sass can: contrast-color() in scss/abstracts/_functions.scss does the real thing β sRGB channels linearized, weighted 0.2126 / 0.7152 / 0.0722, both candidates scored β rather than the YIQ shortcut.
So the button-variant mixin runs that math at build time and parks the answer inside a var() fallback:
--fv-btn-color: var(--fv-#{$token}-contrast, #{$text});
/* compiles to */
.btn-primary { --fv-btn-color: var(--fv-primary-contrast, #ffffff); }
.btn-secondary { --fv-btn-color: var(--fv-secondary-contrast, #0b0f1d); }
--fv-{color}-contrast is never defined on :root. The only definitions anywhere in the build are the --fv-primary-contrast each of the five skins sets; for every other color it exists only as the named hole in that var(). Define it on :root and it wins; leave it and you get the compile-time answer for the default palette β white for primary (5.32:1) and dark, #0b0f1d for the other seven.
:root {
--fv-primary: #f59e0b; /* a light amber brand */
--fv-primary-contrast: #1c1917; /* dark text on the fill */
}
You need this exactly when your brand crosses the brightness boundary the default sat on β swap violet for amber and #ffffff goes from readable to not. Nothing warns you at runtime. This is the one judgment call the architecture hands back to a human, so it's explicit rather than magic.
Guarding it in CI
A refactor like this has a sneaky failure mode: someone later writes color: #6d4af5 directly in a component, and it silently stops following the theme. The page still looks right, the tests still pass, and the only symptom is that somebody else's re-brand comes out subtly wrong months later.
So scripts/check-theming.mjs walks the compiled CSS line by line. A line matching /^\s*--fv-[\w-]+:/ is the sanctioned home for a brand literal; anything else containing one of the brand hexes β or its rgb()/rgba() channel form, since Sass sometimes compiles hexes that way β is an offender. Appending one bad rule to dist/farvist.css and running the gate gives:
$ node scripts/check-theming.mjs
β check-theming: 1 baked brand color(s) outside --fv-* definitions:
9266: [primary hex] .demo-regression { color: #6d4af5; }
$ echo $?
1
It runs in .github/workflows/ci.yml right after the bundle-size budget, so runtime theming can't quietly regress.
Being honest about what it isn't: the brand palette is duplicated inside the script, and the script's own comment says so. Change $primary in _variables.scss without updating the BRAND map in check-theming.mjs and the gate keeps passing cheerfully while guarding a color that no longer ships. It catches derivation regressions, which is what it was built for. It is not a general "no hardcoded colors" linter.
Skins: the payoff for doing it properly
Once every derived color resolves from a variable, a whole theme is just a small block of token overrides. scss/base/_skins.scss is 26 lines, of which about a dozen do any work:
@each $skin, $tokens in $skins {
:root[data-theme='#{$skin}'],
[data-theme='#{$skin}'] {
@each $key, $value in $tokens {
@if $key == 'color-scheme' { color-scheme: $value; }
@else { --fv-#{$key}: #{$value}; }
}
}
}
Adding a skin means adding a map entry. synthwave is seven custom properties plus color-scheme:
:root[data-theme=synthwave],
[data-theme=synthwave] {
color-scheme: dark;
--fv-primary: #ec4899;
--fv-accent: #8b5cf6;
--fv-info: #22d3ee;
--fv-primary-text: #f9a8d4;
--fv-primary-contrast: #2b0517;
--fv-body-bg: #12041f;
--fv-surface-solid: #1d0b30;
}
Everything else β gradients, glows, meshes, the ambient orbs, hover states, focus rings β follows, because each of those was already a color-mix() over a var(). The light skin, dawn, compiles to sixteen, because flipping to a light surface also means moving the glass fills, the border ink, the code background and the pattern ink. That's the honest cost of a light theme: three of those tokens are your brand and two are its readable text pairings, and the remaining eleven are the surface it has to sit on.
Semantic colors stay constant across every skin on purpose. A red error should stay red in synthwave.
The second gate, scripts/check-skins.mjs, is the one that earned its keep. It parses the compiled CSS β pulling each [data-theme='β¦'] block and its --fv-* hex tokens β and asserts four relationships per skin at β₯4.5:1: primary-text, muted and body-color against that skin's own body-bg, plus primary-contrast against its primary. Grading the shipped artifact rather than the Sass source means a skin can't pass on paper and fail in the bundle.
Per the v1.4.0 changelog entry, it caught a 4.40:1 miss in dawn before release. dawn is still the tightest skin in the set, and the numbers show why light themes are where this goes wrong:
dawn #c2410c on #faf6f1 = 4.81:1 β 0.31 of headroom
synthwave #f9a8d4 on #12041f = 10.89:1
forest #6ee7b7 on #041410 = 12.38:1
cyber #86efac on #02120a = 13.68:1
noir #e5e7eb on #0a0a0b = 15.98:1
A dark skin has enormous headroom; a light one has almost none.
That gate has blind spots too, and they're worth stating. It skips the built-in light theme by name (it predates skins). It checks four pairs, not every text-on-surface combination in the framework. And its token regex only matches #hex values, so a skin that wrote its primary-text as rgb() or oklch() would be silently skipped rather than failed.
What this doesn't solve
There is no fallback for color-mix(). Farvist's browserslist floor is Chrome/Edge β₯ 111, Firefox β₯ 113, Safari and iOS Safari β₯ 16.4 β and 223 derivations depend on the function. Every one of them also contains a var(), which is what makes the failure mode nasty: a declaration containing var() parses fine and is only checked after substitution, so on an older engine it is invalid at computed-value time β the property doesn't fall back to an earlier declaration, it resets. A soft badge loses its tint entirely rather than getting a worse one. There is also no build flag that gets you back to baked literals β configuring colors through @use 'farvist/scss/farvist' with (β¦) changes which values land in the custom properties, but the derivations stay color-mix(). The floor is the floor.
Debugging costs more. DevTools used to show a hex and a swatch on .btn-primary:hover. It now shows color-mix(in srgb, var(--fv-primary) 92%, white), and answering "why is this the color it is" means checking three places: the component rule, the :root token, and whichever [data-theme] block is currently winning. The bundle did get smaller when we made the change β 20.5 β 20.2 KB gzip at v1.3, because repeated color-mix(in srgb, var(--fv- strings compress far better than scattered hexes, and the full build is 21.3 KB gzip today β but we have not measured a rendering cost either way, so we're not going to claim there isn't one.
Contrast is still human judgment. The gates check what we told them to check. .badge-soft-primary derives its text as a fixed color-mix(in srgb, #ffffff 45%, var(--fv-primary)); 45% is tuned for the default palette and nothing recomputes it when you swap the brand. Re-brand to a pale yellow and that text goes pale on glass, and no CI job will tell you. Runtime theming moves the color. It doesn't move the taste.
Not everything follows you, by design. Shadows are rgba(0, 0, 0, β¦), the glass fills are white at 6β14% alpha, the greys are literal. Glass is a material, not a brand, and tinting it with every re-brand would look worse, not better. So "override one variable and everything changes" is shorthand: what changes is everything derived from the brand tokens.
What it buys
Re-branding the entire framework is five lines of CSS you can paste anywhere β into a CDN-only page, into a Theme Builder, or into a prompt:
:root {
--fv-primary: #10b981;
--fv-accent: #a3e635;
--fv-info: #2dd4bf;
--fv-primary-text: #6ee7b7; /* links + outline buttons */
--fv-primary-contrast: #06281d; /* text on filled buttons */
}
Skins are the same five lines with a name attached and a contrast gate behind them. The broader lesson: every token you can resolve in the browser is a token your users can change without your toolchain β and some of those users are now models, which cannot run your toolchain at all.
You can try it live on the homepage β click a brand and watch the page recolor β or use the Theme Builder to generate the block. The theming docs cover the full token list.