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

Tailwind CSS Tips That Senior Devs Actually Use (Not the Docs Stuff)

Beyond utility classes and responsive prefixes — the Tailwind patterns that make codebases maintainable at scale.

Ștefan Binisor

Ștefan Binisor

Co-founder, peal.dev

Tailwind CSS Tips That Senior Devs Actually Use (Not the Docs Stuff)

Everyone knows about `flex`, `gap-4`, and `text-sm`. The Tailwind docs cover those in the first five minutes. What they don't cover is what happens six months into a project when your JSX files look like ransom notes and you've copy-pasted the same 14 classes across 30 components. That's where actual Tailwind skill shows up.

We've built enough Next.js projects at this point to have strong opinions about what makes Tailwind pleasant versus painful. These are the patterns we reach for automatically now — not because they're clever, but because we got burned enough times without them.

Stop Reaching for @apply — Use cva Instead

`@apply` feels like the natural escape hatch when your class strings get long. It's not. It pulls you back toward CSS files, kills the colocation benefit of utility classes, and makes your IDE lose track of where styles live. The real solution is `cva` (class-variance-authority).

cva lets you define component variants as a typed object, and the output is just a plain string of Tailwind classes. No magic, no CSS extraction weirdness, and your variants are self-documenting.

import { cva, type VariantProps } from 'class-variance-authority';

const button = cva(
  // Base classes that always apply
  '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-primary text-primary-foreground hover:bg-primary/90',
        destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
        outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
        ghost: 'hover:bg-accent hover:text-accent-foreground',
      },
      size: {
        sm: 'h-8 px-3 text-xs',
        md: 'h-10 px-4 text-sm',
        lg: 'h-12 px-6 text-base',
      },
    },
    defaultVariants: {
      variant: 'default',
      size: 'md',
    },
  }
);

type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
  VariantProps<typeof button>;

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

Now you get TypeScript autocomplete on your variants, a single source of truth, and you can still pass `className` to override from the outside. This pattern alone eliminates most of the "Tailwind doesn't scale" complaints we hear.

Use cn() Everywhere — But Build It Right

You've probably seen the `cn()` utility floating around Next.js projects. If you haven't set one up yet, do it before you write another component. The pattern is `clsx` + `tailwind-merge` combined into a helper.

Why both? `clsx` handles conditional class logic cleanly. `tailwind-merge` is what actually prevents conflicts — without it, passing `className="text-red-500"` to a component that has `text-blue-500` in its base classes will include both, and CSS specificity decides which wins. That's a bug waiting to embarrass you in production.

// lib/utils.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';

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

// Usage — no more class conflicts
function Card({ className, highlighted }: { className?: string; highlighted?: boolean }) {
  return (
    <div
      className={cn(
        'rounded-lg border bg-card p-6 shadow-sm',
        highlighted && 'border-primary shadow-primary/20',
        className // This will correctly override, not stack
      )}
    />
  );
}

Install both packages: `pnpm add clsx tailwind-merge`. This is a two-minute setup that saves hours of debugging weird style conflicts.

CSS Variables + Tailwind = Design Tokens That Actually Work

The default Tailwind color palette is fine for prototyping. But the moment you need dark mode, brand colors, or theming, hardcoding `bg-blue-600` everywhere becomes a nightmare. The pattern that actually works long-term is defining your design tokens as CSS custom properties and then referencing them in your Tailwind config.

/* globals.css */
@layer base {
  :root {
    --background: 0 0% 100%;
    --foreground: 222.2 84% 4.9%;
    --primary: 221.2 83.2% 53.3%;
    --primary-foreground: 210 40% 98%;
    --muted: 210 40% 96.1%;
    --muted-foreground: 215.4 16.3% 46.9%;
    --border: 214.3 31.8% 91.4%;
    --radius: 0.5rem;
  }

  .dark {
    --background: 222.2 84% 4.9%;
    --foreground: 210 40% 98%;
    --primary: 217.2 91.2% 59.8%;
    --primary-foreground: 222.2 47.4% 11.2%;
    --muted: 217.2 32.6% 17.5%;
    --muted-foreground: 215 20.2% 65.1%;
    --border: 217.2 32.6% 17.5%;
  }
}
// tailwind.config.ts
import type { Config } from 'tailwindcss';

const config: Config = {
  darkMode: ['class'],
  theme: {
    extend: {
      colors: {
        background: 'hsl(var(--background))',
        foreground: 'hsl(var(--foreground))',
        primary: {
          DEFAULT: 'hsl(var(--primary))',
          foreground: 'hsl(var(--primary-foreground))',
        },
        muted: {
          DEFAULT: 'hsl(var(--muted))',
          foreground: 'hsl(var(--muted-foreground))',
        },
        border: 'hsl(var(--border))',
      },
      borderRadius: {
        lg: 'var(--radius)',
        md: 'calc(var(--radius) - 2px)',
        sm: 'calc(var(--radius) - 4px)',
      },
    },
  },
};

export default config;

Now `bg-primary` automatically changes in dark mode because it reads from a CSS variable. You change the variable in one place, everything updates. This is the approach shadcn/ui uses, and it's genuinely good — not just because shadcn is popular, but because it separates theming concerns from component markup.

Arbitrary Values Are Not the Enemy

Junior Tailwind developers avoid arbitrary values like `w-[347px]` because they've heard it's bad practice. Senior developers use them confidently for the right situations. The rule of thumb: arbitrary values for one-off layout specifics that don't belong in your design system, never for colors or spacing that appears in more than one place.

