Skip to content
HomePagesHomePages template kit
Menu

template-kit/no-hex

The template-kit/no-hex authoring rule — what it enforces, why, and how to fix it.

No hard-coded colours in section code. Islands are not exempt — this is the one rule that holds on both sides of the hydration boundary.

This rule ships as a warning, not an error: a section spending theme tokens is recommended, not gated. A hard-coded colour squiggles in your editor, but it does not fail template-kit check (the check gates on errors only, so a warning never reaches it) and so does not block ingest either. The guidance below is still the right thing to do — it only changes what the linter does with a violation, not whether the violation is worth fixing.

The rule flags two shapes:

  • A CSS colour function that bakes a colour, anywhere in a string or template literal: oklch(…), oklab(…), rgb(…), rgba(…), hsl(…), hsla(…), color-mix(…). No context test — nobody spells an anchor id rgb(...). One carve-out: a color-mix() whose colour arguments are all theme-derived is legal — see below.
  • A hex literal (3, 4, 6, or 8 digits) in a colour context. A colour context is a Tailwind arbitrary-value bracket that resolves to a colour:
    • a colour property — [color:#fff], [background:#fff], [background-color:#fff], and the rest of border-color, outline-color, box-shadow, text-shadow, fill, stroke, caret-color, accent-color, text-decoration-color;
    • any custom property — [--brand:#ff0000];
    • a colour-utility prefix, with variants stripped — bg-[#f00], hover:bg-[#f00], md:dark:text-[#f00], border-t-[#000], and the same for ring, outline, shadow, fill, stroke, decoration, divide, accent, caret, placeholder, from, via, to;
    • or anywhere inside a JSX style attribute.

Everywhere else, a #-prefixed string is left alone: href="#cafe" is an anchor, and w-[#…] is not a colour utility. #cafe, #facade, #decade, and #beef are real anchor ids that happen to be spelled from hex digits.

Every hex-shaped token in a string is checked, not just the first — an out-of-context token early in a className cannot shadow a genuine colour later in it.

A color-mix() carries no palette of its own when every one of its colour arguments is theme-derived — a var(--…) reference, transparent, currentColor, or a nested color-mix() judged on these same terms. It composes a colour out of the theme the section was handed, so it is as theme-agnostic as bg-surface and cannot break when another template reuses the section. It is legal:

hover:bg-[color-mix(in_srgb,var(--tr-color-surface)_92%,var(--tr-color-ink))]
bg-[color-mix(in_srgb,var(--tr-color-ink)_50%,transparent)] ← a translucent tint:
overlay, scrim, hover wash

A mix weight (92%) is not a colour; only the colour arguments are tested.

transparent and currentColor are the only palette-free keywords: transparent names the absence of a colour, and currentColor defers to the inherited (theme-driven) color. Every other CSS named colour (white, black, rebeccapurple, …) is a baked palette exactly as a hex is. So these report:

color-mix(in srgb, var(--tr-color-primary) 80%, #fff) ← a hex literal
color-mix(in srgb, var(--tr-color-primary) 80%, white) ← a CSS named colour
color-mix(in srgb, var(--tr-color-accent, #fff) 80%, …) ← a var FALLBACK is a baked
colour the moment the
property is undefined

The carve-out is color-mix() only — it is the one colour function that composes a colour rather than stating one. The other six take raw channel values, so they bake a colour by construction: rgb(0 0 0), oklch(0.7 0.1 20), and even rgb(var(--r) var(--g) var(--b)) report.

A section carries zero palette of its own. It is theme-agnostic, and must look right under every template’s theme — so a baked colour breaks the first time another template reuses the section. Keeping colour in tokens is what makes a section portable rather than a one-off.

This is why islands get no exemption. An island is exempt from the effect, state, handler, and inline-style bans because those are server-render concerns and an island runs in the browser. A palette is not a render-path concern: the hydration boundary is not a palette boundary.

Use one of your theme’s colour utilities. If the value is genuinely brand decoration with no token yet, add it to colors in the template’s theme.ts — the key lagoon compiles to --tr-color-lagoon and the bg-lagoon / text-lagoon utilities — which keeps the value in one place instead of in one section.

If you need a colour the theme does not name — a hover, active, or pressed step — derive it from the tokens the theme does name with color-mix(), rather than reaching for a literal to lighten or darken with.

import { Section } from "@homepages/template-kit";
import type { Props } from "./schema.js";
export function Renderer({ slots }: Props) {
return (
<Section>
<div className="bg-[#f5f5f5]">
<h2 style={{ color: "#1a1a1a" }}>{slots.headline}</h2>
</div>
</Section>
);
}
import { Section } from "@homepages/template-kit";
import type { Props } from "./schema.js";
export function Renderer({ slots }: Props) {
return (
<Section>
<div className="bg-surface-alt">
<h2 className="text-ink">{slots.headline}</h2>
</div>
</Section>
);
}

Lightening a hover step by mixing in a hard-coded white is both the violation and the bug: under a dark theme it lightens a button the theme wanted dark. Mix toward a colour the theme names, and the step follows whatever theme the section is handed.

// Before — `#fff` is a palette this section invented.
<button className="bg-primary hover:bg-[color-mix(in_srgb,var(--tr-color-primary)_80%,#fff)]">
{slots.cta}
</button>
// After — every colour argument is a theme var.
<button className="bg-primary hover:bg-[color-mix(in_srgb,var(--tr-color-primary)_80%,var(--tr-color-surface))]">
{slots.cta}
</button>
  • Theme and CSS — the token system, and how a colors entry becomes a utility.
  • Server vs client — why the other bans stop at the hydration boundary and this one does not.