Theme colours
Everything is generated from the $colors map — a family name plus a seed colour. Each family gives you a numbered scale (--jf-color-primary-500 …), semantic role aliases, paired text colours, a button / chip / callout / progress / table variant, a .bg-* surface, and a .text-* class (its 500).
Add a family — $colors: ("brand": #0b7) — and all of that appears for it. Contrast pairs are WCAG-checked at build time.
Semantic role aliases
Alongside the numbered scale, every family in $colors emits five semantic aliases so you can reference a step by intent instead of guessing a number:
| Alias | Default shade | Use for |
|---|---|---|
| --jf-color-{family} | 500 | The colour itself. |
| --jf-color-{family}-subtle | 50 | Faint tinted backgrounds. |
| --jf-color-{family}-muted | 300 | Borders, low-emphasis fills, visited links. |
| --jf-color-{family}-emphasis | 700 | Hover & active states, heading tone. |
| --jf-color-{family}-strong | 900 | Text on a tint, strongest headings. |
On primary:
Each alias is a live var() reference to the numbered token, so a runtime override of a shade flows through to its aliases. Retune which shade a role points at — for the whole framework — with the $color-role-shades map; from SCSS, reference an alias with get-color-role($family, $role) (or role-shade($role) for the raw number).
.promo {
border: 1px solid get-color-role(primary, muted);
background: get-color-role(primary, subtle);
}
.promo h3 { color: get-color-role(primary, strong); }
Background modifiers
A .bg-{family} class is generated for every family in $colors — .bg-primary, .bg-neutral, … each painting that family at its 500 value. Put one on any element to theme it as a surface; don't set the tokens by hand.
Three neutral surfaces sit alongside the families and flip with the theme:
- .bg-base — the page canvas itself. Put it on an element inside a .bg-* section to reset it back to the body surface (background, text, headings and the muted / subtle tiers).
- .bg-soft — a little elevation off the canvas, not emphasised. Literal white in light (a gentle lift against the warm off-white body), a raised dark tint in dark. This is what a panel, callout, chip or modal reaches for when it wants to read as slightly raised without taking a theme colour.
- .bg-inverse — a full reverse of the page: the opposite theme's canvas, so it reads as a deliberate high-contrast block (dark in light mode, light in dark mode), with its text and headings following.
Each surface is a background token plus its paired text / heading tokens: --jf-color-bg + --jf-color-text-base / --jf-color-headings-base for the canvas (the plain --jf-color-text / -headings alias the -base pair); --jf-color-bg-soft + -text-soft / -headings-soft; and the --jf-color-*-inverse set. Set the soft pair per project with $soft-background-color / $dark-soft-background-color.
For a specific shade or a one-off colour, add an entry to the $background-colors map — key is the class suffix, value is a get-color() reference or a raw CSS colour. Reusing a family name as the key overrides its auto class.
$background-colors: (
"primary-tint": get-color(primary, 100),
"brand": #412d1f,
);
// -> .bg-primary-tint, .bg-brand
Each modifier sets the background and picks text and heading colours that stay readable on it. The lighter .text-muted and .text-subtle shades re-mix to suit the new background too, and anything nested inside — headings, cards, callouts, muted text — picks all of this up on its own:
Heading
Muted text
Subtle text
Heading
Muted text
Subtle text
Heading
Muted text
Subtle text
Headings use your normal heading colour ($color-headings-preferred, see Variables) where it stays readable, switch to white on dark backgrounds, and only fall back to a plainer colour when neither works. A modifier built from a palette shade reuses colours the framework has already worked out for that shade, so it follows any later palette override; a one-off colour has its contrast fixed at build time.
Surfaces nest — each one re-establishes its own text, heading and muted / subtle tiers for everything inside it, however deep:
Text modifiers
Where a .bg-* class themes a whole surface, these colour text alone. All are !important.
| Class | Colour | Flips in dark? |
|---|---|---|
| .text-{family} | That family's 500 — one class per family in $colors. | No — it's the numbered primitive, which is the same in both modes. |
| .text-muted | --jf-color-text-muted — secondary content, captions. | Yes — re-mixes against the current surface. |
| .text-subtle | --jf-color-text-subtle — hints, placeholder-level text. | Yes — as above, mixed further. |
| .text-soft | --jf-color-text-soft — the text colour paired with .bg-soft. For text sitting on a soft panel you've painted yourself. | Yes. |
.text-primary · .text-secondary · .text-neutral · .text-success · .text-warning · .text-error · .text-muted · .text-subtle
Dark mode
Dark mode is a class on <html>, not light-dark(). .jf-dark or .jf-light forces a choice and wins over everything else; failing that, data-default-theme sets the site's default; failing that, the OS prefers-color-scheme decides.
Only the semantic tokens and the role aliases above are repointed in dark — the numbered primitives (--jf-color-primary-500 …) and their build-time --jf-color-on-* companions are untouched. So a .bg-primary section and a .button.error look the same in both modes, while surfaces, body text, borders and links flip. Which numbered shade each role points at once dark is set by $dark-color-role-shades (backgrounds move toward the dark end, accent text toward the light end).
Wiring it up
Add the colour-scheme hint and a pre-paint script to your <head> so a stored preference applies before the first paint (no flash):
<meta name="color-scheme" content="light dark">
<script>
(function () {
try {
var t = localStorage.getItem("jf-theme");
if (t === "dark" || t === "light")
document.documentElement.classList.add("jf-" + t);
} catch (e) {}
})();
</script>
Jellyfish's theme.js (bundled) keeps the class in sync after load, follows the OS while no explicit choice is stored, and exposes window.JellyfishTheme:
JellyfishTheme.get(); // 'dark' | 'light' (resolved)
JellyfishTheme.set('dark'); // force + persist
JellyfishTheme.set('system'); // clear the stored choice, follow the OS
JellyfishTheme.toggle(); // flip between dark and light
Any [data-theme-toggle] element toggles; [data-theme-set="dark|light|system"] sets a specific mode. A jfThemeChange event fires on <html> with detail.theme. Set $dark-mode-enabled: false to drop the dark block from the compiled CSS.
Non-token overrides
Reach for a semantic token first — that's what flips automatically. For the rest (an invert() filter on a logo, swapping a background image, anything a token can't express — useful once Jellyfish is compiled into a WordPress or Drupal theme alongside markup it doesn't own), nest @include in-dark-mode / @include in-light-mode inside the selector that needs it. Each resolves the same three-way precedence as the :root block, scoped to that selector:
.wp-block-logo {
@include in-dark-mode {
filter: invert(1);
}
}
With $dark-mode-enabled: false, in-dark-mode compiles to nothing and in-light-mode's content applies unconditionally — light is the only mode.