Tailwind CSS has done wonders for development — it can get you up and running in a matter of minutes. It contains the right building blocks out of the box with options to customize just about anything throughout the system.
If you’ve never built a design system from scratch, it’s easy taking something like Tailwind CSS for granted, especially when it comes to setting up a type-scale, spacing grid, and colors (where Tailwind truly shines). Tailwind’s world-class designers were meticulous when choosing the right color hues and shades capable of making just about anything look great.
Sometimes, you could be given a design with brand colors you could extend in Tailwind or override default colors with anything you’d like when you know those values upfront.
But what happens when you’re not in control of the exact colors that end up in the user’s browser? What if the colors come from the backend or are dynamic and controlled via user input? Do you resort back to doing inline styles? Or perhaps generate separate CSS styles for those use cases outside of Tailwind?
I hope not! I’m here to show you there’s a better way, a solution that’s native to the way Tailwind works, and its extensible API allows us to push it further than the defaults we get out of the box.
The Replay is a weekly newsletter for dev and engineering leaders.
Delivered once a week, it's your curated guide to the most important conversations around frontend dev, emerging AI tools, and the state of modern software.
I’m going to showcase how I solved a real-world use case with a tiny SaaS product I built called NodCards. The concept is simple — it’s your digital business card. With NodCards, your details can be featured on a personal landing page, shared in your email signature, a link-in-bio on social media, or any other place you’d like to share your information.
NodCards is a good example because the user can choose any color as the primary design color, which requires NodCards to adapt dynamically. I was set on using Tailwind CSS for the styling, so how did I do this?
With this context in mind, and knowing how Tailwind classes work, we want to target the text color text-{primary} for the person’s name, as well as a background color bg-{primary} for the buttons, and maybe a bit more for hover effect.

