Tailwind CSS Arbitrary Values in Dynamic Tenant Theme Systems: Generating Color Utilities Without PostCSS Plugin Hell
I've shipped dynamic branding for 500+ tenants at CitizenApp, and I've made every mistake in the book. I tried CSS-in-JS. I built a custom PostCSS plugin that generated 15,000 utility classes per build. I even considered a full theme compilation step before deployment.
Then I realized Tailwind's arbitrary value system combined with CSS custom properties solves this problem so elegantly that I almost feel silly explaining it. But here's the thing: most developers don't know this approach exists, and they're overcomplicating their tenant theming.
The Problem With Traditional Approaches
When you have 500 tenants, each with custom brand colors, you have three bad options:
Option 1: CSS-in-JS (styled-components, Emotion)
Runtime overhead. Bundle bloat. Slower initial renders. I saw a 40% performance regression in CitizenApp's dashboard when we tried this for tenant theming.
Option 2: Dynamic class generation at build time
You generate thousands of Tailwind utilities (bg-tenant-acme-primary, text-tenant-acme-secondary) during your build step. This works but creates massive CSS files (we hit 2.8MB uncompressed) and makes your build process fragile. One tenant's color breaks the whole pipeline.
Option 3: CSS variables alone
You could just use raw CSS variables, but you lose Tailwind's opacity modifiers, dark mode support, and the developer experience of Tailwind's naming system.
The right answer is Tailwind arbitrary values + CSS custom properties. And I'm genuinely surprised this isn't more common.
How Arbitrary Values Actually Work
Tailwind 3+ lets you use square bracket syntax to generate arbitrary utilities on the fly:
<div className="bg-[#ff0000] text-[rgb(0,255,0)]">
This works without any build configuration
</div>
The magic here is that Tailwind uses regex scanning to find these arbitrary values at build time, then generates the CSS. This happens during the normal Tailwind build, not in some separate plugin.
But here's what most developers miss: you can put CSS variable references inside those brackets:
<div className="bg-[var(--tenant-primary)] text-[var(--tenant-secondary)]">
This actually works perfectly
</div>
Tailwind will generate:
.bg-\[var\(--tenant-primary\)\] {
background-color: var(--tenant-primary);
}
No PostCSS plugins. No theme generation. Just CSS variables doing what they were designed to do.
The Architecture
Here's how I structure this at CitizenApp:
1. Define your tenant colors at runtime (FastAPI backend)
from fastapi import FastAPI
from sqlalchemy import Column, String
from sqlalchemy.orm import declarative_base
app = FastAPI()
Base = declarative_base()
class Tenant(Base):
__tablename__ = "tenants"
id = Column(String, primary_key=True)
name = Column(String)
brand_primary = Column(String, default="#3B82F6")
brand_secondary = Column(String, default="#1E40AF")
brand_accent = Column(String, default="#F59E0B")
@app.get("/api/tenant/{tenant_id}")
async def get_tenant(tenant_id: str):
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
return {
"id": tenant.id,
"name": tenant.name,
"colors": {
"primary": tenant.brand_primary,
"secondary": tenant.brand_secondary,
"accent": tenant.brand_accent,
}
}
2. Inject as CSS custom properties (React component)
import { useEffect, useState } from 'react';
interface TenantColors {
primary: string;
secondary: string;
accent: string;
}
export function TenantThemeProvider({ tenantId, children }: {
tenantId: string;
children: React.ReactNode
}) {
const [colors, setColors] = useState<TenantColors | null>(null);
useEffect(() => {
fetch(`/api/tenant/${tenantId}`)
.then(res => res.json())
.then(data => {
setColors(data.colors);
// Inject CSS custom properties into document root
Object.entries(data.colors).forEach(([key, value]) => {
document.documentElement.style.setProperty(
`--tenant-${key}`,
value as string
);
});
});
}, [tenantId]);
return <>{children}</>;
}
3. Use arbitrary values in your components
export function BrandButton({ children }: { children: React.ReactNode }) {
return (
<button className="
px-4 py-2
rounded-lg
bg-[var(--tenant-primary)]
hover:bg-[var(--tenant-secondary)]
text-white
font-semibold
transition-colors
">
{children}
</button>
);
}
That's it. No build step modifications. No dynamic class generation. The CSS custom properties change at runtime, and Tailwind handles the arbitrary value syntax during a normal build.
Why This Beats Everything Else
Build performance: Your build step is unchanged. You're not generating thousands of classes. Build time stays sub-second. At CitizenApp, our build dropped from 8s to 1.2s when we switched to this approach.
Bundle size: We went from 2.8MB CSS (uncompressed) with dynamic generation to 142KB. Opacity modifiers work automatically because Tailwind handles the arbitrary value compilation.
Dark mode support: CSS variables work perfectly with Tailwind's dark mode:
<div className="
bg-[var(--tenant-primary)]
dark:bg-[var(--tenant-primary-dark)]
">
Automatic dark mode support
</div>
Runtime flexibility: Colors change instantly without redeploying. Tenants can update their branding in your admin panel, and it's live immediately.
Developer experience: You still get IntelliSense for Tailwind classes, and the arbitrary values are explicit and searchable.
Gotcha: CSS Variable Fallbacks
One thing that burned me early: if a CSS variable isn't defined when the page loads, you get nothing. Tailwind doesn't generate fallbacks automatically.
// DON'T do this - if --tenant-primary isn't set, background is invisible
bg-[var(--tenant-primary)]
// DO this - provide a fallback
bg-[var(--tenant-primary,#3B82F6)]
Also, if you're hydrating on the server side (Astro, Next.js with SSR), inject your CSS variables in the HTML head before React mounts:
---
// In your Astro layout
const tenant = await getTenant(Astro.params.tenantId);
---
<html>
<head>
<style>
:root {
--tenant-primary: {tenant.brand_primary};
--tenant-secondary: {tenant.brand_secondary};
--tenant-accent: {tenant.brand_accent};
}
</style>
</head>
<body>
<!-- Your app -->
</body>
</html>
This prevents the flash of unstyled content when JavaScript loads.
The Takeaway
Don't overthink tenant theming. Tailwind's arbitrary value syntax + CSS custom properties is a complete solution that's simpler than the alternatives. Your build stays fast, your bundle stays small, and colors update at runtime.
I wish I'd known this before burning a week on PostCSS plugin development. Now it's my default approach for any multi-tenant color system.
Top comments (0)