Creating Dynamic TailwindCSS Themes for a React Library

WEB
9 min read

Here at DoltHub, we're building a React library to share hooks, contexts, utilities, and components between our web applications. This blog will walk you through how we set up TailwindCSS themes to handle different color and style configuration for our shared components. It will also show how we utilize Storybook to toggle between these themes when we view our components.

Background

We have four web apps: DoltHub, DoltLab, Hosted Dolt, and Dolt Workbench. Each of these applications has its own version of the same Dolt database UI with a few different styles and features.

Database UIs

DoltHub was our first web application, and it shares the same code as DoltLab. When we created Hosted Dolt in 2022, we thought about how we could share React components and other code with DoltHub since there would be some overlap. We ultimately decided for the sake of time to hard fork the DoltHub code and make the necessary changes for Hosted. We made this same decision when we created the Dolt Workbench last year, which is essentially an open source version of the Hosted Dolt workbench for any MySQL or Postgres database.

Now that some time has passed and our web products are launched and ready for customer use, we're going back and building out a library to share React code between our apps.

As you can see from the image above, we mostly use color to distinguish the look and feel of each application. We use TailwindCSS to create a color theme for each application. This has made it easier to handle different colors for the same component between applications when we were copying front end code.

In order to build a shared React component library, we needed the ability to configure different Tailwind color themes for the shared components for each application. Luckily, we had already made some of these changes for the DoltHub React code base when we launched custom colors for DoltLab Enterprise last year.

We now have moved this Tailwind theme configuration to our React library. This blog will walk you through how Tailwind themes work and how you can set up dynamic Tailwind themes for your own application.

Goal

We want to deduplicate our components in our three React applications by moving them to a shared React component library. Colors vary between our applications and we want our components to have the correct color without needing to pass down different color variants as props.

For example, take this FormSelect component. Each application has its own accent color, which is used to indicate the active tab in the dropdown: pink for DoltHub/DoltLab, and different shades of orange for Hosted Dolt and Dolt Workbench.

FormSelect tabs

❌ This is an example of how we do NOT want our component library to handle different colors:

type Props = {
  tabColor: "pink" | "orange" | "redorange";
};

export default function FormSelect(props: Props) {
  return (
    <div>
      <div className={`text-${props.tabColor}`}>[tabs code here]</div>
      [...the rest of the FormSelect code]
    </div>
  );
}

Every instance of FormSelect in our applications would need to specify a tabColor.

✅ This is how we DO want our component library to handle different colors:

export default function FormSelect() {
  return (
    <div>
      <div className="text-accent-1">[tabs code here]</div>
      [...the rest of the FormSelect code]
    </div>
  );
}

We can define a color value for accent-1 in each application and it will apply when we use that Tailwind class.

How it works

We can achieve the above using CSS variables. In each application, we will define our CSS variable as a channel for color accent-1 and apply it to our root style.

/* dolthub/styles/main.css */
@layer base {
  :root {
    --color-accent-1: 252, 66, 201;
  }
}

And then in our React component library, we will include the color space in our Tailwind configuration, which will be used in each application.

// components/tailwind.config.ts
import { Config } from "tailwindcss";

const config: Config = {
  theme: {
    extend: {
      colors: {
        "accent-1": "rgba(var(--color-accent-1), <alpha-value>)",
      },
    },
  },
};

Then when we use text-accent-1 in our FormSelect it will apply the color based on the --color-accent-1 CSS variable we define in each application.

For some applications, this Tailwind guide will be enough to handle color themes in a shared component library. However, we need the ability to set our Tailwind color theme dynamically based on environment variables. If this applies to you, read on.

Implementing dynamic color themes

As I mentioned earlier, DoltHub and DoltLab share the same code, and DoltLab Enterprise has a feature where administrators can set custom colors via environment variables for their application. This means that we cannot simply define CSS variables for colors in our main CSS file. We need to dynamically set the theme based on environment variables. Luckily, we found this very helpful article to get us started.

These are the main parts we will need to implement:

  1. A React context that applies CSS variables to an application's root.style
  2. A base Tailwind configuration that defines the color space
  3. A place to create the color themes for each application

We will walk through how we implemented each of the above, using our accent-1 color as an example.

1. Create a React context that applies CSS variables to an application's root.style

This is the part that leans heavily on this article. In our React library we define a React context that takes some CSS variables and applies them to the application's root.style.

First, we define some types:

// components/tailwind/types.ts

// Defines the color channels. Passed to the context from each app.
// i.e. {"rgb-accent-1": "252 66 201"}
export interface IThemeRGB {
  "rgb-accent-1"?: string;
}

// Name of the CSS variables used in tailwind.config.ts
export interface IThemeVariables {
  "--color-accent-1": string;
}

