50% off SaaS Starter Kit — only for the first 100 buildersGrab it →
← Back to blog
best-practicesMay 21, 2026·8 min read

Tailwind CSS Tips That Senior Devs Actually Use

Not the docs rehash. The tricks that make your Tailwind code readable, maintainable, and fast — learned from shipping real products.

Ștefan Binisor

Ștefan Binisor

Co-founder, peal.dev

Tailwind CSS Tips That Senior Devs Actually Use

Every Tailwind tutorial shows you how to center a div. Useful, once. What they don't show you is why your className strings are 200 characters long and why your teammate is quietly updating their CV. We've been building with Tailwind since before it had JIT mode — back when purging CSS was a thing you did and hoped for the best. These are the patterns that actually survived contact with real codebases.

Stop Writing Inline Conditionals in className

The first sign of a Tailwind codebase in trouble: ternaries and template literals tangled directly in the className prop. You've seen it. You've written it. We've absolutely written it at 11pm when a deadline was close.

// This is how bugs hide
<button
  className={`px-4 py-2 rounded font-medium ${
    isLoading
      ? 'bg-gray-400 cursor-not-allowed'
      : isPrimary
      ? 'bg-blue-600 hover:bg-blue-700 text-white'
      : 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50'
  } ${disabled ? 'opacity-50' : ''}`}
>
  {children}
</button>

Use `clsx` (or `cn` from shadcn which wraps `clsx` with `tailwind-merge`). It's not just cleaner — it handles class conflicts properly. `tailwind-merge` is the important part: without it, if you pass both `p-4` and `p-6`, you get both classes in the DOM and only one wins based on specificity order in the CSS file, which is not intuitive.

import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

// Now your button becomes readable
<button
  className={cn(
    'px-4 py-2 rounded font-medium transition-colors',
    isLoading && 'bg-gray-400 cursor-not-allowed',
    !isLoading && isPrimary && 'bg-blue-600 hover:bg-blue-700 text-white',
    !isLoading && !isPrimary && 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50',
    disabled && 'opacity-50'
  )}
>
  {children}
</button>

Every component in every serious Tailwind project should have access to this `cn` utility. If you're copy-pasting it across projects, put it in your shared utils once and never think about it again.

Use CVA for Component Variants, Not Ad-Hoc Props

Class Variance Authority (CVA) is what happens when someone got tired of maintaining six versions of a Button component with slightly different class strings. The idea is simple: define your variants declaratively, and the library handles which classes to apply. Senior devs reach for this the moment a component has more than two variants.

import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'

const buttonVariants = cva(
  // Base styles — always applied
  'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50',
  {
    variants: {
      variant: {
        default: 'bg-blue-600 text-white hover:bg-blue-700',
        outline: 'border border-gray-300 bg-white text-gray-700 hover:bg-gray-50',
        ghost: 'text-gray-700 hover:bg-gray-100',
        destructive: 'bg-red-600 text-white hover:bg-red-700',
      },
      size: {
        sm: 'h-8 px-3 text-sm',
        md: 'h-10 px-4 text-sm',
        lg: 'h-12 px-6 text-base',
      },
    },
    defaultVariants: {
      variant: 'default',
      size: 'md',
    },
  }
)

interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {}

export function Button({ className, variant, size, ...props }: ButtonProps) {
  return (
    <button
      className={cn(buttonVariants({ variant, size }), className)}
      {...props}
    />
  )
}

The `className` override at the end is intentional — it lets consumers extend the component without forking it. This pattern plays really well with TypeScript since VariantProps gives you full autocomplete on the variant and size props.

Design Tokens Live in tailwind.config, Not in Arbitrary Values

Arbitrary values — `text-[#1a2b3c]`, `mt-[37px]`, `w-[calc(100%-2rem)]` — are a necessary escape hatch. They're not a design system. We've seen codebases where every component has different shades of blue because nobody set up a proper palette in the config. Six months later, a rebrand request arrives and you're doing a full-text search for hex codes.

// tailwind.config.ts
import type { Config } from 'tailwindcss'

