Skip to content
Design

Customizing a Tailwind Theme with Design Tokens: From Purchase to Brand in One Day

Use HSL CSS variables, Tailwind config extensions and a one-day plan to rebrand a premium Tailwind theme with accessible colors, typography and dark mode.

CoodesCoodes Engineering Team 9 min read · 2,067 words
Customizing a Tailwind Theme with Design Tokens: From Purchase to Brand in One Day
Table of contents

You've just bought a premium Tailwind theme. It looks great in the demo, but right now it looks like everybody else's version of it. The work in front of you is turning it into your product: your colors, your type, your logo, your tone, without breaking the responsive layouts and components that made the theme worth buying.

The fastest and most maintainable way to do that is with design tokens: a small set of named values (colors, radii, fonts, spacing) that everything else refers to. Set them up properly and a rebrand becomes a handful of edits. This guide covers the token approach popularized by shadcn/ui, how to wire it into tailwind.config, and a realistic one-day plan for getting from purchase to a branded, accessible site.

What design tokens are (and why they matter)

A design token is a named design decision. Instead of scattering #4F46E5 across 200 components, you define --primary once and refer to it everywhere. Tokens usually come in two tiers:

  • Primitive tokens: raw values such as indigo-600, radius-8 or font-inter.
  • Semantic tokens: values named for their role, such as primary, background, muted-foreground, destructive and ring.

Components should only use semantic tokens. A button uses bg-primary text-primary-foreground, never bg-indigo-600 text-white. That one rule is what makes rebranding and dark mode cheap: you change the value behind primary and every button, link, badge and focus ring follows.

If changing your brand color means a project-wide search and replace, you don't have a design system yet. You have a lot of hard-coded values.

CSS variables with HSL: the shadcn/ui pattern

shadcn/ui made a simple convention popular: define semantic tokens as CSS custom properties that hold bare HSL channel values, then let Tailwind wrap them in hsl(). Keeping the channels bare lets Tailwind's opacity modifiers (bg-primary/80) keep working.

@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  :root {
    --background: 0 0% 100%;
    --foreground: 222 47% 11%;
    --card: 0 0% 100%;
    --card-foreground: 222 47% 11%;
    --primary: 243 75% 59%;
    --primary-foreground: 0 0% 100%;
    --secondary: 210 40% 96%;
    --secondary-foreground: 222 47% 11%;
    --muted: 210 40% 96%;
    --muted-foreground: 215 16% 40%;
    --accent: 172 66% 40%;
    --accent-foreground: 0 0% 100%;
    --destructive: 0 72% 51%;
    --destructive-foreground: 0 0% 100%;
    --border: 214 32% 91%;
    --input: 214 32% 91%;
    --ring: 243 75% 59%;
    --radius: 0.75rem;
  }

  .dark {
    --background: 222 47% 7%;
    --foreground: 210 40% 98%;
    --card: 222 40% 10%;
    --card-foreground: 210 40% 98%;
    --primary: 243 85% 70%;
    --primary-foreground: 222 47% 11%;
    --secondary: 217 33% 17%;
    --secondary-foreground: 210 40% 98%;
    --muted: 217 33% 17%;
    --muted-foreground: 215 20% 70%;
    --border: 217 33% 20%;
    --input: 217 33% 20%;
    --ring: 243 85% 70%;
  }
}

Each surface token comes paired with a -foreground token. That pairing is what lets you check contrast systematically, which we'll come back to later.

Note: Newer versions of shadcn/ui and Tailwind CSS v4 lean towards OKLCH color values and CSS-first configuration through @theme. The ideas here (semantic tokens, paired foregrounds, class-based dark mode) apply either way. Only the syntax changes, so follow whichever convention your theme already uses.

Extending the Tailwind config

With Tailwind v3-style configuration, you map the variables into the theme once, under extend so the default palette is still there for one-off needs:

/** @type {import('tailwindcss').Config} */
module.exports = {
  darkMode: 'class',
  content: ['./app/**/*.{ts,tsx}', './components/**/*.{ts,tsx}'],
  theme: {
    container: { center: true, padding: '1rem', screens: { '2xl': '1280px' } },
    extend: {
      colors: {
        border: 'hsl(var(--border))',
        input: 'hsl(var(--input))',
        ring: 'hsl(var(--ring))',
        background: 'hsl(var(--background))',
        foreground: 'hsl(var(--foreground))',
        primary: {
          DEFAULT: 'hsl(var(--primary))',
          foreground: 'hsl(var(--primary-foreground))',
        },
        secondary: {
          DEFAULT: 'hsl(var(--secondary))',
          foreground: 'hsl(var(--secondary-foreground))',
        },
        muted: {
          DEFAULT: 'hsl(var(--muted))',
          foreground: 'hsl(var(--muted-foreground))',
        },
        accent: {
          DEFAULT: 'hsl(var(--accent))',
          foreground: 'hsl(var(--accent-foreground))',
        },
        destructive: {
          DEFAULT: 'hsl(var(--destructive))',
          foreground: 'hsl(var(--destructive-foreground))',
        },
        card: {
          DEFAULT: 'hsl(var(--card))',
          foreground: 'hsl(var(--card-foreground))',
        },
      },
      borderRadius: {
        lg: 'var(--radius)',
        md: 'calc(var(--radius) - 2px)',
        sm: 'calc(var(--radius) - 4px)',
      },
      fontFamily: {
        sans: ['var(--font-sans)', 'system-ui', 'sans-serif'],
        heading: ['var(--font-heading)', 'var(--font-sans)', 'sans-serif'],
      },
    },
  },
  plugins: [require('@tailwindcss/typography')],
};

In Tailwind v4 the same mapping lives in CSS, with @theme inline blocks that point --color-primary at your variables, and the class-based dark variant is declared with @custom-variant. Check your theme's Tailwind version before you start editing. Mixing the two styles is a common source of "why isn't my color applying?" confusion.

Radius and spacing tokens

A single --radius variable decides much of a brand's feel. Setting it to 0.25rem gives a crisp, enterprise look, and 1rem gives a soft, friendly one. Because md and sm are derived from it, nested elements stay proportional.

Leave Tailwind's spacing scale alone in most cases, since it's well balanced and the theme's layouts depend on it. Tokenize layout-level decisions instead, such as section padding and content width:

:root {
  --section-y: clamp(3rem, 6vw, 6rem);
  --content-max: 72rem;
}
<section class="py-[var(--section-y)]">
  <div class="mx-auto max-w-[var(--content-max)] px-4">...</div>
</section>

Typography tokens

Fonts do a lot of the work in making a theme feel like your brand. With Next.js, next/font self-hosts fonts and exposes them as CSS variables, which slot straight into the token system and avoid layout shift:

import { Inter, Space_Grotesk } from 'next/font/google';

const sans = Inter({ subsets: ['latin'], variable: '--font-sans', display: 'swap' });
const heading = Space_Grotesk({ subsets: ['latin'], variable: '--font-heading', display: 'swap' });

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" className={`${sans.variable} ${heading.variable}`} suppressHydrationWarning>
      <body className="bg-background font-sans text-foreground antialiased">{children}</body>
    </html>
  );
}

Some guidelines:

  • Use no more than two families: one for headings and one for body text. A monospace font for code or wallet addresses is a sensible third.
  • Load only the weights you actually use. Every weight is another file to download.
  • Keep body text at 16px or larger, with a line height around 1.5–1.7 for comfortable reading.
  • Use the @tailwindcss/typography plugin's prose classes for blog and docs content, and customize them through the same tokens so articles match the rest of the site.

Dark and light modes with the class strategy

With darkMode: 'class', dark styles apply whenever an ancestor has the dark class. Since your tokens already switch values under .dark, most components need no dark: variants at all. bg-background just works in both modes.

For Next.js, next-themes handles the details: reading the system preference, saving the user's choice, and injecting a small script that sets the class before first paint so there's no flash of the wrong theme.

'use client';
import { ThemeProvider } from 'next-themes';

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
      {children}
    </ThemeProvider>
  );
}
Tip: Don't just invert your light palette for dark mode. Dark backgrounds work best as very dark, slightly tinted neutrals rather than pure black. Saturated brand colors usually need to be lighter and a little less saturated on dark surfaces to stay readable and comfortable to look at.

Component variants that respect tokens

Most modern Tailwind themes use class-variance-authority (CVA) or a similar helper to define component variants. That's where your brand's personality shows up: the weight of a primary button, how subtle a ghost button is, how much a card lifts.

import { cva, type VariantProps } from 'class-variance-authority';

export const buttonVariants = cva(
  'inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors ' +
    'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ' +
    'ring-offset-background disabled:pointer-events-none disabled:opacity-50',
  {
    variants: {
      variant: {
        default: 'bg-primary text-primary-foreground hover:bg-primary/90',
        secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
        outline: 'border border-input bg-background hover:bg-muted',
        ghost: 'hover:bg-muted hover:text-foreground',
        destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
        brand: 'bg-gradient-to-r from-primary to-accent text-primary-foreground shadow-lg',
      },
      size: {
        sm: 'h-9 px-3',
        md: 'h-10 px-4',
        lg: 'h-12 px-6 text-base',
      },
    },
    defaultVariants: { variant: 'default', size: 'md' },
  }
);

export type ButtonVariants = VariantProps<typeof buttonVariants>;

