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
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/tailwindSome slides have callouts
Information presented is more relevant to UX/VD professionals.
Advanced or Niche topic. Safe to skip.
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
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
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.
A good spacing scale?
Tailwind default
ageorge.dev
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
1 2 3 4 5 6 7 8 9 10 11 12export function Card({ className, ...otherProps }: React.HTMLProps<HTMLDivElement>) { return ( <div {...otherProps} className={`shadow border-gray border-2 mb-4 ${className ?? ''}`} /> ); }
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>
Best of both worlds
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16type 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.
A plugin for typography
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20plugin(({ 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 8plugin(({ 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>
match* plugins
1 2 3 4 5 6 7 8 9 10 11 12 13const 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 20plugins: [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" ], }, }, },
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)", } } }, };
Using fallbacks for more flexibility
1 2 3 4 5primary: { 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%))", }
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!
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 3primary: { 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 { --page-far: var(--rc-parchment-500); --accent: var(--rc-p-accent-500); --neutral: var(--rc-d-neutral-500); --neutral-inverse: var(--rc-l-neutral-500); }
1 2 3 4 5 6:root.dark { --page-far: var(--rc-timber-300); --accent: var(--rc-p-accent-300); --neutral: var(--rc-l-neutral-500); --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(--page-far)', 'cc-accent': 'var(--accent)', 'cc-neutral': 'var(--neutral)', //... others } } }
1 2 3<section class="bg-page-far text-neutral"> <button class="bg-accent">Click me</button> </section>
The biggest problem with Tailwind
It allows all combinations!
1 2 3 4<button class="text-page-far">Click me</button> <div class="bg-neutral"> <!-- other stuff --> </div>
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 10import 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
Prior to v3.3, the tokens file needs to be commonJS
Post v3.3, tailwind supports ESM config file
bonus
A CSS-in-JS library that works well with Tailwind CSS...
Vanilla Extract
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