const config: Config = {
  theme: {
    extend: {
      colors: {
        brand: {
          50: '#eff6ff',
          100: '#dbeafe',
          500: '#3b82f6',
          600: '#2563eb',
          700: '#1d4ed8',
          900: '#1e3a5f',
        },
        surface: {
          primary: 'hsl(var(--surface-primary))',
          secondary: 'hsl(var(--surface-secondary))',
        },
      },
      fontFamily: {
        sans: ['var(--font-geist-sans)', 'system-ui', 'sans-serif'],
        mono: ['var(--font-geist-mono)', 'monospace'],
      },
      borderRadius: {
        DEFAULT: '0.375rem',
        card: '0.75rem',
      },
    },
  },
}

export default config

The CSS variable approach (`hsl(var(--surface-primary))`) is worth understanding. It lets you support dark mode by just swapping the CSS variable values, without duplicating Tailwind classes everywhere. You define the variable in your root CSS, and Tailwind picks it up. Dark mode becomes a matter of updating the variable, not hunting down every `bg-white` and adding a `dark:bg-gray-900`.

Responsive Design That Doesn't Make You Insane

Tailwind's mobile-first breakpoints are genuinely good. The mistake people make is applying them inconsistently. You end up with a component that has `text-sm md:text-base` in one place and `text-base` everywhere else, and the design just drifts. A few rules that keep this manageable:

  • Always start mobile: write the base class for small screens, then add breakpoint prefixes as overrides. `p-4 md:p-6 lg:p-8` — reads naturally.
  • Use `max-md:` (max-width variants) sparingly. When you mix min-width and max-width breakpoints in the same class string, you're buying yourself a debugging session.
  • Extract repeated responsive patterns into components. If you have `text-2xl md:text-3xl lg:text-4xl font-bold` on every page heading, that's a PageHeading component waiting to be born.
  • The `container` class alone isn't enough — configure its padding in tailwind.config: `container: { center: true, padding: { DEFAULT: '1rem', md: '2rem' } }`. One line instead of `mx-auto max-w-7xl px-4 md:px-8` everywhere.

The Plugin System Is Underused

Most Tailwind users treat `tailwind.config` as a place to add colors and call it done. The plugin API is where you add actual utilities that your project needs repeatedly. Say you're building a dashboard with a sidebar layout. You'll write that layout five times in five different files. Write it once as a plugin.

// tailwind.config.ts
import plugin from 'tailwindcss/plugin'

const config = {
  plugins: [
    // Add custom utilities
    plugin(({ addUtilities, addComponents, theme }) => {
      addUtilities({
        '.scrollbar-hide': {
          '-ms-overflow-style': 'none',
          'scrollbar-width': 'none',
          '&::-webkit-scrollbar': { display: 'none' },
        },
        '.text-balance': {
          'text-wrap': 'balance',
        },
      })

      addComponents({
        '.dashboard-layout': {
          display: 'grid',
          gridTemplateColumns: '16rem 1fr',
          minHeight: '100vh',
          [`@media (max-width: ${theme('screens.md')})`]: {
            gridTemplateColumns: '1fr',
          },
        },
      })
    }),
  ],
}

`.scrollbar-hide` and `.text-balance` are things we add to every project now. `text-wrap: balance` is one of those CSS properties that makes headings look immediately more professional on small screens. It's available in all modern browsers and nobody talks about it enough.

Group and Peer Modifiers Are Underrated

These two features solve so many JavaScript-based state problems that you'll feel slightly embarrassed you weren't using them earlier. `group` lets you style children based on parent state. `peer` lets you style siblings based on sibling state.

// Without group — you'd need JS state for hover effects on children
// With group — pure CSS, no state needed
<div className="group relative overflow-hidden rounded-card border border-gray-200 p-6 hover:border-brand-500 transition-colors">
  <h3 className="font-semibold text-gray-900">{title}</h3>
  <p className="mt-2 text-sm text-gray-500">{description}</p>
  {/* This arrow only appears when the parent is hovered */}
  <span className="absolute right-4 top-4 opacity-0 group-hover:opacity-100 transition-opacity">
    →
  </span>
</div>

