A Tailwind CSS (v3) preset for your design system

Whether you like Tailwind CSS or not, there will always be developers who choose to use Tailwind CSS. Instead of forcing their hand to write CSS with your design token, why not provide a Tailwind CSS preset that uses the design tokens from your design system?

That’s exactly what we’ve done at Spendesk for our design system, we created a Tailwind CSS preset using our design token.

Tailwind CSS config

Build a preset is exactly like customizing your own Tailwind CSS configuration. In that sence, there are two ways to add variables: either extend the default values or override them. We chose to override the default values but remember that you can extend the theme provided.

To get started, we need a new file to contain our preset: tailwind.ts.

Here is a snippet from our:

export default {
  theme: {
    borderRadius: {
      4: 'var(--border-radius-4)',
      8: 'var(--border-radius-8)',
      12: 'var(--border-radius-12)',
    },
    spacing: {
      0: '0px',
      4: 'var(--unit-4)',
      8: 'var(--unit-8)',
      12: 'var(--unit-12)',
      16: 'var(--unit-16)',
      20: 'var(--unit-20)',
      24: 'var(--unit-24)',
      32: 'var(--unit-32)',
      40: 'var(--unit-40)',
      48: 'var(--unit-48)',
      56: 'var(--unit-56)',
      64: 'var(--unit-64)',
    },
    textColor: {
      'primary': 'var(--color-content-primary)',
      'secondary-bg-primary': 'var(--color-content-secondary-bg-primary)',
      'secondary-bg-secondary': 'var(--color-content-secondary-bg-secondary)',
      'complementary': 'var(--color-content-complementary:)',
      'selected': 'var(--color-content-selected)',
      'disabled': 'var(--color-content-disabled)',
      'brand-default': 'var(--color-content-brand-default)',
      'brand-hover': 'var(--color-content-brand-hover)',
      'brand-pressed': 'var(--color-content-brand-pressed)',
      'info-default': 'var(--color-content-info-default)',
      'success-default': 'var(--color-content-success-default)',
      'warning-default': 'var(--color-content-warning-default)',
      'alert-default': 'var(--color-content-alert-default)',
    },
  },
};

You may note, we used textColor instead of color. This is because we have different design token for text, background, and borders. Fortunately, Tailwind CSS allows us to make this distinction. So the rest of the file contains backgroundColor and borderColor.

If you want to know more, the documentation can be found here: https://v3.tailwindcss.com/docs/configuration.

Plugins

In addition to overriding the default Tailwind CSS values, we also wanted to register some custom utilities to make writing Tailwind CSS as easy as writing regular CSS for typography.

The following code adds utilities named title-s through title-xxl to the Tailwind CSS configuration. These utilities will then use the appropriate design tokens from our design system.

export default {
  theme: {
    title: {
      s: 's',
      m: 'm',
      l: 'l',
      xl: 'xl',
      xxl: 'xxl',
    },
  },
  plugins: [
    plugin(({ addUtilities, theme }) => {
      const values = theme('title');

      const baseSelectors = Object.entries(values).reduce(
        (acc, [key, value]) => {
          acc[`.title-${key}`] = {
            font: `var(--title-${value})`,
          };
          return acc;
        },
        {},
      );

      addUtilities(baseSelectors);
    }),
  ],
};

Let’s break down the code step by step.

First, we have a new property called title inside the theme object. Here, we register the different values for this property.

export default {
  theme: {
    title: {
      s: 's',
      m: 'm',
      l: 'l',
      xl: 'xl',
      xxl: 'xxl',
    },
  },
};

Now, in the plugin section, we declare a new plugin and retrieve the values of title from the theme.

export default {
  plugins: [
    plugin(({ addUtilities, theme }) => {
      const values = theme('title');
    }),
  ],
};

Next, we loop over the values to build an object in the format { properties: CSS code } and pass it to the Tailwind CSS function addUtilities.

export default {
  plugins: [
    plugin(({ addUtilities, theme }) => {
      const values = theme('title');

      const baseSelectors = Object.entries(values).reduce(
        (acc, [key, value]) => {
          acc[`.title-${key}`] = {
            font: `var(--title-${value})`,
          };
          return acc;
        },
        {},
      );

      addUtilities(baseSelectors);
    }),
  ],
};

You can find the documentation about Tailwind CSS plugin here: https://v3.tailwindcss.com/docs/plugins.

Build and publish

Our design system is powered by Vite, so we naturally used Vite to build our preset. We created a new Vite configuration file called vite.tailwind.config.ts that looks like this:

import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    target: 'node22',
    sourcemap: false,
    lib: {
      entry: 'src/tailwind.ts',
      fileName: 'tailwind',
      // Unfortunately, we needed to build to cjs for the main spendesk repo
      formats: ['cjs'],
    },
    rollupOptions: {
      // Prevent tailwindcss to be included in the build
      external: (dependency) => /^tailwindcss/.test(dependency),
    },
  },
});

Then we created a new export called tailwind inside our package.json

"exports": {
  // Other exports...

  "./tailwind": "./dist/tailwind.js",
},

Usage

Once published, any consumer can get the preset using the new exports.

Here is how our main frontend at Spendesk consumes the preset:

import type { Config } from 'tailwindcss';
import preset from '@dev-spendesk/grapes/tailwind';

export default {
  // [...]
  presets: [preset],
} satisfies Config;