Beyond prototyping with Tailwind CSS

Advanced strategies for production

Anish

George

Lead UI Developer

Built a design system in Angular + Tailwind

This site is in Remix + Tailwind + Vanilla-Extract

https://ageorge.dev

Why should you care?

Tailwind has been the subject of much controversy and you are curious!

You want/need/have to use it...

Tailwind has pretty much "won" the CSS-framework contest

What I'm assuming of you

Good with CSS

Maybe have tried Tailwind CSS before

Knowledgeable on either React or Angular. Or else some other component based fremework.

How I roll!

Slides available at

https:// ageorge.dev /talks/tailwind

Some slides have callouts

UI/UX

Information presented is more relevant to UX/VD professionals.

Advanced

Advanced or Niche topic. Safe to skip.

Opinion

My subjective opinion. I may be utterly wrong about this.

Topics covered

Fundamentals of Tailwind CSS

Anti-patterns

Lean configuration

Cognitive load

Componentisation

Plugins

Theming with overrides

Dark mode

CSS-in-JS

Fundamentals of Tailwind

The quick version

Utility first fundamentals

1
2
3
4
<button class="text-white text-lg bg-blue hover:bg-grey
    rounded px-2 mb-2">
  Click Me!
</button>

Each class basically maps to a single css property-value pair

Lots of css classes needed

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
.text-white { color: white; }

.text-lg { font-size: 1.25rem; }

.bg-blue {
  background-color: #3490dc;
}

.hover\:bg-grey:hover {
  background-color: #e8e8e8;
}

.px-2 {
  padding-left: 0.5rem;
  padding-right: 0.5rem;
}

.mb-2 {
  margin-bottom: 0.5rem;
}

How does Tailwind compare?

A typical CSS framework

1
2
3
<button class="btn btn-primary">
  Cick Me!
</button>

Individual classes encapsulate full sets of styles

Need both HTML and CSS to author the UI

Clean HTML

Styles hidden away

Only layout based CSS skills needed

Tailwind CSS

1
2
3
4
<button class="text-white text-lg bg-blue hover:bg-grey
    rounded px-2 mb-2">
  Click Me!
</button>

Classes are atomic. Each describes a specific CSS property-value pair

HTML becomes the authoring language

HTML overloaded with styling

Styles co-located with markup

CSS skills required. It is just a one-to-one mapping of CSS

Finite values through configuration

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// tailwind.config.js

export default {
  // ...
  content: ["./src/**/*.{html,js,jsx,ts,tsx}"]
  theme: {
    colors: {
      transparent: "transparent",
      white: "#ffffff",
      purple: "#3f3cbb",
      midnight: "#121063",
      "bubble-gum": "#ff77e9",
    },
    spacing: {
      1: "8px",
      2: "12px",
      3: "16px",
      large: '32px',
      massive: "70px",
    },
  },
}
1
2
3
4
5
6
7
<!-- view.html -->

<h1 class="mb-large text-purple">Hello world!</h1>
<button class="p-3">Click Me!</button>

<!-- p-10 and bg-blue are not valid classes, ignored -->
<button class="p-10 bg-blue">Another button</button>

These are foundations of a design system

Talk to your designer to fill this config!

Tailwind only generates the minimum CSS required

1
2
3
4
<!-- view.html -->

<h1 class="mb-large text-purple">Hello world!</h1>
<button class="p-3">Click Me!</button>
1
2
3
4
5
/* styles.css */

@tailwind base;
@tailwind components;
@tailwind utilities;
1
2
3
4
5
6
7
8
9
10
11
12
13
/* dist/styles.css */

/* ... reset and other styles */
.mb-large {
  margin-bottom: 32px;
}
.p-3 {
  padding: 16px;
}
.text-purple {
  --tw-text-opacity: 1;
  color: rgb(63 60 187 / var(--tw-text-opacity));
}

PostCSS magic

CSS file size stays to a minimum

