Introduction
Unlocking the potential of theming in UI design has never been more exciting, especially with shining examples like shadcn/ui. With its array of captivating themes—ranging from blue, red, orange I found myself intrigued by the challenge: How could I create such diverse themes? In this tutorial, we'll embark on a thematic journey through 3 distinct approaches. We will create a theme able button component with the help of Tailwind Variants for both light and dark modes, all while harnessing the prowess of Tailwind CSS.
Our aim is to design a versatile Button component with various options like solid and outline styles, available in multiple color schemes such as red, orange, and green. This component should seamlessly function in both light and dark themes. I recommend checking out the github repository and reviewing the attached screenshots in the readme section. To achieve distinct Button
styles, we're utilizing Tailwind Variants, though this specific aspect will be covered in upcoming tutorials.
Spanning across 3 branches of my repository -
- In the first branch drawing inspiration from Chakra UI, we'll wield theme tokens to craft our adaptable button, seamlessly transitioning between light and dark modes, using the Tailwind
:dark
selector. - In the second branch taking inspiration from nextui's separate tokens for light and dark mode colors, we will use the power of CSS variables with Tailwind CSS.
- In the final branch, taking inspiration from shadcn/ui kaleidoscope of themes - red, blue, orange, green, all with their dark mode counterparts, we will put Tailwind CSS and CSS variables into awe-inspiring action.
Project Setup
We will be using vite to bootstrap a React project. From your terminal run -
npm create vite@latest
During the setup, you'll be prompted to provide a project name. Feel free to choose a name that resonates with you. Select React
as the framework and TypeScript
as the variant. Now we need to install & setup Tailwind CSS, I would recommend you follow this detailed guide. Finally we will install Tailwind Variants for creating multi-variant React component -
yarn add tailwind-variants
With that you can commit your code, our project setup is completed.
Charka UI style theming
In this section, we'll embark on our theming journey by simulating Chakra UI's elegant theming approach, but using Tailwind CSS. First, create a new Git branch -
git checkout -b chakra-ui
We're aiming to create a button similar to the image above. To use this button, you'd simply employ the following code snippet for your component API:
<Button
size="lg"
colorScheme="orange"
variant="outline"
>
Click Me
</Button>
This structure enhances code readability and offers a user-friendly way to generate the desired button appearance.
Open the index.css file and introduce custom color tokens using CSS variables. These tokens will serve as the foundation for our theme, you can get the complete code here -
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--gray-50: 247, 250, 252;
--gray-100: 237, 242, 247;
--gray-200: 226, 232, 240;
--gray-300: 203, 213, 224;
--gray-400: 160, 174, 192;
--gray-500: 113, 128, 150;
--gray-600: 74, 85, 104;
--gray-700: 45, 55, 72;
--gray-800: 26, 32, 44;
--gray-900: 23, 25, 35;
--red-50: 255, 245, 245;
--red-100: 254, 215, 215;
--red-200: 254, 178, 178;
--red-300: 252, 129, 129;
--red-400: 245, 101, 101;
--red-500: 229, 62, 62;
--red-600: 197, 48, 48;
--red-700: 155, 44, 44;
--red-800: 130, 39, 39;
--red-900: 99, 23, 27;
...other tokens
}
}
The @layer base {}
directive is used to define foundational styles applied globally. In theming, it's employed to set up color, spacing, fonts tokens universally, ensuring a consistent theme palette throughout the application.
Now in the tailwind.config.js paste the following -
/** @type {import('tailwindcss').Config} */
import { withTV } from 'tailwind-variants/transformer'
const getPropertyValue = (variable) => {
return ({ opacityValue }) =>
opacityValue
? `rgba(var(--${variable}), ${opacityValue})`
: `rgb(var(--${variable}))`
}
export default withTV({
darkMode: "class",
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
gray: {
50: getPropertyValue('gray-50'),
100: getPropertyValue('gray-100'),
200: getPropertyValue('gray-200'),
300: getPropertyValue('gray-300'),
400: getPropertyValue('gray-400'),
500: getPropertyValue('gray-500'),
600: getPropertyValue('gray-600'),
700: getPropertyValue('gray-700'),
800: getPropertyValue('gray-800'),
900: getPropertyValue('gray-900')
},
red: {
50: getPropertyValue('red-50'),
100: getPropertyValue('red-100'),
200: getPropertyValue('red-200'),
300: getPropertyValue('red-300'),
400: getPropertyValue('red-400'),
500: getPropertyValue('red-500'),
600: getPropertyValue('red-600'),
700: getPropertyValue('red-700'),
800: getPropertyValue('red-800'),
900: getPropertyValue('red-900')
},
orange: {
50: getPropertyValue('orange-50'),
100: getPropertyValue('orange-100'),
200: getPropertyValue('orange-200'),
300: getPropertyValue('orange-300'),
400: getPropertyValue('orange-400'),
500: getPropertyValue('orange-500'),
600: getPropertyValue('orange-600'),
700: getPropertyValue('orange-700'),
800: getPropertyValue('orange-800'),
900: getPropertyValue('orange-900')
},
green: {
50: getPropertyValue('green-50'),
100: getPropertyValue('green-100'),
200: getPropertyValue('green-200'),
300: getPropertyValue('green-300'),
400: getPropertyValue('green-400'),
500: getPropertyValue('green-500'),
600: getPropertyValue('green-600'),
700: getPropertyValue('green-700'),
800: getPropertyValue('green-800'),
900: getPropertyValue('green-900')
},
},
spacing: {
xxs: "0.5rem",
xs: "0.8rem",
sm: "1rem",
md: "1.25rem",
lg: "1.5rem",
xl: "2rem",
xxl: "2.4rem",
"3xl": "3rem",
"4xl": "3.6rem"
}
},
plugins: [],
})
First, we're expanding our theme to make sure that when we use Tailwind classes like text-red-600
, we're using our custom colors, not the default Tailwind colors. We've also introduced tokens for spacing. Now, the interesting part is the getPropertyValue
function. We're using it because when Tailwind generates classes for bg-green-500
, it sets an opacity value like -
.bg-green-500 {
--tw-bg-opacity: 1;
background-color: rgb(34 197 94 / var(--tw-bg-opacity));
}
The getPropertyValue
function helps us generate the right color dynamically by allowing us to pass opacity values. For instance, using the class bg-green-500
alongside opacity-50
will adjust the color's transparency, as the function smartly handles opacity values for us. And for the same reason we are using rgb values for our color tokens, so that it becomes easy for us to add the opacity variable.
Under src
folder create the Button.tsx
file, using Tailwind variants, we'll craft our Button
component. -
import { VariantProps, tv } from "tailwind-variants";
const baseButton = tv({
base: "border-none outline-none cursor-pointer inline-flex items-center justify-center px-[0.25em] py-[0.75em] font-semibold text-center leading-[1.1] transition duration-220 ease-in-out rounded-[0.375rem] focus:shadow-outline hover:bg-transparent hover:bg-initial disabled:opacity-40 disabled:cursor-not-allowed disabled:shadow-none",
variants: {
colorScheme: {
red: "",
orange: "",
green: "",
},
variant: {
solid: "",
outline: "",
},
},
compoundVariants: [
{
colorScheme: "green",
variant: "solid",
class:
"bg-green-500 dark:bg-green-200 text-white dark:text-gray-800 hover:bg-green-600 dark:hover:bg-green-300 hover:disabled:bg-green-500 dark:hover:disabled:bg-green-200 active:bg-green-700 dark:active:bg-green-400",
},
{
colorScheme: "red",
variant: "solid",
class:
"bg-red-500 dark:bg-red-200 text-white dark:text-gray-800 hover:bg-red-600 dark:hover:bg-red-300 hover:disabled:bg-red-500 dark:hover:disabled:bg-red-200 active:bg-red-700 dark:active:bg-red-400",
},
{
colorScheme: "orange",
variant: "solid",
class:
"bg-orange-500 dark:bg-orange-200 text-white dark:text-gray-800 hover:bg-orange-600 dark:hover:bg-orange-300 hover:disabled:bg-orange-500 dark:hover:disabled:bg-orange-200 active:bg-orange-700 dark:active:bg-orange-400",
},
{
colorScheme: "green",
variant: "outline",
class:
"text-green-600 dark:text-green-200 bg-transparent border-solid border border-current hover:bg-green-50 dark:hover:bg-green-200/[.12] active:bg-green-100 dark:active:bg-green-200/[.24]",
},
{
colorScheme: "red",
variant: "outline",
class:
"text-red-600 dark:text-red-200 bg-transparent border-solid border border-current hover:bg-red-50 dark:hover:bg-red-200/[.12] active:bg-red-100 dark:active:bg-red-200/[.24]",
},
{
colorScheme: "orange",
variant: "outline",
class:
"text-orange-600 dark:text-orange-200 bg-transparent border-solid border border-current hover:bg-orange-50 dark:hover:bg-orange-200/[.12] active:bg-orange-100 dark:active:bg-orange-200/[.24]",
},
],
defaultVariants: {
colorScheme: "green",
variant: "solid",
},
});
const button = tv(
{
extend: baseButton,
variants: {
size: {
xs: "h-[1.5rem] min-w-[1.5rem] text-xs px-xs",
sm: "h-[2rem] min-w-[2rem] text-sm px-sm",
md: "h-[2.5rem] min-w-[2.5rem] text-md px-md",
lg: "h-[3rem] min-w-[3rem] text-lg px-lg",
},
},
defaultVariants: {
size: "md",
},
},
{
responsiveVariants: true,
}
);
export type ButtonProps = VariantProps<typeof button> &
React.ComponentPropsWithoutRef<"button">;
export function Button(props: ButtonProps) {
const { colorScheme, size, variant, className, ...delegated } = props;
return (
<button
className={button({
colorScheme,
variant,
className,
size,
})}
{...delegated}
/>
);
}
Notice that we're utilizing the dark:
selector for managing dark mode effortlessly – Tailwind makes adding these selectors incredibly simple.
Finally test the button component under App.tsx
, you can get the complete code here. To toggle the theme from light to dark add .light
/ .dark
class to the parent element.
Next UI style theming
Picture a scenario where you write your styles just once, but they seamlessly adjust to both light and dark themes. Say goodbye to duplicating styles using the dark:
selector for each property. This is where the magic of separate tokens for each theme mode comes in. By employing this method, your styles dynamically adapt as you switch themes, eradicating redundancy and streamlining your styling process.
We're aiming to create a button similar to the image above. First, create a new Git branch -
git checkout -b next-ui
Open the index.css file and introduce custom color tokens using CSS variables. These tokens will serve as the foundation for our theme, you can get the complete code here -
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
.light {
--blue50: 237, 245, 255;
--blue100: 225, 239, 255;
--blue200: 206, 228, 254;
--blue300: 183, 213, 248;
--blue400: 150, 193, 242;
--blue500: 94, 162, 239;
--blue600: 0, 114, 245;
--blue700: 0, 95, 204;
--blue800: 0, 71, 153;
--blue900: 0, 37, 77;
...other tokens
}
.dark {
--blue50: 16, 37, 62;
--blue100: 16, 44, 76;
--blue200: 15, 49, 88;
--blue300: 13, 56, 104;
--blue400: 10, 66, 129;
--blue500: 9, 82, 165;
--blue600: 0, 114, 245;
--blue700: 54, 148, 255;
--blue800: 54, 148, 255;
--blue900: 234, 244, 255;
...other tokens
}
}
@layer utilities {
.shadow-primary {
box-shadow: 0 4px 14px 0 rgb(var(--blue500));
}
.shadow-secondary {
box-shadow: 0 4px 14px 0 rgb(var(--purple500));
}
.shadow-error {
box-shadow: 0 4px 14px 0 rgb(var(--red500));
}
.shadow-success {
box-shadow: 0 4px 14px 0 rgb(var(--green500));
}
.shadow-warning {
box-shadow: 0 4px 14px 0 rgb(var(--yellow500));
}
}
Unlike the earlier method, we're now generating tokens for both the light and dark themes. This means that when the theme changes, the appropriate token for the corresponding mode automatically takes effect, eliminating the need for the dark:
selector. Additionally, by including custom classes in the utilities layer, we've opened the door to extending Tailwind classes. This is particularly useful when we require classes like shadow-primary
, which aren't native to Tailwind. This approach not only optimizes theme switching but also allows for seamless expansion of the styling capabilities.
Now in the tailwind.config.js, copy paste the code from here -
theme: {
extend: {
colors: {
'primary': {
'light': getPropertyValue('blue200'),
'light-hover': getPropertyValue('blue300'),
'light-active': getPropertyValue('blue400'),
'light-contrast': getPropertyValue('blue600'),
'border': getPropertyValue('blue500'),
'border-hover': getPropertyValue('blue600'),
'solid-hover': getPropertyValue('blue700'),
'shadow': getPropertyValue('blue500'),
'DEFAULT': getPropertyValue('blue600'),
},
'secondary': {
'light': getPropertyValue('purple200'),
'light-hover': getPropertyValue('purple300'),
'light-active': getPropertyValue('purple400'),
'light-contrast': getPropertyValue('purple600'),
'border': getPropertyValue('purple500'),
'border-hover': getPropertyValue('purple600'),
'solid-hover': getPropertyValue('purple700'),
'shadow': getPropertyValue('purple500'),
'DEFAULT': getPropertyValue('purple600'),
},
// ... (other tokens)
}
}
}
In our Tailwind config, we've introduced semantic tokens
that add a layer of organization above the foundational base tokens. This approach offers several advantages. For instance, consider the primary color definition. When the mode is light, it draws from the light theme, while in dark mode, it derives from the corresponding base token associated with the dark theme. This dynamic behavior ensures that regardless of the mode, the primary color consistently adapts, maintaining the expected appearance while switching between light and dark themes.
Now under src/Button.tsx
paste the following -
import { VariantProps, tv } from "tailwind-variants";
const baseButton = tv({
base: "appearance-none box-border flex items-center justify-center leading-5 select-none text-center whitespace-nowrap border-none cursor-pointer transition duration-250 ease-in border-2 active:scale-[.97]",
variants: {
colorScheme: {
primary: "",
secondary: "",
success: "",
warning: "",
error: "",
},
variant: {
solid: "",
bordered: "bg-transparent border-2 border-solid",
ghost: "bg-transparent border-2 border-solid",
flat: "",
},
isShadow: {
true: "",
},
},
compoundVariants: [
{
colorScheme: "primary",
variant: "solid",
class: "bg-primary text-white",
},
{
colorScheme: "secondary",
variant: "solid",
class: "bg-secondary text-white",
},
{
colorScheme: "success",
variant: "solid",
class: "bg-success text-black",
},
{
colorScheme: "warning",
variant: "solid",
class: "bg-warning text-black",
},
{
colorScheme: "error",
variant: "solid",
class: "bg-error text-white",
},
{
colorScheme: "primary",
isShadow: true,
class: "shadow-primary",
},
{
colorScheme: "secondary",
isShadow: true,
class: "shadow-secondary",
},
{
colorScheme: "success",
isShadow: true,
class: "shadow-success",
},
{
colorScheme: "warning",
isShadow: true,
class: "shadow-warning",
},
{
colorScheme: "error",
isShadow: true,
class: "shadow-error",
},
{
colorScheme: "primary",
variant: "bordered",
class: "text-primary border-primary",
},
{
colorScheme: "secondary",
variant: "bordered",
class: "text-secondary border-secondary",
},
{
colorScheme: "success",
variant: "bordered",
class: "text-success border-success",
},
{
colorScheme: "warning",
variant: "bordered",
class: "text-warning border-warning",
},
{
colorScheme: "error",
variant: "bordered",
class: "text-error border-error",
},
{
colorScheme: "primary",
variant: "ghost",
class: "text-primary border-primary hover:text-white hover:bg-primary",
},
{
colorScheme: "secondary",
variant: "ghost",
class:
"text-secondary border-secondary hover:text-white hover:bg-secondary",
},
{
colorScheme: "success",
variant: "ghost",
class: "text-success border-success hover:text-black hover:bg-success",
},
{
colorScheme: "warning",
variant: "ghost",
class: "text-warning border-warning hover:text-black hover:bg-warning",
},
{
colorScheme: "error",
variant: "ghost",
class: "text-error border-error hover:text-white hover:bg-error",
},
{
colorScheme: "primary",
variant: "flat",
class:
"bg-primary-light text-primary-light-contrast hover:bg-primary-light-hover active:bg-primary-light-active",
},
{
colorScheme: "secondary",
variant: "flat",
class:
"bg-secondary-light text-secondary-light-contrast hover:bg-secondary-light-hover active:bg-secondary-light-active",
},
{
colorScheme: "success",
variant: "flat",
class:
"bg-success-light text-success-light-contrast hover:bg-success-light-hover active:bg-success-light-active",
},
{
colorScheme: "warning",
variant: "flat",
class:
"bg-warning-light text-warning-light-contrast hover:bg-warning-light-hover active:bg-warning-light-active",
},
{
colorScheme: "error",
variant: "flat",
class:
"bg-error-light text-error-light-contrast hover:bg-error-light-hover active:bg-error-light-active",
},
],
defaultVariants: {
colorScheme: "primary",
variant: "solid",
},
});
const button = tv(
{
extend: baseButton,
variants: {
size: {
xs: "rounded-xs h-10 pl-3 pr-3 leading-10 min-w-20 text-xs",
sm: "rounded-sm h-12 pl-5 pr-5 leading-14 min-w-36 text-sm",
md: "rounded-md h-14 pl-7 pr-7 leading-14 min-w-48 text-sm",
lg: "rounded h-16 pl-9 pr-9 leading-15 min-w-60 text-md",
xl: "rounded-xl h-18 pl-10 pr-10 leading-17 min-w-72 text-lg",
},
},
defaultVariants: {
size: "md",
},
},
{
responsiveVariants: true,
}
);
export type ButtonProps = VariantProps<typeof button> &
React.ComponentPropsWithoutRef<"button">;
export function Button(props: ButtonProps) {
const { colorScheme, size, variant, isShadow, className, ...delegated } =
props;
return (
<button
className={button({
colorScheme,
variant,
className,
isShadow,
size,
})}
{...delegated}
/>
);
}
As evident from the above example, the absence of the dark:
selector is notable. Our semantic tokens, like primary and secondary, smoothly manage theming shifts. This feature enhances code readability and maintenance significantly. By allowing these tokens to handle the theming intricacies, our code becomes more concise and easier to understand, resulting in streamlined development and maintenance processes.
Finally test the button component under App.tsx
, you can get the complete code here. To toggle the theme from light to dark add .light
/ .dark
class to the parent element.
Shadcn style theming
Up to this point, we've constructed a single theme encompassing light and dark modes. But can we emulate shadcn's approach? They feature multiple themes — red, green, blue, orange - each accompanied by its respective dark mode. Could this method uphold code quality and readability as effectively?
First, create a new Git branch -
git checkout -b shad-ui
First open the index.css
file and add the CSS variables -
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Red Theme */
.red-theme {
--primary: 346.8 77.2% 49.8%;
--primary-foreground: 355.7 100% 97.3%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
}
.dark .red-theme {
--primary: 346.8 77.2% 49.8%;
--primary-foreground: 355.7 100% 97.3%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
}
/* Blue Theme */
.blue-theme {
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
}
.dark .blue-theme {
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
}
/* Green Theme */
.green-theme {
--primary: 142.1 76.2% 36.3%;
--primary-foreground: 355.7 100% 97.3%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
}
.dark .green-theme {
--primary: 142.1 70.6% 45.3%;
--primary-foreground: 144.9 80.4% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
}
/* Orange Theme */
.orange-theme {
--primary: 24.6 95% 53.1%;
--primary-foreground: 60 9.1% 97.8%;
--secondary: 60 4.8% 95.9%;
--secondary-foreground: 24 9.8% 10%;
}
.dark .orange-theme {
--primary: 20.5 90.2% 48.2%;
--primary-foreground: 60 9.1% 97.8%;
--secondary: 12 6.5% 15.1%;
--secondary-foreground: 60 9.1% 97.8%;
}
We're defining CSS variables to represent different theme colors, both for regular and dark modes. These variables ensure that the themes can be conveniently customized and managed. The dark class is applied when the dark mode is active, which updates the variable values accordingly.
Next open the tailwind.config.js
file and create the semantic tokens -
/** @type {import('tailwindcss').Config} */
const getPropertyValue = (variable) => {
return ({ opacityValue }) =>
opacityValue
? `hsl(var(--${variable}) / ${opacityValue})`
: `hsl(var(--${variable}))`
}
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
primary: {
DEFAULT: getPropertyValue("primary"),
foreground: getPropertyValue("primary-foreground")
},
secondary: {
DEFAULT: getPropertyValue("secondary"),
foreground: getPropertyValue("secondary-foreground")
}
}
},
spacing: {
xxs: '0.6rem',
xs: '0.8rem',
sm: '1rem',
md: '1.2rem',
lg: '1.5rem',
xl: '2rem',
xxl: '2.4rem',
'3xl': '3rem',
'4xl': '3.6rem'
}
},
plugins: [],
}
Now under src/Button.tsx
-
import { VariantProps, tv } from "tailwind-variants";
const baseButton = tv({
base: "border-none outline-none cursor-pointer inline-flex items-center justify-center px-[0.25em] py-[0.75em] text-center leading-[1.1] transition duration-220 ease-in-out rounded-[0.375rem] focus:shadow-outline hover:bg-transparent hover:bg-initial disabled:opacity-40 disabled:cursor-not-allowed disabled:shadow-none",
variants: {
colorScheme: {
primary: "",
},
varaint: {
solid: "",
outline: "",
},
},
compoundVariants: [
{
colorScheme: "primary",
varaint: "solid",
class: "bg-primary text-primary-foreground hover:bg-primary/90",
},
{
colorScheme: "primary",
varaint: "outline",
class:
"bg-transparent text-primary hover:bg-primary/10 border-solid border border-primary",
},
],
defaultVariants: {
colorScheme: "primary",
varaint: "solid",
},
});
const button = tv(
{
extend: baseButton,
variants: {
size: {
xs: "h-[1.5rem] min-w-[1.5rem] text-xs px-xs",
sm: "h-[2rem] min-w-[2rem] text-sm px-sm",
md: "h-[2.5rem] min-w-[2.5rem] text-md px-md",
lg: "h-[3rem] min-w-[3rem] text-lg px-lg",
},
},
defaultVariants: {
size: "md",
},
},
{
responsiveVariants: true,
}
);
export type ButtonProps = VariantProps<typeof button> &
React.ComponentPropsWithoutRef<"button">;
export function Button(props: ButtonProps) {
const { colorScheme, size, varaint, className, ...delegated } = props;
return (
<button
className={button({
colorScheme,
varaint,
className,
size,
})}
{...delegated}
/>
);
}
Finally test the button component under App.tsx
, you can get the complete code here. To toggle the theme from light to dark add .light
/ .dark
class to the parent element.
Conclusion
We explored multiple theming approaches throughout this journey. Initially, we employed flat tokens and the dark selector. In the second approach, we refined the code, establishing distinct tokens for light and dark modes, and introduced an abstraction layer through semantic tokens. Our explorations culminated in the creation of multiple themes with their corresponding dark modes. This intricate process, while it may seem complex, is surprisingly straightforward.
All the code can be found here. Until next time PEACE.
Top comments (0)