Your first thought might be, thank goodness for the just-in-time (JIT) compiler, which can dynamically compose styles like bg-[#6231af]. But I would argue that it’s not a real contender for what we have to do here, as you’ll soon see why.
I had a few considerations and requirements to make this work for my use case:
Based on these criteria, I put together a short demo of the project. To help visualize, I made the demo interactive by adding a color picker to simulate the dynamic changing of color in the UI. As you can see, it changes instantly without affecting the stylesheet and/or mark-up.
Let’s see how we achieved this.
The best approach I found was by leveraging the power of CSS variables. The same approach could apply to any language or framework. In my example, I’m building this as a Next.js site using React, but the concepts are transferable. Here’s the full source code.
Most of the time, we’ll be provided with a hex color from the backend. In this case, I wrote a quick helper function to help with getting our hex colors from the backend into the right RGB format.
The second utility function we need is a method to get the accessible color that is of high contrast (according to the WCAG guidelines) to display on top of the dynamic primary color. I created a utilities file to centralize my helper functions that we can use later on:
// utils/index.js
/////////////////////////////////////////////////////////////////////
// Change hex color into RGB /////////////////////////////////////////////////////////////////////
export const getRGBColor = (hex, type) => {
let color = hex.replace(/#/g, "")
// rgb values
var r = parseInt(color.substr(0, 2), 16)
var g = parseInt(color.substr(2, 2), 16)
var b = parseInt(color.substr(4, 2), 16)
return `--color-${type}: ${r}, ${g}, ${b};`
}
/////////////////////////////////////////////////////////////////////
// Determine the accessible color of text
/////////////////////////////////////////////////////////////////////
export const getAccessibleColor = (hex) => {
let color = hex.replace(/#/g, "")
// rgb values
var r = parseInt(color.substr(0, 2), 16)
var g = parseInt(color.substr(2, 2), 16)
var b = parseInt(color.substr(4, 2), 16)
var yiq = (r * 299 + g * 587 + b * 114) / 1000
return yiq >= 128 ? "#000000" : "#FFFFFF"
}
The getAccessibleColor function works by converting the RGB color space into YIQ, as explained in calculating color contrast. For our use case, we needed a reliable method for the text color to go on top of our primary color.
With our helper functions in place, we convert our dynamic primary color from our backend to an RGB format that can be used in our CSS variable. We then get the a11y (accessibility) color.
I structured my function to receive a second param for the type of color I’m declaring. This allows me to reuse the function for any combination of CSS variables I would want to declare. Adding a secondary, accent, or any other color into the mix would follow the exact same logic.
Changing the format into RGB is a crucial step. The main reason we need these colors in RGB format is that when we compose our new colors through Tailwind CSS, we can add an alpha layer for RGBA colors. This ensures all bg-opacity and text-opacity classes would work, and we could get various shades of our dynamic color by working with the opacity layer as well.
With our primaryColor and a11yColor in RGB format, we need to declare a CSS variable scoped to the root of our HTML document. Adding this on :root means we’ll have access to it anywhere we use CSS classes within the DOM.
// pages/index.js
const primaryColor = getRGBColor("#6231af", "primary")
const a11yColor = getRGBColor(getAccessibleColor("#6231af"), "a11y")
<!-- ... -->
<!-- ... -->
<Head>
<style>:root {`{${primaryColor} ${a11yColor}}`}</style>
</Head>
For now, we’ve just hard-coded this to #6231af, but this is easy to replace as a single entry point for any dynamic color later on.
Any color in Tailwind CSS is declared by immediately declaring a --tw-bg-opacity: 1; utility in that class. Any colors declared are then made into RGBA values, where it uses the --tw-bg-opacity value, as declared for the alpha channel.
If you just declare a color, that color would be solid, but when we declare bg-opacity-{value}, Tailwind CSS re-declares that alpha channel, enabling us to achieve various levels of opacity with our dynamic color.
This is actually very clever from the guys at Tailwind CSS, as there is no way to declare or use a text or background-only opacity in CSS. The only way to achieve this is with the alpha channel in RGBA, and, by following the pattern they’ve laid out, we’re leveraging the full potential of their color system.
At this point, we have a CSS variable declared in our HTML (which could be connected to our backend). The next step is to link that CSS variable to some Tailwind CSS classes to use.
To achieve this, we have to focus on the tailwind.config.js file, which is where all the magic happens. Tailwind allows us to assign colors as a function instead of a string to get access to the internal Tailwind opacity utility. This is something that we’ll use a couple of times, so it’s best to extract this into a reusable function at the top of our tailwind.config.js:
// tailwind.config.js
function withOpacity(variableName) {
return ({ opacityValue }) => {
if (opacityValue !== undefined) {
return `rgba(var(${variableName}), ${opacityValue})`
}
return `rgb(var(${variableName}))`
}
}
First off, we receive the opacity value on colors from Tailwind that we can use with our RGB-formatted color as an alpha channel. Because this might not be set, we perform a quick check: if it’s undefined, we return it without any alpha.
In either case, we simply assign the value of our CSS variable to the rgb in this format. This is now ready to use when composing our colors.
Next, we set up our new colors by extending our theme:
// tailwind.config.js
theme: {
extend: {
textColor: {
skin: {
primary: withOpacity("--color-primary"),
a11y: withOpacity("--color-a11y"),
},
},
backgroundColor: {
skin: {
primary: withOpacity("--color-primary"),
a11y: withOpacity("--color-a11y"),
},
},
ringColor: {
skin: {
primary: withOpacity("--color-primary"),
},
},
borderColor: {
skin: {
primary: withOpacity("--color-primary"),
a11y: withOpacity("--color-a11y"),
},
},
},
},
I like to nest these utilities with my own prefix keyword, skin. This is optional, but it comes in handy when you are typing out classes, especially when you use something like the Tailwind VSCode extension with IntelliSense, which would pick up and show all your new classes.
We extend our theme with the textColor and backgroundColor as planned, as well as add the ringColor and borderColor variations so that we have our dynamic custom color available on those utilities as well.
With this in place, Tailwind can generate a bunch of new classes for us. We can just start using any of these new classes in our markup.
Now, let’s change the text color to our new dynamically set primary color.
<p className="... text-skin-primary"> Jane Cooper </p>
On the buttons, let’s set the background to the primary with a 30 percent opacity, which allows us to use the primary text color. We can then make the background solid on hover and switch to our accessibility safe color for the icon on hover.
We can also use the border color and focus ring colors all set in our primary dynamic color.
<a href={link.href} target="_blank" className="... bg-skin-primary bg-opacity-30 text-skin-primary hover:bg-opacity-100 hover:text-skin-a11y border-skin-primary focus:ring-skin-primary">
<link.icon className="h-5 w-5" />
</a>
Looking back at our requirements, we were able to dynamically set our primary color without changing markup, get different shades of our primary color, and maintain a good contrast ratio when displaying anything on top of our dynamic primary color.
Have you done something similar? Let me know in the comments below!
As web frontends get increasingly complex, resource-greedy features demand more and more from the browser. If you’re interested in monitoring and tracking client-side CPU usage, memory usage, and more for all of your users in production, try LogRocket.
LogRocket lets you replay user sessions, eliminating guesswork around why bugs happen by showing exactly what users experienced. It captures console logs, errors, network requests, and pixel-perfect DOM recordings — compatible with all frameworks.
LogRocket's Galileo AI watches sessions for you, instantly identifying and explaining user struggles with automated monitoring of your entire product experience.
Modernize how you debug web and mobile apps — start monitoring for free.

Why the future of DX might come from the web platform itself, not more tools or frameworks.

A hands-on test of Claude Code Review across real PRs, breaking down what it flagged, what slipped through, and how the pipeline actually performs in practice.

CSS art once made frontend feel playful and accessible. Here’s why it faded as the web became more practical and prestige-driven.

Learn how inline props break React.memo, trigger unnecessary re-renders, and hurt React performance — plus how to fix them.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up now