CSS filesizeCodebase size

The built css file gets the same hash if no new classes are used. This means it gets cached by the user's browser!

And that was the quick version!

Visit https://tailwindcss.com to learn more.

Anti-patterns with Tailwind CSS

The @apply

1
2
3
4
5
6
7
8
9
10
/* style.css */

.my-btn {
  @apply text-lg text-white bg-blue-600 transition-colors;
  @apply px-3 py-2 rounded;
}

.my-btn:hover {
  @apply bg-blue-900;
}
1
2
3
<!-- view.html -->

<button class="my-btn">Click Me!</button>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/* dist/style.css */

.my-btn {
  --tw-bg-opacity: 1;
  background-color: rgb(37 99 235 / var(--tw-bg-opacity));
  font-size: 1.125rem;
  line-height: 1.75rem;
  --tw-text-opacity: 1;
  color: rgb(255 255 255 / var(--tw-text-opacity));
  transition-property: color, background-color, border-color,
    text-decoration-color, fill, stroke;
  transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
  transition-duration: 0.15s;
  border-radius: 0.25rem;
  padding: 0.5rem 0.75rem;
}
.my-btn:hover {
  --tw-bg-opacity: 1;
  background-color: rgb(30 58 138 / var(--tw-bg-opacity));
}

When to use @apply

The @apply directive is not a convenience construct. It has repurcussions

Use sparingly

When writing complicated styles for psuedo selectors in one-off scenarios

Creating small highly re-usable classes if creating components or plugins not a great option

Opinion

Arbitrary value notation

1
2
3
4
<div>
  <i class="fa-solid fa-magnifying-glass mt-[3px]"></i>
  <span>Search</span>
</div>
1
2
3
.mt-\[3px\] {
  margin-top: 3px;
}

But...

1
2
3
<div class="shadow-[0_35px_60px_-15px_rgba(0,0,0,0.3)]">
  Oh my god, my eyes!!
</div>

strategy #1

Lean Configuration

Extending Tailwind configuration

1
2
3
4
5
6
7
8
9
10
11
12
13
/* tailwind.config.js */

export default {
  // ...
  theme: {
    extend: {
      colors: {
        purple: "#3f3cbb",
        midnight: "#121063",
      },
    },
  },
};

Allows adding onto the existing Tailwind values

Easy for devs to use non-compliant design

Tailwind tends to have too many values

Look at their colors and spacing scale

Overriding Tailwind value sets

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* tailwind.config.js */

export default {
  // ...
  theme: {
    spacing: {
      1: "8px",
      2: "12px",
      3: "16px",
      large: "32px",
      massive: "70px",
    },
    // ... non-specified sets picked up
    // from tailwind defaults
  },
};

Limits the possible values to a strict design system

You are free to choose more meaninful property names

The tailwind documentation becomes partially useless,
you will need your own documentation describing all possible properties

Storybook can be a good solution

The earlier you move to custom sets, the better

Pro tip: Get a strong spacing scale from your designer before it is too late.
The Tailwind default is trash.

UI/UX

A good spacing scale?

Tailwind default

w-px (1px)
w-0.5 (2px)
w-1 (4px)
w-1.5 (6px)
w-2 (8px)
w-2.5 (10px)
w-3 (12px)
w-3.5 (14px)
w-4 (16px)
w-5 (20px)
w-6 (24px)
w-7 (24px)
w-8 (24px)
w-9 (36px)
w-10 (40px)
w-11 (44px)
w-12 (48px)
w-14 (56px)
w-16 (64px)
w-20 (80px)

ageorge.dev

w-px (1px)
w-0.5 (2px)
w-1 (4px)
w-2 (8px)
w-3 (12px)
w-4 (16px)
w-5 (24px)
w-6 (32px)
w-7 (48px)
w-8 (64px)
w-9 (72px)
w-10 (128px)
w-11 (192px)
w-12 (256px)
w-13 (384px)
w-14 (480px)
w-15 (640px)

