shadcn/ui generates when you initialise a new project:
// tailwind.config.ts
import type { Config } from 'tailwindcss';
export default {
darkMode: ['class'],
content: [
'./src/**/*.{js,ts,jsx,tsx}',
'./node_modules/@acme-ds/core/dist/**/*.{js,ts,jsx,tsx}',
],
prefix: '',
theme: {
container: {
center: true,
padding: '2rem',
screens: {
'2xl': '1400px',
},
},
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
keyframes: {
'accordion-down': {
from: { height: '0' },
to: { height: 'var(--radix-accordion-content-height)' },
},
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: '0' },
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
},
},
},
plugins: [require('tailwindcss-animate')],
} satisfies Config;
/* tailwind.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
Compared to just this with our preset:
// tailwind.config.ts
import { tokens } from '@acme-ds/brands/my-brand';
import { createPreset } from '@acme-ds/core/tailwind';
import { type Config } from 'tailwindcss';
export default {
content: [
'./node_modules/@acme-ds/core/src/**/*.{js,ts,jsx,tsx}',
'./src/**/*.{js,ts,jsx,tsx}',
],
presets: [createPreset(tokens)],
} satisfies Config;
/* tailwind.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
Tailwind’s default theme is extensive but not overly opinionated. This is great for websites and small apps where getting started quickly is important, but for a design system, you’ll likely have strong opinions on things like colours and typography, so turning off many of Tailwind’s defaults is probably something you’ll want to do.
While Tailwind lets you disable built-in plugins using the corePlugins
key, it’s best to avoid this. We wanted developers already familiar with Tailwind to use our design system without having to learn a bunch of new conventions, so keeping the same prefixes was important. Also, turning off core plugins means losing features like arbitrary variants.
Instead, the best thing to do is to remove the defaults from the theme object. If you create your preset function, you can even add flags to turn features on or off. This is useful if you want to incrementally add the design system to an app that’s already using Tailwind.
export function createPreset(
tokens: Tokens,
{
shouldExcludeBaseStyles = false,
shouldUseTailwindBorderRadius = false,
shouldUseTailwindBoxShadows = false,
shouldUseTailwindColors = false,
shouldUseTailwindFontSize = false,
shouldUseTailwindFontWeight = false,
}: CreatePresetOptions = {}
): Config {
return {
content: [],
theme: {
...defaultTheme,
boxShadow: shouldUseTailwindBoxShadows ? defaultTheme.boxShadow : {},
boxShadowColor: shouldUseTailwindBoxShadows ? defaultTheme.boxShadowColor : {},
fontSize: shouldUseTailwindFontSize ? defaultTheme.fontSize : {},
fontWeight: shouldUseTailwindFontWeight ? defaultTheme.fontWeight : {},
...getBorderRadiusThemeConfig(tokens, { shouldUseTailwindBorderRadius }),
...getColorThemeConfig({ shouldUseTailwindColors }),
},
plugins: [
// ...
],
};
}
Tailwind plugins are a great way to add new styles. Don’t be afraid to reach for them when you need them. You even get IntelliSense for plugins you create, so the developer experience is still excellent.
Tailwind styles are split into base
, component
, and utility
layers. In Tailwind, the base
layer is for resets, global styles, and custom properties. The component
layer targets multiple CSS properties, and the utility
layer (with its higher specificity) overrides properties from the previous layers. Tailwind’s plugin function gives you a callback for adding styles to a layer, e.g. addComponents
for the component
layer.
Here are some examples of useful plugins we created:
We created a typography plugin where we kept the familiar text-
prefix, turned off Tailwind’s defaults, and set up styles for font-family
, font-size
, font-weight
, line-height
, and letter-spacing
together, for both regular text and headings. This gave us a robust set of styles and reduced the need for a <Text>
component.
export function createTypographyPlugin(typography: GlobalTokens["typography"]) {
return plugin(({ addComponents, addUtilities }) => {
const headingUtilities = Object.fromEntries(
Object.entries(typography.heading).map(([key, value]) => [
`.text-heading-${key}`,
{
fontFamily: [
typography.fontFamily.heading,
typography.fontFamily.fallback,
].join(", "),
fontSize: value.fontSize,
fontWeight: value.fontWeight,
lineHeight: value.lineHeight,
letterSpacing: value.letterSpacing,
},
])
);
const bodyUtilities = Object.fromEntries(
Object.entries(typography.body).map(([key, value]) => [
`.text-body-${key}`,
{
fontFamily: [
typography.fontFamily.body,
typography.fontFamily.fallback,
].join(", "),
fontSize: value.fontSize,
fontWeight: value.fontWeight,
lineHeight: value.lineHeight,
letterSpacing: value.letterSpacing,
},
])
);
addComponents({
...headingUtilities,
...bodyUtilities,
});
addUtilities({
".font-regular": {
fontWeight: typography.fontWeight.regular,
},
".font-strong": {
fontWeight: typography.fontWeight.strong,
},
".font-stronger": {
fontWeight: typography.fontWeight.stronger,
},
});
});
}
Tip: Use custom properties for font weights to style them differently between brands. For example, you might want bold headings in one brand and lighter headings in another.
Next, we developed a colour plugin to manage colour schemes. By using custom properties for our colours, users don’t need to write any extra code to support switching between light and dark modes. We use both a prefers-color-scheme
media query and .light
/.dark
classes so you can let users of your app manually set their preferred colour scheme or have it automatically switch based on system settings.
export function createColorPlugin(
colorSchemes: Tokens['colorSchemes'],
{ shouldExcludeBaseStyles }: { shouldExcludeBaseStyles: boolean }
) {
return plugin(({ addBase }) => {
if (!shouldExcludeBaseStyles) {
const colors = getColors(colorSchemes);
addBase({
':root': {
colorScheme: 'light dark',
...colors.light,
},
'@media (prefers-color-scheme: dark)': {
':root': {
colorScheme: 'dark',
...colors.dark,
},
},
'.light': {
colorScheme: 'light',
...colors.light,
},
'.dark': {
colorScheme: 'dark',
...colors.dark,
},
});
}
});
}
Tip: Set a color-scheme along with dark/light mode classes and media queries so browser chrome elements like scrollbars and native selects match your design.
We also created a focus ring plugin to ensure consistent focus styles across our components. This is a component class since it targets lots of CSS properties. It handles different states and improves accessibility by providing clear visual feedback for focusable elements.
export const focusRingPlugin = plugin(({ addComponents, theme }) => {
addComponents({
'.focus-ring': {
outline: 'none',
'--tw-ring-color': theme('borderColor.focus'),
'--tw-ring-offset-color': theme('backgroundColor.neutral'),
'--tw-ring-offset-width': theme('ringWidth.1'),
'--tw-ring-offset-shadow':
'0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)',
'--tw-ring-shadow':
'0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color)',
'box-shadow':
'var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 rgb(0 0 0 / 0))',
'@media (-ms-high-contrast: active), (forced-colors: active)': {
'outline-color': theme('outlineColor.transparent'),
'outline-offset': theme('outlineOffset.2'),
'outline-style': 'solid',
'outline-width': theme('outlineWidth.2'),
},
},
'.no-focus-ring': {
outline: 'revert',
'box-shadow': 'revert',
'@media (-ms-high-contrast: active), (forced-colors: active)': {
'outline-color': 'revert',
'outline-offset': 'revert',
'outline-style': 'revert',
'outline-width': 'revert',
},
},
});
});
Tip: Creating the .focus-ring
class with Tailwind’s plugin system means that it works for all valid Tailwind variants, e.g., using it with has-[:focus]:
for a radio card.
Like anything in web development, the answer is “it depends.” The decision to use Tailwind should be based on your project’s specific needs and your team’s preferences and capabilities.
When we started building this design system, there weren’t many projects using Tailwind for this kind of work that we could reference. That’s not the case anymore. If you’re considering building a design system using Tailwind, I’d highly recommend checking out the following:
Overall, using Tailwind on this project was a great experience, and I think it’s a solid choice for almost any application. It’s also proving to be a dominant force in the CSS framework space, and I’m confident it will remain that way for some time to come. With version 4 just around the corner, it’s only going to get better!
That said, alternatives like Emotion remain strong contenders. There are also a ton of great new libraries popping up all the time. Some promising options worth exploring include StyleX, Panda CSS, Tokenami, CSS-Hooks, and pigment-css. Each of these libraries brings its own unique features and philosophies to the table, potentially addressing specific pain points or aligning better with your team’s preferences.
If you’re considering using Tailwind for your design system, I hope this article has given you some valuable insights and tips to help you make the most of it.
article
· 11 min readarticle
· 12 min readtalk
article
· 11 min readtalk
talk
article
· 11 min readarticle
· 12 min readtalk
article
· 11 min readtalk
talk
article
· 11 min readarticle
· 12 min readarticle
· 11 min readtalk
Have a chat with one of our co-founders, Jed or Boris, about how Thinkmill can support your organisation’s software ambitions.
Contact us