Tailwind Custom Colors — tailwind.config Gotchas
Design hands you #0F766E and #CCFBF1. You add bg-brand locally; it works. Production CSS has no .bg-brand. Or worse: you replaced theme.colors and suddenly text-gray-500 vanished from the whole app. Custom colors in Tailwind are easy — until content detection and config shape disagree with how you write class names.
Extend, do not clobber
// tailwind.config.js (v3-style)
module.exports = {
content: ["./src/**/*.{html,js,ts,jsx,tsx,astro}"],
theme: {
extend: {
colors: {
brand: {
50: "#f0fdfa",
500: "#0f766e",
700: "#115e59",
},
},
},
},
};
theme.colors = { brand: … } replaces the default palette. theme.extend.colors merges. Most teams want merge.
In Tailwind v4, tokens often live in CSS with @theme. The same mental model applies: add tokens deliberately; do not wipe the built-in scale unless you mean to.
The dynamic class trap
// Dangerous — scanner may never see bg-brand-500
const shade = "500";
return <div className={`bg-brand-${shade}`} />;
JIT/content scanning looks for complete class strings in source. String concatenation hides them. Fixes:
- Map to full class names:
const map = { 500: "bg-brand-500" } - Use safelist sparingly for truly dynamic sets
- Prefer CSS variables:
style={{ background: "var(--brand-500)" }}with utilities that reference the variable
Tokens that survive dark mode
Brand colors as raw hexes in config work until marketing wants runtime themes. Pattern that scales:
:root {
--color-brand-500: 15 118 110; /* RGB channels for /opacity support */
}
colors: {
brand: {
500: "rgb(var(--color-brand-500) / <alpha-value>)",
},
}
Then swap variables under .dark without regenerating every utility name.
From Figma hex dump to a usable scale
Designers often send five hexes, not a 50–950 ladder. You still need names that match how utilities are written.
| Need | Approach |
|---|---|
| One accent | brand / brand-DEFAULT plus brand-soft |
| Full UI scale | Generate or hand-map 50–900; keep 500 as the primary |
| Match Tailwind gray | Keep default zinc/slate; only add brand |
Use a Tailwind color / palette helper to move between hex and named scales before editing config. Consistency beats inventing brandPrimaryDarkest mid-sprint.
Content paths and monorepos
Missing colors in prod also happen when content omits a package:
apps/web/src/** ✓
packages/ui/src/** ← forgot this; Button with bg-brand never scanned
After adding a color, search the repo for the full utility string and confirm it appears in built CSS (bg-brand-500 in the output file).
Checklist before you merge a palette PR
- Colors live under
extend(or v4@themeadditions), not a full wipe. - Every utility used in JSX exists as a complete literal somewhere the scanner reads.
contentglobs cover all packages that reference the utilities.- Dark mode strategy is variables or paired classes — not “we’ll remember to tweak hexes later.”
- Document the canonical 500 (or DEFAULT) for designers so Figma and code stay aligned.
Custom colors fail in production for boring reasons: config shape, purge/content, and dynamic strings — not because Tailwind “hates brand.” Wire tokens once, keep class names complete, and convert hexes into a scale you can reuse across components.
Naming that survives handoff
Call the primary step brand-500 (or brand-DEFAULT) and keep lighter/darker steps symmetric so designers can speak in the same numbers as utilities. Avoid one-off names like brandPrimaryDarkestHover that never map to a scale. When Figma exports a flat list of hexes, spend ten minutes assigning them onto 50–900 before writing config — the conversion step prevents almost-the-same teal from proliferating across components. Revisit the scale when dark mode lands; sometimes you need paired tokens rather than inverting hexes blindly.