Disable core plugins

1
2
3
4
5
6
7
8
9
10
/* tailwind.config.js */

export default {
  // ...
  corePlugins: {
    float: false,     // only modern layouts please
    zIndex: false,    // we don't mess with z-index
    animation: false  // will be custom anyway
  },
};

Core plugins provide the various css features that Tailwind supports

You can disable any of these plugins to stop tailwind from generating those classes

Allows you to restrict to CSS best practices and reduce confusion

You also have the option of disabling all core plugins and use Tailwind as an
engine to drive your plugins. More on that later

It is a good idea to prune out CSS practices you don't need

an important bit...

Cognitive load with Tailwind

With tailwind you need to remember 2 syntaxes

1
2
3
4
5
6
7
8
9
10
11
.imaginary-selector {

  color: hsl(83, 27%, 53%);
  font-weight: bold;
  text-decoration: underline;
  padding: 0.5rem;
  border: 1px solid hsl(83, 27%, 53%);
  display: flex;
  justify-content: center;

}
1
2
3
4
<div class="text-primary-200 font-bold underline p-2 
  border border-primary-200 flex items-center">
  <!-- -->
</div>

We are adding mental steps to the development process

Things get more complicated if we have heavily customised the configuration

How do we deal with this?

Step 1/2: Create components!

This reduces the amount of Tailwind most developers need to work on

If you have a strong enough design system with components, most devs only need to learn the layout classes in Tailwind

Step 2/2: Solid documentation through Storybook

A heavily customised config makes the Tailwind docs partially useless

Use Storybook to create documentation which allows devs to peruse and disover the design tokens available

Let me show you what I mean...

Not a silver bullet

Over time, devs adapt to it, but the cost is present

strategy #2

DRY tailwind styles

What do you do about this?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<div className="max-w-sm rounded overflow-hidden shadow-lg">
  <img className="w-full" src="http://dummy.com/img.jpeg" alt="User Avatar" />
  <div className="px-6 py-4">
    <h3 className="font-bold text-xl mb-3">
      John Doe
    </h3>
    <p className="text-gray-700 text-base mb-2">
      Professional Web developer with several years of experience
    </p>
    <p className="text-gray-700 text-base">
      Loves basketball
    </p>
  </div>
  <div className="px-6 py-4 flex gap-2">
    <span className="bg-gray-200 rounded-full px-3 py-1 text-sm text-gray-700">
      Web Developer
    </span>
    <span className="bg-gray-200 rounded-full px-3 py-1 text-sm text-gray-700">
      UI/UX Designer
    </span>
  </div>
</div>

Maintaining strings in Javascript

1
2
3
4
5
6
7
8
9
10
// DO NOT BREAK UP TAILWIND CLASS NAMES

const CommonStyles = "text-2xl bg-primary-100";

const MoreStyles = cls("shadow-sm px-2", otherStyles);

const Variants = {
  primary: 'bg-primary-300 hover:bg-primary-400',
  secondary: 'bg-secondary-200 hover:text-white'
}

Manage the tailwind classes in javascript like regular strings

Make sure you don't split the class names

Works pretty well in React

Works passably well in Angular. Bit more awkward getting the strings onto
the templates since they need to be part of the component class.

Componentisation

React
1
2
3
4
5
6
7
8
9
10
11
12
export function Card({ 
  className, ...otherProps
}: React.HTMLProps<HTMLDivElement>) {
  return (
    <div
      {...otherProps}

      className={`shadow border-gray border-2 mb-4 
      ${className ?? ''}`}
    />
  );
}
Angular
1
2
3
4
5
6
7
@Directive({
  selector: '[appCard]',
})
export class CardDirective {
  @HostBinding('class') classes 
    = 'shadow border-gray border-2 mb-4';
}
1
<section class='bg-grey' appCard>Notes</section>

