Tailwind Custom Colors — tailwind.config Gotchas

webdev tailwind css design-tokens tools

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.

NeedApproach
One accentbrand / brand-DEFAULT plus brand-soft
Full UI scaleGenerate or hand-map 50–900; keep 500 as the primary
Match Tailwind grayKeep 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

  1. Colors live under extend (or v4 @theme additions), not a full wipe.
  2. Every utility used in JSX exists as a complete literal somewhere the scanner reads.
  3. content globs cover all packages that reference the utilities.
  4. Dark mode strategy is variables or paired classes — not “we’ll remember to tweak hexes later.”
  5. 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.