The patterns where arbitrary values genuinely shine:

  • `grid-cols-[1fr_300px]` — asymmetric grid layouts that don't fit the standard column system
  • `h-[calc(100vh-64px)]` — dynamic heights accounting for navbar or header offsets
  • `bg-[url('/hero.jpg')]` — inline background images without custom config
  • `[&>p]:mb-4` — targeting child elements inside rich text or markdown content
  • `top-[117px]` — pixel-perfect positioning for overlays that a designer measured in Figma
Arbitrary values are fine. Arbitrary values for your brand colors repeated in 40 components is where you've made a mistake — that belongs in your config.

The Group and Peer Modifiers Are Underused

If you're still using JavaScript state to handle hover effects that should be pure CSS, `group` and `peer` will save you. These two modifiers let you style children (or siblings) based on parent or preceding element state.

// group: style children based on parent hover/state
function FeatureCard({ title, description }: { title: string; description: string }) {
  return (
    <div className="group relative rounded-xl border p-6 transition-all hover:border-primary hover:shadow-lg">
      <div className="mb-2 flex items-center gap-2">
        <span className="text-lg font-semibold">{title}</span>
        {/* Arrow only visible on parent hover */}
        <span className="opacity-0 transition-opacity group-hover:opacity-100">→</span>
      </div>
      {/* Subtle color change on parent hover */}
      <p className="text-muted-foreground group-hover:text-foreground transition-colors">
        {description}
      </p>
    </div>
  );
}

// peer: style siblings based on sibling state (great for form validation)
function FormField({ label, error }: { label: string; error?: string }) {
  return (
    <div className="space-y-1">
      <label className="text-sm font-medium">{label}</label>
      <input
        className="peer w-full rounded-md border px-3 py-2 focus:outline-none focus:ring-2 focus:ring-primary aria-[invalid=true]:border-destructive"
      />
      {/* Only visible when input has aria-invalid=true */}
      <p className="hidden text-sm text-destructive peer-aria-[invalid=true]:block">
        {error}
      </p>
    </div>
  );
}

The `group-hover` pattern alone eliminates a surprising number of `useState` calls that were only there to track hover state. Less JS, same result, and it works before React hydrates.

Organize Your Class Strings Before They Organize You

Long class strings are inevitable. The question is whether they're readable or chaos. We settled on a consistent ordering convention: layout → sizing → spacing → typography → colors → borders → effects → transitions → states. Once it's muscle memory, you can scan a class string and find what you're looking for.

Prettier's official Tailwind plugin enforces this automatically. Add it once and stop thinking about it:

// Install: pnpm add -D prettier-plugin-tailwindcss

// prettier.config.js
const config = {
  plugins: ['prettier-plugin-tailwindcss'],
  // Optional: if you're using a non-standard config path
  tailwindConfig: './tailwind.config.ts',
};

export default config;

That's it. On save, your class strings get sorted consistently. In code review, nobody debates ordering anymore. This is the kind of boring automation that makes a codebase feel professional.

The Variant Pattern for Responsive Components

One pattern we see less than we should: using `sm:`, `md:`, `lg:` prefixes inside cva variants instead of scattering responsive prefixes throughout your JSX. This keeps the responsive logic in one place.

const container = cva('mx-auto w-full px-4', {
  variants: {
    size: {
      sm: 'max-w-2xl',
      md: 'max-w-4xl',
      lg: 'max-w-6xl',
      full: 'max-w-none',
    },
    // Responsive padding as a variant instead of scattered sm:px-6 md:px-8
    padding: {
      none: '',
      default: 'px-4 sm:px-6 lg:px-8',
      wide: 'px-6 sm:px-8 lg:px-12',
    },
  },
  defaultVariants: {
    size: 'lg',
    padding: 'default',
  },
});

// Usage is clean:
<div className={container({ size: 'md' })}>
  {/* content */}
</div>

When your designer asks "can we make the padding a bit tighter on mobile?" you change it in one place, not grep through 15 files looking for `px-4 sm:px-6`.

If you're starting a new Next.js project and want these patterns already wired up, our templates at peal.dev include the full setup — cva, cn utility, CSS variable theming, and the Prettier plugin — so you skip the boilerplate and start on the actual product.

What We Still Get Wrong

Honesty moment: even with all these patterns, we still occasionally write a component with 40 classes in a single string when we're moving fast and tell ourselves we'll refactor it later. We don't refactor it later. If your gut says a class string is too long to read in a glance, extract a component or reach for cva right then. The five minutes it takes is always less than the 20 minutes it takes to understand that component three weeks later.

Also: don't add Tailwind plugins you don't need. `@tailwindcss/typography` is excellent if you have markdown content. `@tailwindcss/forms` is genuinely useful for resetting browser form styles. But we've seen projects load four or five Tailwind plugins for features they barely use, slowing down the build for no reason. Each plugin is a dependency — treat it like one.

The real Tailwind skill isn't knowing all the utility classes. It's knowing when to stop writing utility classes and extract an abstraction instead.

The short version: set up `cn()` today, switch to `cva` for any component that has more than one visual state, use CSS variables for anything that should respect dark mode, and let the Prettier plugin handle the rest. These aren't advanced techniques — they're just the things that stop Tailwind from becoming a mess once your project has real complexity.

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