Looks better already

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<Card>
  <img className="w-full" src="http://dummy.com/img.jpeg" alt="User Avatar" />
  <div className="px-6 py-4">
    <Heading2 className='mb-3'>John Doe</Heading2>
    <Body strength="subtle" className='mb-2'>
      Professional Web developer with several years of experience
    </Body>
    <Body strength="subtle">
      Loves basketball
    </Body>
  </div>
  <div className="px-6 py-4 flex gap-2">
    <SkillBadge>
      Web Developer
    </SkillBadge>
    <SkillBadge>
      UI/UX Designer
    </SkillBadge>
    <SkillBadge>Photographer</SkillBadge>
  </div>
</Card>
Opinion

Best of both worlds

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type Heading1Props = React.PropsWithChildren &
  React.HTMLAttributes<HTMLHeadingElement>;

function Heading1({ 
  className, children, ...otherProps 
}: Heading1Props) {
  return (
    <h1 {...otherProps} 
      className={Heading1.classes + ' ' + (className ?? '') }
    >
      {children}
    </h1>
  );
}

Heading1.classes = 'text-2xl font-bold md:text-3xl';
1
2
3
4
5
6
7
8
<Heading1>Hello There</Heading1>

<section 
  className={Heading1.classes + ' bg-blue-100 rounded'}>

  <p>A section with common styling</p>
  <span>Pretty nifty</p>
</section>

Similar can be somewhat achieved in Angular using
static fields in Components/Directives

strategy #3

Plugins

Understanding @layer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@tailwind base;
@tailwind components;
@tailwind utilities;

@layer components {
  .layout-master {
    grid-template-columns: minmax(200px, 2fr) 8fr;
    grid-template-rows: 100px 1fr 50px;
    grid-template-areas: "header  header"
                         "sidebar main"
                         "footer  footer";
  }

  .layout-master-stacked {
    grid-template-areas: "header"
                         "sidebar"
                         "main"
                         "footer";
  }

  .area-header { grid-area: header; }
  .area-main { grid-area: main; }
}

@layer moves your css into the right spot within tailwind stylesheet

@layer can only be used within the context of the core stylesheet

Classes in @layer only added to stylsheet if used and they work with modifiers

1
2
3
4
5
6
<section class="grid layout-master-stacked lg:layout-master">
  <header class="area-header">header</header>
  <section class="area-main">main</section>
  <aside class="area-sidebar">sidebar</aside>
  <footer class="area-footer">footer</footer>
</section>

Understanding plugins

1
2
3
4
5
6
7
8
9
10
11
/* tailwind.config.js */

const plugin = require('tailwindcss/plugin')

module.exports = {
  plugins: [
    plugin(function({ addUtilities, addComponents, e, config }) {
      // Add your custom styles here
    }),
  ]
}

Tailwind is built out of plugins

You can create your own plugins to enable your own kind of CSS generation

There are different types of plugins

We will only focus on a couple. Read the docs for more.

https://tailwindcss.com/docs/plugins

A plugin for typography

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
plugin(({ addBase, theme }) => {
  addBase({
    '.typo-h1': {
      fontSize: theme('fontSize.4xl'),
      fontFamily: theme('fontFamily.serif'),
    },
    '.typo-h2': {
      fontSize: theme('fontSize.3xl'),
      fontFamily: theme('fontFamily.serif'),
      letterSpacing: '0.05em',
    },
    '.typo-body': {
      fontSize: theme('fontSize.base'),
    },
    '.typo-interface': {
      fontSize: theme('fontSize.sm'),
      textTransform: 'uppercase',
    }
  })
})
1
2
3
<span class="typo-h2 lg:typo-h1">
  Some content
</span>

Your utilties can work with Tailwind modifiers!

Creating your own modifiers

