- Fix Dynamic Tailwind Classes with Class Registries
Fix Dynamic Tailwind Classes with Class Registries
Implement static class registries to map CMS values to Tailwind classes, avoiding build-time parsing errors and…

Need Help Making the Switch?
Moving to Next.js and Payload CMS? I offer advisory support on an hourly basis.
Book Hourly AdvisoryRelated Posts:
I was building a page layout system for a Payload CMS-driven project when I hit a wall. The navbar height was configurable in the CMS, and I needed to apply the correct top padding to page content. My first instinct was to generate a Tailwind class dynamically—something like dynamic class names with template variables. After hours of debugging cryptic CSS parsing errors, I discovered the real problem: Tailwind can't generate classes at runtime because it builds your CSS at development time, not in the browser.
The solution? Class registries—a pattern used by shadcn/ui internally. Instead of generating classes dynamically, you map static CMS values to predefined Tailwind classes at development time. This guide walks you through exactly how to implement this approach.
Why Dynamic Tailwind Classes Fail
Tailwind CSS works by scanning your source code at build time, finding class names like pt-20 or bg-primary, and generating the corresponding CSS. It does this once, during the build process. The browser never sees raw class names—only the final compiled CSS.
When you try to create a class dynamically—for instance, interpolating a CMS value into a class name—Tailwind has no way to detect it during the build. The dynamically generated class name doesn't match any static class pattern, so Tailwind never generates the CSS. Your styles don't apply, and you're left with an element that has no padding.
Even worse, some setups report a parsing error when they encounter invalid arbitrary values like arbitrary brackets with dynamic content. The CSS parser sees a class name that looks valid but contains invalid CSS syntax, causing the entire build to fail.
The root issue: CMS flexibility and Tailwind's build-time generation are fundamentally at odds. You can't bridge this gap by trying to be clever with string interpolation. You need a different approach.
The Class Registry Pattern
A class registry is simple: a static object that maps CMS values to Tailwind classes. It's created at development time when you write your code, so Tailwind can see and process every class name. When your code runs, you look up the appropriate class based on the CMS value, and Tailwind has already generated the CSS for it.
This is exactly how shadcn/ui handles variants, colors, and spacing. It's not a workaround—it's the intended way to handle dynamic styling in a Tailwind project.
The pattern consists of three parts:
- A static registry object that maps values to classes
- A helper function that looks up the class for a given value
- A component or utility that uses the helper function
Let's implement this step by step.
Step 1: Create Your First Class Registry
Start with a simple example: mapping navbar heights to padding classes.
Create a new file:
// File: src/lib/navbar-classes.ts
/**
* Navbar Height to Tailwind Padding Class Mappings
*
* Maps numeric navbar heights (in pixels) to corresponding Tailwind padding-top classes.
* This allows the PageLayout component to apply the correct spacing without generating
* dynamic class names at runtime.
*
* Pattern: navbar height in pixels → Tailwind pt- class
*/
export const NAVBAR_PADDING_CLASSES: Record<number, string> = {
64: "pt-16", // 64px (4rem)
80: "pt-20", // 80px (5rem)
96: "pt-24", // 96px (6rem)
112: "pt-28", // 112px (7rem)
128: "pt-32", // 128px (8rem)
};
/**
* Get Tailwind padding-top class for navbar height
*
* @param heightPx - Navbar height in pixels
* @param fallback - Default class if height not found (defaults to 'pt-20')
* @returns Tailwind padding class ready to use in className
*/
export function getNavbarPaddingClass(
heightPx: number,
fallback = "pt-20",
): string {
return NAVBAR_PADDING_CLASSES[heightPx] || fallback;
}
This file does two things. First, it defines NAVBAR_PADDING_CLASSES as a static mapping. Every key-value pair in this object is a Tailwind class name that Tailwind will see during the build and generate CSS for. The keys are navbar heights in pixels, and the values are the corresponding pt- (padding-top) classes.
Second, it exports a helper function getNavbarPaddingClass() that takes a navbar height and returns the appropriate class name. The fallback ensures that if a height isn't in the registry, you get a sensible default instead of undefined.
Step 2: Use the Registry in Your Component
Now create a component that uses this registry:
// File: src/components/page-layout.tsx
import { getNavbarPaddingClass } from '@/lib/navbar-classes';
interface PageLayoutProps {
children: React.ReactNode;
navbarHeight: number;
isNavbarFixed: boolean;
}
export async function PageLayout({
children,
navbarHeight,
isNavbarFixed,
}: PageLayoutProps) {
// Get the class name based on the navbar height
const paddingClass = isNavbarFixed ? getNavbarPaddingClass(navbarHeight) : '';
return (
<div className={paddingClass}>
{children}
</div>
);
}
The component receives navbarHeight and isNavbarFixed as props. If the navbar is fixed, it calls getNavbarPaddingClass() to get the appropriate class name, then applies it via className. If the navbar is relative (not fixed), it uses an empty string and no padding is applied.
Here's why this works: when Tailwind scans your source code, it finds the static class names pt-16, pt-20, pt-24, etc. in the navbar-classes.ts file. It generates CSS for all of them. Later, at runtime, your component picks the right class name based on the CMS data and applies it to the element. Tailwind's CSS is already there, waiting.
Step 3: Extend to Other Styles
Now that you understand the pattern, extend it to other CMS values. Here's an example for colors:
// File: src/lib/color-classes.ts
/**
* Color to Tailwind Class Mappings
*
* Maps semantic color names (used in your CMS) to actual Tailwind color classes.
* This is the same pattern shadcn/ui uses for its design system.
*/
export const TEXT_COLOR_CLASSES: Record<string, string> = {
white: "text-white",
light: "text-gray-100",
muted: "text-gray-600",
primary: "text-blue-600",
dark: "text-gray-900",
accent: "text-orange-500",
};
export const BG_COLOR_CLASSES: Record<string, string> = {
white: "bg-white",
light: "bg-gray-50",
muted: "bg-gray-100",
primary: "bg-blue-600",
dark: "bg-gray-900",
accent: "bg-orange-500",
};
/**
* Get Tailwind text color class
*/
export function getTextColorClass(
colorName?: string,
fallback = "text-gray-900",
): string {
if (!colorName) return fallback;
return TEXT_COLOR_CLASSES[colorName] || fallback;
}
/**
* Get Tailwind background color class
*/
export function getBgColorClass(
colorName?: string,
fallback = "bg-white",
): string {
if (!colorName) return fallback;
return BG_COLOR_CLASSES[colorName] || fallback;
}
Now when your CMS sends back a color name like "primary", you pass it to getBgColorClass() and get back 'bg-blue-600'—a static class name that Tailwind has already processed.
Use it in a component:
// File: src/components/hero.tsx
import { getBgColorClass, getTextColorClass } from '@/lib/color-classes';
interface HeroProps {
title: string;
bgColor?: string; // From CMS: "primary", "dark", etc.
textColor?: string; // From CMS: "white", "light", etc.
}
export function Hero({ title, bgColor, textColor }: HeroProps) {
const bgClass = getBgColorClass(bgColor);
const textClass = getTextColorClass(textColor);
return (
<section className={`${bgClass} ${textClass} py-20`}>
<h1>{title}</h1>
</section>
);
}
When the CMS provides bgColor: "primary" and textColor: "white", the component resolves these to 'bg-blue-600 text-white' and applies them via className. Tailwind has already generated CSS for both classes.
Real-World Application: Navbar Configuration System
Here's how this pattern solves the original problem at scale. If you're building a flexible navbar system where heights and colors are configurable per route or page:
// File: src/lib/navbar-utils.ts
import { getNavbarPaddingClass } from "@/lib/navbar-classes";
import { getBgColorClass } from "@/lib/color-classes";
interface NavbarConfig {
isFixed: boolean;
height: number;
bgColor?: string;
}
export function resolveNavbarConfig(cmsData: NavbarConfig) {
const paddingClass = cmsData.isFixed
? getNavbarPaddingClass(cmsData.height)
: "";
const bgClass = getBgColorClass(cmsData.bgColor);
return {
paddingClass,
bgClass,
isFixed: cmsData.isFixed,
};
}
Then in your page layout:
// File: src/components/page-layout.tsx
import { resolveNavbarConfig } from '@/lib/navbar-utils';
interface PageLayoutProps {
children: React.ReactNode;
navbarConfig: NavbarConfig;
}
export function PageLayout({ children, navbarConfig }: PageLayoutProps) {
const { paddingClass, bgClass } = resolveNavbarConfig(navbarConfig);
return (
<div className={paddingClass}>
<nav className={bgClass}>
{/* Navbar content */}
</nav>
{children}
</div>
);
}
The CMS can now control navbar height and background color completely, and your component applies static Tailwind classes that were generated at build time. No dynamic class generation, no parsing errors, no runtime styling surprises.
Why This Is the Correct Pattern
Class registries aren't a workaround. They're the standard approach because they align with how Tailwind works. When you look at shadcn/ui components, you'll find this exact pattern throughout the codebase. They define static class mappings, use them consistently, and let Tailwind's build process handle everything.
The benefits:
Type Safety: Your registries are TypeScript objects. TypeScript catches typos in color names or missing height values.
Consistency: All styling goes through the same registry functions. You can't accidentally apply inconsistent classes.
Maintainability: Want to change a color or spacing value? Update one place in the registry. All components that use it automatically reflect the change.
Performance: No runtime style calculation. The class name lookup is a simple object property access.
CMS Independence: Your registries live in code, not in the CMS. Your CMS only provides semantic values ("primary", "dark", etc.), not raw CSS.
Conclusion
Dynamic Tailwind classes seem like they should work, but they create a fundamental conflict with Tailwind's build-time CSS generation. Class registries solve this by moving the mapping from runtime to development time. You define all possible class combinations upfront in static objects, Tailwind generates CSS for all of them, and at runtime your component simply looks up the right class for the given CMS value.
This pattern scales beautifully. Whether you're mapping two values or twenty, adding new registries is straightforward. Start with one (like navbar heights), test it, then extend it to colors, spacing, typography, or any other CMS-driven style. You're building a design token system—the same approach that powers production design systems like shadcn/ui.
By the end of implementing this, you'll have a flexible, type-safe styling system that works seamlessly with your headless CMS, and you'll never hit a dynamic class generation error again.
Let me know in the comments if you run into edge cases or have questions about applying this to your specific setup, and subscribe for more practical development guides.
Thanks, Matija
📚 Comprehensive Payload CMS Guides
Detailed Payload guides with field configuration examples, custom components, and workflow optimization tips to speed up your CMS development process.