Add brand-specific variants (like brand above) instead of editing the defaults in place. That keeps the theme's own pages intact and makes future theme updates easier to merge. Also keep the visible focus ring: WCAG 2.2 puts extra emphasis on focus visibility, and keyboard users depend on it.

Brand palette generation and WCAG 2.2 contrast

From one brand color to a full palette

Most brands begin with one or two hex values. To turn them into a working palette:

  1. Convert to HSL (or OKLCH). This makes lightness easy to adjust separately from hue.
  2. Generate a tonal scale (50–950) by stepping lightness, and adjust saturation slightly at the extremes so the lightest tints don't look washed out and the darkest shades don't look muddy. Tools like Tailwind-focused palette generators do this well, but always review the result by eye.
  3. Choose neutrals with a hint of your brand hue. A slightly tinted gray usually looks more cohesive than a pure gray.
  4. Map scale steps to semantic tokens, for example --primary = 600 in light mode and 400 in dark mode.
  5. Keep status colors distinct (destructive, warning, success) and don't let the brand color double as an error color.

Checking contrast

WCAG 2.2 Level AA contrast requirements are the same as in WCAG 2.1:

ContentMinimum ratio (AA)Success criterion
Normal body text4.5:11.4.3 Contrast (Minimum)
Large text (about 24px regular, or about 18.66px bold, and up)3:11.4.3 Contrast (Minimum)
UI component boundaries, icons, focus indicators3:1 against adjacent colors1.4.11 Non-text Contrast

Since every surface token has a paired foreground token, you can test each pair: primary/primary-foreground, muted/muted-foreground, background/foreground, in both light and dark modes. The pairs most often missed are muted-foreground on background, which gets used for secondary text everywhere, and white text on mid-tone brand colors such as bright orange, yellow or teal.

A small script that reads your token file and computes contrast ratios makes this repeatable in CI. Browser devtools and dedicated contrast checkers are fine for spot checks.

Warning: Placeholder text, disabled states and text laid over images or gradients are the usual places where a nicely themed site quietly fails contrast. Check them on purpose, especially hero sections with background imagery.

Logos, favicons and social images

  • Export your logo as SVG and use currentColor fills where possible, so it picks up text-foreground and adapts to dark mode automatically. Otherwise ship separate light and dark variants.
  • With the Next.js App Router, place icon.png or icon.svg, apple-icon.png and favicon.ico in the app directory and the framework generates the right tags. An SVG favicon can even include a prefers-color-scheme media query.
  • Update opengraph-image assets and the web app manifest's theme_color so link previews and mobile browser chrome match the brand.

The one-day plan: purchase to brand

This is a realistic schedule for a developer customizing a well-structured theme such as any of the Next.js templates in our themes catalog. It assumes brand assets (logo, colors, fonts) already exist.

TimeTaskOutput
09:00–09:45Install, run locally, read the theme docs; commit a pristine baselineClean git history to diff against
09:45–10:30Audit how the theme handles tokens; list hard-coded colors (search for hex values and palette classes)Token inventory and a short fix list
10:30–12:00Generate the brand palette; set light and dark semantic tokens; set --radiusBranded globals.css
12:00–12:30Contrast check on every token pair in both modes; adjustAA-passing token set
13:30–14:15Fonts through next/font; heading and body scales; prose stylesTypography applied site-wide
14:15–15:30Component variants: buttons, badges, cards, inputs; replace hard-coded values found earlierConsistent component library
15:30–16:15Logo, favicons, OG images, manifest, metadataBrand assets wired in
16:15–17:00Swap placeholder copy on key pages; remove unused demo pages and sectionsLaunch-ready content skeleton
17:00–17:45QA: mobile breakpoints, dark mode, keyboard focus, Lighthouse runBug list and fixes
17:45–18:00Commit, deploy a preview, share for feedbackPreview URL for stakeholders

A day is realistic for the visual rebrand. Custom features, new page types, CMS integration or smart contract wiring are separate work and should be scoped as such.

Conclusion

Design tokens turn customizing a theme from a slow search-and-replace into a controlled, repeatable process. Define semantic CSS variables, map them into Tailwind once, let class-based dark mode swap the values, express brand personality through component variants, and check every foreground/background pair against WCAG 2.2 AA. With that done, your purchased theme feels like your own product and stays easy to maintain as the brand evolves.

Short on time, or want an expert to handle the rebrand, deployment and QA for you? Our theme customization and installation service covers all of it. Browse the rest of our services or get in touch to plan your launch.

Written by the Coodes Engineering Team

We build premium Web2 & Web3 themes and help teams ship dApps, SaaS platforms and Flutter apps.

Talk to us

Related articles

Ready to Build Something Amazing?

Join thousands of developers who trust our themes for their projects. Professional designs, clean code, and ongoing support.