// Peer: style an element based on a sibling input's state
<div>
  <input
    type="checkbox"
    id="toggle"
    className="peer sr-only"
  />
  <label
    htmlFor="toggle"
    className="block h-6 w-10 rounded-full bg-gray-300 cursor-pointer peer-checked:bg-brand-600 transition-colors"
  />
</div>

The `peer` pattern is particularly useful for accessible form validation feedback. Style an error message with `peer-invalid:block hidden` on the message element, and it shows automatically when the input's validity changes — no JavaScript event handlers required.

Sorting and Linting Your Classes

This sounds boring until you're in a code review trying to figure out why `flex` is at the end of a 30-class string instead of the beginning. `prettier-plugin-tailwindcss` is the answer. It sorts classes in a consistent order that follows how the CSS actually outputs. Install it, configure it once, never argue about class order again.

// .prettierrc
{
  "plugins": ["prettier-plugin-tailwindcss"],
  "tailwindConfig": "./tailwind.config.ts",
  "tailwindFunctions": ["cn", "cva", "clsx", "twMerge"]
}

// The tailwindFunctions option is important!
// Without it, the plugin won't sort classes inside your cn() calls
// — only direct className strings

The `tailwindFunctions` array tells the plugin to also sort classes inside those function calls. If you're using `cn()` or `cva()`, you need this or half your classes stay unsorted. We learned this about three months after adding the plugin, which means we had three months of partially sorted classes that looked sorted.

The prettier-plugin-tailwindcss sorts by the order classes appear in the compiled CSS — layout first, then typography, then visual. It's not arbitrary. Once you internalize this order, you can scan className strings much faster.

When to Extract a Component vs. When to Use @apply

This is the debate that never dies. The Tailwind docs say almost never use `@apply`. They're mostly right, but 'mostly' is doing a lot of work in that sentence. Our actual rule: if the same class combination appears in more than two places AND it doesn't need props/variants, extract it with `@apply` in your CSS. If it needs any kind of dynamic behavior, make it a React component.

The one place `@apply` genuinely shines is with third-party HTML you can't control — think a markdown renderer outputting raw HTML tags. You can't add className props to what a markdown parser produces, but you can write `.prose h2 { @apply text-xl font-semibold mt-8 mb-4 }` and get consistent styling. For everything else, components are better — you get TypeScript props, stories, tests, and a single source of truth.

If you're starting from a template rather than from scratch, a lot of these patterns come pre-baked. The peal.dev templates ship with `cn`, CVA-based components, and the prettier plugin already wired up — so you're not spending your first two hours setting up utilities before you can write a single feature.

The One Mistake That Kills Bundle Size

Tailwind only includes classes it finds in your source files. This is great — except when you build class names dynamically by concatenating strings. Tailwind's scanner is looking for complete class strings, not building them.

// ❌ Tailwind can't see these — they'll be missing from your bundle
const color = 'blue'
const size = '4'
return <div className={`text-${color}-600 p-${size}`} />

// ✅ Use a lookup map with complete class strings
const colorMap = {
  blue: 'text-blue-600',
  red: 'text-red-600',
  green: 'text-green-600',
} as const

const sizeMap = {
  sm: 'p-2',
  md: 'p-4',
  lg: 'p-6',
} as const

return <div className={cn(colorMap[color], sizeMap[size])} />

This trips people up most often with dynamic color theming — like user-configurable brand colors. If you need truly dynamic Tailwind classes at runtime, the workaround is to use CSS variables for the values and a fixed Tailwind class for the property: `text-[var(--brand-color)]` works because the class string is static.

Complete class names must appear as unbroken strings somewhere in your source for Tailwind to include them. When in doubt, add a safelist in tailwind.config for classes you know you're building dynamically.

None of these tips require a big refactor. Pick one: add `cn` and `tailwind-merge` today, set up prettier-plugin-tailwindcss tomorrow, migrate one component to CVA on Friday. The compounding effect of these small improvements is a Tailwind codebase that new teammates can actually navigate — which matters more than any single technique.

Newsletter

Liked this post? There's more where it came from.

Dev guides, honest build stories, and the occasional 2am debugging confession — straight to your inbox. No spam, unsubscribe anytime.

Browse templates
Written by humansWeekly dropsSubscriber perks

Join the Discord

Ask questions, share builds, get help from founders