// Name of the defined colors in the Tailwind theme
export interface IThemeColors {
  "accent-1"?: string;
}

Then we create a ThemeProvider that takes the IThemeRGB (which is passed as a prop), converts it to a CSS variable, and applies the variable to the root.style.

// components/tailwind/context/index.tsx
import React, { createContext, useEffect, useMemo } from "react";
import { IThemeRGB } from "../types";
import applyTheme from "./applyTheme";

type Props = {
  children: React.ReactNode;
  themeRGB?: IThemeRGB;
};

type ThemeContextType = {
  themeRGB: IThemeRGB;
};

const ThemeContext = createContext<ThemeContextType>({
  themeRGB: {} as IThemeRGB,
});

export default function ThemeProvider(props: Props) {
  // Note: if you want switch themes on demand, you should
  // add `props.themeRGB` to the dependency array
  useEffect(() => {
    applyTheme(props.themeRGB);
  }, []);

  const value = useMemo(() => {
    return { themeRGB: props.themeRGB };
  }, [props.themeRGB]);

  return (
    <ThemeContext.Provider value={value}>
      {props.children}
    </ThemeContext.Provider>
  );
}
// components/tailwind/context/applyTheme.ts
import { IThemeRGB, IThemeVariables } from "../types";

export default function applyTheme(themeRGB: IThemeRGB) {
  const themeObject: IThemeVariables = mapTheme(themeRGB);
  const root = document.documentElement;

  Object.keys(themeObject).forEach((v) => {
    const propertyVal = themeObject[v as keyof IThemeVariables];
    const validation = validateRGB(propertyVal);
    if (!validation) {
      throw new Error(`Invalid RGB value for ${v}: ${propertyVal}`);
    }

    root.style.setProperty(v, propertyVal);
  });
}

function mapTheme(rgb: IThemeRGB): IThemeVariables {
  return {
    "--color-accent-1": rgb["rgb-accent-1"] ?? "",
  };
}

function validateRGB(rgb: string): boolean {
  if (!rgb) return true;
  const rgbRegex = /^(\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})$/;
  return rgbRegex.test(rgb);
}

ThemeProvider should wrap every page in your application. We use Next.js and put it in our pages/_app.tsx file:

import { ThemeProvider } from "@dolthub/react-components";
import { withApollo } from "@lib/apollo";
import App from "next/app";
import { ReactNode } from "react";
import "../styles/global.css";

type Props = {
  children: ReactNode;
};

export default class DoltHub extends App {
  public render() {
    const { Component, pageProps } = this.props;

    const WrappedPage = withApollo()(Component);
    return (
      <ThemeProvider themeRGB={{ "rgb-accent-1": "252, 66, 201" }}>
        <WrappedPage {...pageProps} />
      </ThemeProvider>
    );
  }
}

2. Create a base Tailwind configuration that defines the color space

Now we create a base color configuration in our React library. We'll want to merge this with each individual application's Tailwind configuration.

The base configuration should define the color space.

// components/tailwind/base/colors.ts

const staticColors = {
  // define any static colors that will have the same value for all apps
};

const configurableColors: IThemeColors = {
  "accent-1": withOpacity("--color-accent-1"),
};

function withOpacity(variableName: keyof IThemeVariables): string {
  return `rgba(var(${variableName}), <alpha-value>)`;
}

const colors = { ...staticColors, ...configurableColors };
export default colors;

We created a mergeConfig utility function in our React library that can merge any Tailwind configuration with our base colors:

// components/tailwind/mergeConfig.ts
import merge from "deepmerge";
import { Config } from "tailwindcss";
import breakpoints from "./theme/base/breakpoints";
import colors from "./theme/base/colors";
import plugins from "./theme/base/plugins";
import typography from "./theme/base/typography";

const reactComponentsTailwindConfig: Config = {
  plugins,
  theme: {
    extend: {
      transitionProperty: { width: "width" },
      gradientColorStops: colors,
      colors,
      fontFamily: typography,
      screens: breakpoints,
    },
  },
};

/**
 * Merge @dolthub/react-components and Tailwind CSS configurations
 */
export function mergeConfig(tailwindConfig: Config): Config {
  const merged = merge(reactComponentsTailwindConfig, { ...tailwindConfig });
  return merged;
}

We can use this in our tailwind.config.ts in each of our applications.

// dolthub/tailwind.config.ts
import { mergeConfig } from "@dolthub/react-components";

const dolthubColors = {
  /* any other colors specific to this app */
};

const config = mergeConfig({
  theme: {
    extend: {
      gradientColorStops: dolthubColors,
      colors: dolthubColors,
    },
  },
  content: [
    // Add content
  ],
});

