Variable layers
Krysalicss separates design values into three strict layers. Knowing which layer a value belongs to is the difference between a token that themes can re-tune and a hardcoded literal that locks every consumer into one look.
The three layers
| Layer | What lives here | Where it lives | Who sets it |
|---|---|---|---|
| Declarative | Raw values: hex codes, brand-specific pixel constants, named font stacks | src/theme/<name>.scss (and the consumer's own theme files); src/variables/_color.scss for the shipped reference palette | The theme author |
| Semantic | Named, role-bearing tokens: $global-border-radius, $global-min-touch-target, $size-normal, $color-combination-default | src/variables/_global.scss, _size.scss, _color.scss, _selector.scss | The framework |
| Runtime | --kc-* custom properties: --kc-bg, --kc-fg, --kc-primary-bg | Emitted by src/theme/_create.scss under :root / @media / class selectors | The framework, populated by the active theme |
Each layer depends on the one above it. Declarative feeds semantic; semantic feeds runtime; runtime drives every component. The dependency is one-way; a lower layer never reaches up.
Declarative variables (theme-local)
A declarative variable is a raw value with no context. #247d59, the
hex of Krysalicss's brand emerald, doesn't mean anything on its own. It
gets meaning when a theme assigns it to --kc-primary-bg. That
assignment is the entire job of the theme layer.
// src/variables/_color.scss — the shipped palette (theme material).
$palette: (
sage: #1a6347,
moss: #525a22,
emerald: #247d59,
amber: #7a4f10,
coral: #8c351a,
white: #ffffff,
black: #27282b,
) !default;
// src/theme/default.scss — wires declarative values to the runtime contract.
$variables: (
bg: map.get($_palette, white),
fg: map.get($_palette, black),
link: map.get($_palette, sage),
focus-ring: map.get($_palette, sage),
// …
);
Where declarative values must NOT live: anywhere under
src/<category>/<name>/_variables.scss. Hardcoding #247d59 inside a
button's variables file locks every theme into the brand green. Even a
borderless gray like #ccc is a declarative value and belongs in a
theme; if a component needs "border in the page foreground tinted at
35%", that's a semantic concern (see below).
Semantic variables (framework-wide)
A semantic variable describes what a value is for, not which value:
$global-border-radius— the default rounding of any rectangular surface.$global-min-touch-target— the AAA-compliant minimum tap area (2.75rem).$global-gap— the cross-cutting spacing primitive between siblings.$global-hover-brightness— the multiplier applied to interactive surfaces on hover.$size-normal,$size-small,$size-large— the size tier scale.$color-combination-default,$color-combination-primary— the (background, foreground) tuples per named role.$selector-prefix-is,$selector-prefix-has— the modifier prefixes every component interpolates.
Plugins consume these tokens by name. Re-tuning a single semantic value
cascades to every component that derives from it. If a consumer wants
fatter buttons everywhere, they bump $global-min-touch-target; if they
want square corners, they bump $global-border-radius to 0. They do
not edit each component's _variables.scss.
This layer is the cornerstone of the framework's tunability. Themes
can override semantic tokens too (a "compact" theme can ship a
smaller $global-min-touch-target), but the typical pattern is theme =
declarative, framework = semantic.
Runtime tokens (--kc-* custom properties)
The third layer crosses from compile-time to runtime. Themes emit a
block of --kc-* custom properties under :root (or a media query, or
a class selector). Components reference those properties via three
mixins from src/variables/_default.scss:
textColor($key, $fallback)— paintscolor: var(--kc-<key>, <fallback>).backgroundColor($key, $fallback)— paintsbackground-color: var(--kc-<key>, <fallback>).defaultProperty($prop, $key, $fallback)— the general case.
The fallback is the SCSS-time value; the var() call is the runtime
value. When the active theme changes (class swap, media query flip),
every --kc-* reference updates simultaneously. The plugin doesn't
re-run; the browser just resolves the new value.
// Good — theme-switchable
.is-primary {
@include variables.backgroundColor(primary-bg, list.nth($combo, 1));
@include variables.textColor(primary-fg, list.nth($combo, 2));
}
// Bad — bakes the colour at SCSS compile time, no theme can override
.is-primary {
background: #247d59;
color: #ffffff;
}
Authoring discipline
Three rules keep the layers apart, and breaking them is the most common authoring mistake:
1. Per-component variables derive from globals
A _variables.scss file under src/<category>/<name>/ should look like
a translation table from per-component knobs to global tokens. Literals
appear only when no global covers the concept.
// Good — every value derives from the framework
@use "../../variables";
$padding-x: variables.$size-normal !default;
$border-radius: variables.$global-border-radius !default;
$focus-outline-width: variables.$global-focus-outline-width !default;
$default-combination: variables.$color-combination-default !default;
// Bad — magic numbers that drift from the framework
$padding-x: 1rem !default;
$border-radius: 5px !default;
$focus-outline-width: 2px !default;
If three components hardcode the same literal, that literal is a missing global. Hoist it.
2. Calculation over duplication
When two values are mathematically related, write the relationship. Krysalicss's button is the canonical example — its vertical padding is derived from the touch-target floor minus the line-height, so the AAA invariant holds even after consumers re-tune the inputs:
@use "sass:math";
@use "../../variables";
@function _touch-target-padding($target, $line-height-rem) {
@return math.max(0rem, math.div($target - $line-height-rem, 2));
}
$padding-y: _touch-target-padding(
variables.$global-min-touch-target,
1rem * variables.$global-body-line-height
) !default;
Hand-computed magic numbers ($padding-y: 12px; written next to a
comment "value that hits 44 px height") are a smell — the comment will
rot when someone retunes the inputs.
3. Theme-switchable values go through the runtime mixins
The default theme works fine when you hardcode color: #fff. The dark
theme doesn't. Routing every theme-switchable value through
textColor / backgroundColor / defaultProperty is the only way the
component stays correct under every shipped and consumer-authored theme.
Why this matters
The reward for keeping the layers separate is one tunable framework that serves every brand. A consumer can:
- Re-tune the entire visual identity by editing one theme file (declarative layer).
- Re-shape the framework's geometry (padding scale, radii, touch-target floor) by editing one semantic file.
- Ship a runtime theme toggle (light / dark / brand variants) with no recompile.
Conflate the layers — hardcode a hex in a component's variables, bake a literal into a theme, hardcode a CSS class name — and one of those abilities breaks for every downstream user.
Further reading
- Architecture overview — the SCSS module graph that lets each layer stay leaf-only.
- Recommendation: variable typography — applies the same discipline to typeface choice.
- Theme contract reference — the surface a theme must satisfy.