1
2
3
4
5
6
7
8
plugin(({ addVariant }) => {
  // Overwrite the default invalid:* modifier to support angular classes
  addVariant('invalid', ['&.ng-invalid.ng-touched', '&:invalid']);

  // For group and peer modifiers
  addVariant('group-invalid', [':merge(.group).ng-invalid.ng-touched &', ':merge(.group):invalid &']);
  addVariant('peer-invalid', [':merge(.peer).ng-invalid.ng-touched ~ &', ':merge(.peer):invalid ~ &']);
});
1
<app-custom-form-control class="invalid:ring-red-500" ></app-custom-form-control>
Advanced

match* plugins

1
2
3
4
5
6
7
8
9
10
11
12
13
const gridPlugin = plugin(({ matchComponents, theme }) => {
  matchComponents(
    {
      layout: (value) => ({
        gridTemplateAreas: value.areas
          .map((area) => `"${area}"`).join(" "),
        gridTemplateColumns: value.columns,
        gridTemplateRows: value.rows,
      }),
    },
    { values: theme("layouts") }
  );
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
plugins: [gridPlugin],
theme: {
  layouts: {
    master: {
      areas: [
          "header header", 
          "sidebar main", 
          "footer footer"
      ],
      columns: "minmax(200px, 2fr) 8fr",
      rows: "100px 1fr 50px",
    },
    ["master-stacked"]: {
      areas: [
        "header", "sidebar", 
        "main", "footer"
      ],
    },
  },
},
Opinion

What to use?

@layer styles

If you are in a single application and need to build simple plugins

plugins

If you in a MonoRepo and need to support multiple projects

Tailwind presets allow you to share entire tailwind configurations including the plugins across projects easily

strategy #4

Theming using overrides

The problem with plain colors

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* tailwind.config.js */

export default {
  theme: {
    colors: {
      primary: {
        DEFAULT: "#5B8C5A",
        light: "#9CBF9B",
        dark: "#406440",
      },
      secondary: {
        DEFAULT: "#E3655B",
        light: "#ED9C96",
        dark: "#DC392E",
      }
    }
  },
};

Inflexible

You can't change colors at run-time.

Side note: HSL rocks!

1
2
3
4
5
6
7
.selector {
  /* hsl(<hue> <saturation> <lightness>) */
  color: hsl(205 30% 90%)

  /* hsl(<hue> <saturation> <lightness> / <alpha-value>) */
  background: hsl(205 30% 90% / .4)
}

How to make Tailwind more flexible?

1
2
3
4
5
6
7
8
/* styles.css */
@layer base {
  :root {
    --color-primary-light: hsl(321 12% 48%);
    --color-primary-base: hsl(321 12% 29%);
    --color-primary-dark: hsl(119 22% 44%);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* tailwind.config.js */

export default {
  theme: {
    colors: {
      primary: {
        DEFAULT: "var(--color-primary-base)",
        light: "var(--color-primary-light)",
        dark: "var(--color-primary-dark)",
      },
      secondary: {
        DEFAULT: "var(--color-secondary-base)",
        light: "var(--color-secondary-light)",
        dark: "var(--color-secondary-dark)",
      }
    }
  },
};
Advanced

Using fallbacks for more flexibility

1
2
3
4
5
primary: {
  DEFAULT: "var(--color-primary-base, hsl(321 12% 29%))",
  light: "var(--color-primary-light, hsl(321 12% 48%))",
  dark: "var(--color-primary-dark, hsl(119 22% 44%))",
}
Advanced

About color opacity modifiers

1
<div class='bg-primary-dark/30 text-neutral/60'>...</div>

This fails with our vars setup!

It is debatable whether color opacity is a good practice from a design perspective

Talk to your designer!

Advanced

We can make use of color channels!

1
2
3
:root {
  --color-primary-base: 207 49% 65%;
}

You actually might want to hardcode opacities into colors instead

Talk to your designer!

1
2
3
primary: {
  DEFAULT: "hsl( var(--color-primary-base) / <alpha-value>)",
}

strategy #5

Dark mode

Full Tailwind support

1
2
3
4
5
/* tailwind.config.js */
export default {
  darkMode: 'class'
  // ... rest of the config
}
1
2
3
<html class="dark">
<!-- ... -->
</html>
1
2
3
<button class="bg-primary-100 dark:bg-primary-800 dark:text-white">
  Click me
</button>

We can do better

Raw Colors

Parchment

Timber

Accent

Neutral

Contextual Colors

Light Mode

Page far

Page near

Accent

Neutral text

Inverse text

Dark Mode

Page far

Page near

Accent

Neutral text

Inverse text

All together now...

1
2
3
4
5
6
7
8
9
10
:root {
  --rc-parchment-500: hsl(39 100% 89%);
  --rc-timber-300: hsl(189, 31%, 13%);

  --rc-p-accent-300: hsl(83 27% 53%);
  --rc-p-accent-500: hsl(83 49% 30%);

  --rc-d-neutral-500: hsl(189 67% 6%);
  --rc-l-neutral-500: hsl(45 100% 99%);
}
1
2
3
4
5
6
:root {
  --cc-page-far: var(--rc-parchment-500);
  --cc-accent: var(--rc-p-accent-500);
  --cc-neutral: var(--rc-d-neutral-500);
  --cc-neutral-inverse: var(--rc-l-neutral-500);
}
1
2
3
4
5
6
:root.dark {
  --cc-page-far: var(--rc-timber-300);
  --cc-accent: var(--rc-p-accent-300);
  --cc-neutral: var(--rc-l-neutral-500);
  --cc-neutral-inverse: var(--rc-d-neutral-500);
}

and a straightforward Tailwind config

1
2
3
4
5
6
7
8
9
10
11
12
/* tailwind.config.js */

export default {
  theme: {
    colors: {
      'cc-page-far': 'var(--cc-page-far)',
      'cc-accent': 'var(--cc-accent)',
      'cc-neutral': 'var(--cc-neutral)',
      //... others
    }
  }
}
1
2
3
<section class="bg-cc-page-far text-cc-neutral">
  <button class="bg-cc-accent">Click me</button>
</section>

The biggest problem with Tailwind

It allows all combinations!

1
2
3
4
<button class="text-cc-page-far">Click me</button>
<div class="bg-cc-neutral">
  <!-- other stuff -->
</div>
Opinion

a final word on colors

If it gets any more complicated, don't use Tailwind's color system

strategy #6

Tailwind CSS + CSS-in-JS

Why?...

Let application logic manipulate CSS safely using design system tokens

Migrating to tailwind from an existing system

Some things are better done without Tailwind?

You want the flexibility

Tailwind approved support

1
2
3
4
5
6
7
8
9
10
import resolveConfig from 'tailwindcss/resolveConfig'
import tailwindConfig from './tailwind.config.js'

const fullConfig = resolveConfig(tailwindConfig)

fullConfig.theme.width[4]
// => '1rem'

fullConfig.theme.screens.md
// => '768px'

Using this includes a lot of tailwind dependencies into the client bundle

There are ways around this, but be careful

What if we don't want Tailwind to
own our Design Tokens?

A basic token pipeline

design-tokens.jstailwind.config.jsCSS-in-JSadapterCSS-in-JS

Prior to v3.3, the tokens file needs to be commonJS

Post v3.3, tailwind supports ESM config file

Opinion

bonus

A CSS-in-JS library that works well with Tailwind CSS...

Vanilla Extract

https://vanilla-extract.style

Opinion

Final thoughts

I am a CSS expert, I don't need Tailwind!

Will your docs be as comprehensive?

Will build tools automatically exclude unused styles?

Will your dev team already have experience with your CSS approach?

In fact, most of the time, we build an inferior Tailwind ourselves...

Tailwind is not a CSS framework..

it is a framework to build your own CSS framework

that's all folks!

Thank You

https://ageorge.dev/talks/tailwind