export default config;

3. Creating the color themes for each application

Now that we have the ability to dynamically change Tailwind themes for our applications, we decided to move the color themes for each application to our React library. This gives us the ability to test our shared components in Storybook and view what they look like with different color themes. It's also nice to have everything in one place.

You can see here in our themes folder we have a place for each of our web applications where we have defined their colors. The DoltHub colors are our base colors. Our Hosted Dolt application uses a different definition for accent-1:

// components/tailwind/themes/hosted
export const tailwindColorTheme: IThemeRGB = {
  "rgb-accent-1": "237, 137, 54", // ld-orange
};

We export this from the React library and pass it to our ThemeProvider in our Hosted application's pages/_app.tsx.

import { ThemeProvider, hostedRGBColors } from "@dolthub/react-components";
import { withApollo } from "@lib/apollo";
import App from "next/app";
import { ReactNode } from "react";
import "../styles/global.css";

type Props = {
  children: ReactNode;
};

export default class Hosted extends App {
  public render() {
    const { Component, pageProps } = this.props;

    const WrappedPage = withApollo()(Component);
    return (
      <ThemeProvider themeRGB={hostedRGBColors}>
        <WrappedPage {...pageProps} />
      </ThemeProvider>
    );
  }
}

Now you have everything you need to create your own dynamic Tailwind themes.

Viewing results using Storybook

There are two ways you can see your new color themes in action:

  1. Wrapping your application with ThemeProvider and importing a shared component that uses one of your custom colors
  2. Viewing your shared components in Storybook

In order to apply dynamic Tailwind themes to our components in Storybook, we must wrap every story in ThemeProvider. We can do this in .storybook/preview.tsx using decorators.

// components/.storybook/preview.tsx
import { Preview } from "@storybook/react";
import React from "react";
import "../src/styles/global.css";
import ThemeProvider from "../src/tailwind/context";

const preview: Preview = {
  decorators: [
    (Story) => (
      <ThemeProvider>
        <Story />
      </ThemeProvider>
    ),
  ],
};

When we run storybook dev, we will see the component that looks like this, with the pink base accent color.

Storybook FormSelect with pink tabs

However, when I'm working on this component, I also want to see what it would look like with other color themes. We can add a theme switcher to Storybook to allow toggling between different Tailwind themes.

First, we add a toggle button to the toolbar in .storybook/preview.tsx.

// components/.storybook/preview.tsx
import { Preview } from "@storybook/react";
import React from "react";
import "../src/styles/global.css";

const preview: Preview = {
  globalTypes: {
    theme: {
      name: "Theme",
      description: "Global theme for components",
      defaultValue: "dolthub",
      toolbar: {
        // The icon for the toolbar item
        icon: "circlehollow",
        // Array of options
        items: [
          { value: "dolthub", title: "DoltHub" },
          { value: "hosted", title: "Hosted Dolt" },
          { value: "workbench", title: "Dolt Workbench" },
        ],
        dynamicTitle: true,
      },
    },
  },
};

It will look like this:

Storybook theme toggle

Then we adjust our decorators to switch the Tailwind theme based on the selected theme from the toolbar.

import { Preview } from "@storybook/react";
import React from "react";
import "../src/styles/global.css";
import ThemeProvider from "../src/tailwind/context";
import { baseColorVariableValues } from "../src/tailwind/theme/base/colors";
import { tailwindColorTheme as hostedTheme } from "../src/tailwind/theme/hosted/colors";
import { tailwindColorTheme as workbenchTheme } from "../src/tailwind/theme/workbench/colors";
import { IThemeRGB } from "../src/tailwind/types";

const preview: Preview = {
  globalTypes: {
    // same as above
  },
  decorators: [
    (Story, context) => {
      // Will default to the theme specified in an individual story
      const theme = context.args.theme ?? context.globals.theme;
      return (
        <ThemeProvider themeRGBOverrides={getTheme(theme)}>
          <Story />
        </ThemeProvider>
      );
    },
  ],
};

function getTheme(theme: string): IThemeRGB {
  switch (theme) {
    case "hosted":
      return hostedTheme;
    case "workbench":
      return workbenchTheme;
    default:
      return baseColorVariableValues;
  }
}

export default preview;

Now when we run Storybook, we can use the toolbar to switch themes for our FormSelect component!

FormSelect Storybook with toggle

Conclusion

You now know how to create Tailwind themes dynamically and view them using Storybook. We use this to set our Tailwind themes in DoltHub using environment variables, but there are many other applications, such as letting users define their own colors and themes.

Have questions or feedback? Find me (@taylorb) in our Discord.

SHARE

JOIN THE DATA EVOLUTION

Get started with Dolt

Or join our mailing list to get product updates.