DEV Community

Mohamed Idris
Mohamed Idris

Posted on

Learning TailwindCSS As If You Built It Yourself

If you have ever opened a year old CSS file and tried to figure out which class does what, you remember the feeling. card-2, card-2-blue, card-2-blue-mobile, card-2-blue-mobile-final, all in different files, all targeting the same component, all "almost" doing the same thing. Renaming any of them is risky. Deleting any of them is scarier. So nobody does, and the file grows forever.

That graveyard of dead classes is what Tailwind was invented to prevent.

What is TailwindCSS, really

Think of Tailwind as a tool belt of pre cut Lego bricks for styling. Instead of inventing class names like card-header-title-medium-blue and writing CSS for them, you snap together a handful of small, single purpose classes right in your HTML:

<div class="rounded-lg bg-white p-6 shadow">
  <h2 class="text-xl font-semibold text-slate-900">Mochi</h2>
  <p class="mt-2 text-slate-600">A small white cat with big plans.</p>
</div>
Enter fullscreen mode Exit fullscreen mode

Each class does exactly one thing. rounded-lg is just border-radius: 0.5rem. p-6 is just padding: 1.5rem. There is no naming meeting. There is no dead CSS. The styles live next to the markup that uses them. When you delete the markup, the styles go with it.

Two ideas drive the whole thing:

  • Utility first. You compose your design from many tiny utilities, not from a few "components" that grow attribute soup over time.
  • Constrained, not free. The utilities only exist for the values you defined as design tokens (spacing scale, color palette, font sizes). You cannot accidentally write padding: 13.7px. You can only pick from p-1, p-2, p-3, etc. The constraint is the point.

That is the whole vibe.

Let's pretend we are building one

We want a way to write CSS that scales to a team and a year, without inventing names, without dead code, without a fight in code review every Monday. We will call it Tailwind.

For the running example, we are styling a small profile card with a name, an avatar, a bio, and two buttons. We will grow it piece by piece.

Decision 1: Ship a sensible default scale

We need a small, consistent set of values for spacing, type, and color. If everything is on the scale, components will feel like they were designed by the same person, even if 12 different people wrote them.

The defaults that ship with Tailwind:

  • Spacing: a 0.25rem (4px) base. p-1 = 4px, p-2 = 8px, p-4 = 16px, p-8 = 32px. Same scale used by margin, gap, width, height.
  • Type: text-xs, text-sm, text-base, text-lg, text-xl, text-2xl, all the way up to text-9xl. Each comes with a sensible line height.
  • Colors: 22 named color families (slate, gray, red, green, blue, indigo, ...) with 11 shades each (50 to 950). Plus full opacity control (text-blue-500/50, half opacity).
  • Radii, shadows, borders: also on a scale. rounded, rounded-md, rounded-lg, rounded-2xl. shadow-sm, shadow, shadow-md, shadow-lg.

The full classes look like this in a real component:

<button class="
  inline-flex items-center gap-2
  rounded-md
  bg-blue-600 px-4 py-2
  text-sm font-medium text-white
  hover:bg-blue-500
  focus-visible:outline-2 focus-visible:outline-blue-600 focus-visible:outline-offset-2
  disabled:opacity-50 disabled:cursor-not-allowed
">
  Adopt
</button>
Enter fullscreen mode Exit fullscreen mode

Read it like a sentence, left to right. Layout, then box, then color, then text, then states. Once your eye adjusts (give it a week), it is faster to scan than CSS files split across the repo.

Decision 2: Variants for state and screen size

Plain CSS uses :hover and @media. Tailwind gives you the same power as prefixes on the class name.

<a class="
  text-slate-700
  hover:text-slate-900
  focus-visible:underline
  active:text-slate-950
">Read more</a>

<div class="
  grid
  grid-cols-1
  md:grid-cols-2
  lg:grid-cols-3
  gap-4
">...</div>
Enter fullscreen mode Exit fullscreen mode

The state prefixes you will reach for daily:

  • hover:, focus:, focus-visible:, active:, disabled:
  • group-hover: and peer-hover: for "when my parent or sibling is hovered" (super useful)
  • aria-expanded:, aria-checked:, data-[state=open]: for components driven by ARIA or data attributes
  • has-[:invalid]: for the new CSS :has() selector
  • not-first:, first:, last:, odd:, even:

The screen prefixes (mobile first):

  • sm: 640px and up
  • md: 768px and up
  • lg: 1024px and up
  • xl: 1280px and up
  • 2xl: 1536px and up

Read md:grid-cols-2 as "from 768px and up, use two columns". The bare class is the mobile baseline.

You can stack prefixes:

<button class="
  bg-blue-600 hover:bg-blue-500
  md:bg-emerald-600 md:hover:bg-emerald-500
">Multi state</button>
Enter fullscreen mode Exit fullscreen mode

Variants are how Tailwind avoids the "one giant CSS file with sixteen media queries" problem. The state lives next to the value.

Decision 3: Dark mode is just another variant

<div class="bg-white text-slate-900 dark:bg-slate-900 dark:text-slate-100">
  ...
</div>
Enter fullscreen mode Exit fullscreen mode

In Tailwind v4, dark: defaults to the user's prefers-color-scheme. If you want a manual toggle (a moon icon in your nav), you opt into the class strategy:

@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
Enter fullscreen mode Exit fullscreen mode

Then <html class="dark"> flips your whole site.

Decision 4: Configuration, the v4 way

Tailwind used to live in a tailwind.config.js. Tailwind v4 (the current major in 2026) moved configuration into CSS itself. You define design tokens with @theme, and Tailwind generates utilities for you automatically.

/* app.css */
@import "tailwindcss";

@theme {
  --color-brand:    oklch(63% 0.16 256);
  --color-brand-50: oklch(96% 0.02 256);

  --font-display: "Inter", system-ui, sans-serif;

  --spacing-128: 32rem;

  --radius-card: 1.25rem;

  --breakpoint-3xl: 1920px;
}
Enter fullscreen mode Exit fullscreen mode

Defining --color-brand automatically gives you bg-brand, text-brand, border-brand, ring-brand. Defining --spacing-128 gives you p-128, mx-128, w-128. Defining --breakpoint-3xl gives you 3xl:.

That is the whole config story now. No JS file, no theme.extend, no rebuilding the world.

If you need full power, the JS config still works for backwards compatibility. New projects should start in CSS.

Decision 5: Arbitrary values for the rare one off

Sometimes you need a specific pixel value that is not on the scale. The escape hatch is square brackets:

<div class="w-[317px] mt-[7px] grid-cols-[200px_1fr_60px]">...</div>
<p class="text-[#bada55]">design demanded a specific hex</p>
<div class="bg-[url('/hero.jpg')]">...</div>
Enter fullscreen mode Exit fullscreen mode

Useful, but be honest with yourself. If the same arbitrary value appears more than twice, it should be a token in @theme, not an inline literal.

You can also use modifiers for opacity, font weight, etc:

<div class="bg-blue-500/30 text-slate-900/80">
  30% blue background, 80% opaque text
</div>
Enter fullscreen mode Exit fullscreen mode

Decision 6: Composition without losing your mind

Once a button shows up in twenty places with the same long class string, you want to deduplicate. Two senior level moves:

Component in your framework, not in CSS

The Tailwind opinion: a component is a React/Vue/Svelte component, not a .btn CSS class.

// Button.tsx
import { cva, type VariantProps } from "class-variance-authority";
import clsx from "clsx";

const button = cva(
  "inline-flex items-center gap-2 rounded-md font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 disabled:opacity-50",
  {
    variants: {
      kind: {
        primary:   "bg-blue-600 text-white hover:bg-blue-500 focus-visible:outline-blue-600",
        secondary: "bg-slate-200 text-slate-900 hover:bg-slate-300",
        ghost:     "text-slate-700 hover:bg-slate-100",
      },
      size: {
        sm: "px-3 py-1.5 text-sm",
        md: "px-4 py-2  text-sm",
        lg: "px-5 py-2.5 text-base",
      },
    },
    defaultVariants: { kind: "primary", size: "md" },
  }
);

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

export function Button({ kind, size, className, ...rest }: Props) {
  return <button className={clsx(button({ kind, size }), className)} {...rest} />;
}
Enter fullscreen mode Exit fullscreen mode

The pair you will see most: clsx (or classnames) for conditional class composition, tailwind-merge for safely overriding earlier classes (so <Button className="bg-red-500" /> actually wins), and class-variance-authority for typed component variants.

@apply, the rare exception

If a component is used in plain HTML (no framework), or if you need to style elements you do not control (like markdown HTML), you can extract a class with @apply:

@layer components {
  .prose-link {
    @apply text-blue-600 underline underline-offset-2 hover:text-blue-500;
  }
}
Enter fullscreen mode Exit fullscreen mode

@apply is a tool, not a strategy. If you find yourself reaching for it everywhere, you are fighting Tailwind's grain. Use components instead.

Decision 7: Layouts with utilities

A side by side layout in pure utilities:

<header class="flex items-center justify-between gap-4 p-4 border-b">
  <h1 class="text-lg font-semibold">Mia's Blog</h1>
  <nav class="flex items-center gap-6 text-sm text-slate-600">
    <a class="hover:text-slate-900" href="/">Home</a>
    <a class="hover:text-slate-900" href="/about">About</a>
  </nav>
</header>
Enter fullscreen mode Exit fullscreen mode

A responsive grid:

<section class="grid gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 p-6">
  <article class="rounded-2xl bg-white p-6 shadow">...</article>
  <article class="rounded-2xl bg-white p-6 shadow">...</article>
  <article class="rounded-2xl bg-white p-6 shadow">...</article>
</section>
Enter fullscreen mode Exit fullscreen mode

A modern landing hero with fluid type:

<section class="
  mx-auto max-w-3xl px-6 py-20 text-center
  bg-gradient-to-b from-slate-50 to-white
">
  <h1 class="text-balance text-4xl sm:text-5xl lg:text-6xl font-bold tracking-tight">
    A tiny library for tiny lives.
  </h1>
  <p class="mt-6 text-lg text-slate-600">
    Carefully selected books for slow afternoons.
  </p>
  <div class="mt-10 flex justify-center gap-3">
    <button class="rounded-md bg-slate-900 px-5 py-2.5 text-white hover:bg-slate-800">
      Browse
    </button>
    <button class="rounded-md px-5 py-2.5 text-slate-700 hover:bg-slate-100">
      Learn more
    </button>
  </div>
</section>
Enter fullscreen mode Exit fullscreen mode

text-balance is a small modern win. It tells the browser to balance lines so the heading does not orphan a single word on its own line.

Decision 8: Container queries and modern CSS, free

Tailwind v4 ships container queries built in, no plugin needed.

<aside class="@container p-6">
  <article class="
    rounded-lg bg-white p-4
    @md:flex @md:items-center @md:gap-4
  ">
    <img class="h-20 w-20 rounded-full" src="/mochi.jpg" />
    <div class="@md:flex-1">
      <h3 class="text-lg font-semibold">Mochi</h3>
      <p class="text-sm text-slate-600">A small cat with big plans.</p>
    </div>
  </article>
</aside>
Enter fullscreen mode Exit fullscreen mode

@md: triggers when the container crosses the md size, not the viewport. The same card looks different in a wide and a narrow column without changing its markup.

Other modern bits Tailwind exposes nicely:

  • has-[] for parent based styling: has-[:invalid]:border-red-500
  • group-has-[], peer-has-[] for descendant or sibling based logic
  • text-balance, text-pretty for typography
  • scroll-snap-x, snap-mandatory, snap-start for snap scrolling
  • backdrop-blur, backdrop-brightness for frosted glass UIs
  • mix-blend-multiply and other blend modes

Decision 9: Theming with CSS variables

Because v4 builds tokens as real CSS variables, runtime theming is free.

@theme {
  --color-brand: oklch(63% 0.16 256);
}

:root[data-theme="warm"]  { --color-brand: oklch(70% 0.15 30); }
:root[data-theme="cool"]  { --color-brand: oklch(70% 0.12 250); }
:root[data-theme="green"] { --color-brand: oklch(70% 0.15 150); }
Enter fullscreen mode Exit fullscreen mode

Now bg-brand follows the data-theme attribute. Switching themes is one attribute change in JavaScript, no rebuild, no class swap on every component.

Decision 10: Forms and prose

Two official plugins worth installing on any content site:

  • @tailwindcss/forms gives all form elements consistent base styles you can build on. Without it, browser defaults are inconsistent across systems.
  • @tailwindcss/typography gives you a .prose class that styles raw HTML (from a CMS or markdown) beautifully. <article class="prose lg:prose-lg dark:prose-invert">{markdown}</article> is the whole setup.

Add them to app.css:

@import "tailwindcss";
@plugin "@tailwindcss/forms";
@plugin "@tailwindcss/typography";
Enter fullscreen mode Exit fullscreen mode

(In v4, plugins are declared in CSS, not in a JS config.)

Decision 11: The headless component story

Tailwind only handles styles. Real apps need accessible behaviors: dropdowns with focus trap, dialogs that handle escape, comboboxes with type ahead. Hand rolling these is a graveyard.

The two friends to know:

  • Headless UI (from the Tailwind team): unstyled but accessible primitives. Bring your own utility classes.
  • Radix UI (or React Aria on the React side): a more comprehensive set of accessible primitives. Pairs beautifully with Tailwind.

Combine these with cva for variants and you have a small, fast, type safe component library that looks however you want.

Decision 12: Anti patterns, the senior level smell test

A handful of things that will rot your Tailwind codebase:

  • One giant @apply per component. You are reinventing CSS classes inside Tailwind. Extract a real component instead.
  • Random arbitrary values. w-[317px] once is fine. w-[317px] in 30 places is a missing token.
  • Inline string of 60 classes with no structure. Group by purpose: layout, then box, then color, then text, then states. Or use cva.
  • Skipping tailwind-merge when your component accepts className. Without it, callers cannot override.
  • Using @apply for components used inside React/Vue. You are halfway between two worlds. Pick one.
  • Hand rolling dropdowns and dialogs instead of using Headless UI or Radix. Accessibility lives there.
  • No design tokens. Without @theme variables, you lose the "constrained on purpose" benefit.

A peek under the hood

What really happens when Tailwind builds your CSS:

  1. Tailwind scans your template files (HTML, JSX, Vue, Svelte, etc).
  2. It finds every class string used.
  3. For each class it recognizes, it generates the CSS rule that class needs.
  4. Unused utilities are not generated. The output stylesheet only contains what your code actually uses.
  5. The JIT (just in time) engine in v4 uses Rust under the hood and a new Lightning CSS based pipeline. Builds that used to take seconds now take milliseconds.
  6. The output ships as one small CSS file.

That last point is why a "huge utility framework" still produces tiny stylesheets in production. You only get the classes you used. Delete a component, the styles disappear with it. There is no dead CSS.

Tiny tips that will save you later

  • Use the official VS Code extension. Autocomplete, hover docs, class sorting. Non negotiable.
  • Use the Prettier plugin (prettier-plugin-tailwindcss) for canonical class order. Code reviews stop arguing about it.
  • Set up tailwind-merge in any component that accepts className.
  • Define design tokens in @theme on day one. You will use them on day two.
  • Mobile first, always. Bare class is mobile. md: is tablet up.
  • Use cva for typed component variants. Avoid stringly typed className props with five conditionals.
  • Turn on text-balance for headings for free typography wins.
  • Wrap content with max-w-prose for readable line lengths.
  • Use Headless UI or Radix for any interactive component (modal, menu, combobox).
  • Audit unused arbitrary values every month or so. Promote the repeated ones into the theme.

Wrapping up

So that is the whole story. We were tired of inventing class names that aged into a graveyard. We built a system where styles are tiny single purpose classes, written next to the markup that uses them. Our design tokens are CSS variables defined in @theme. Our utilities respect a constrained scale, so layouts feel consistent across the team. Variants give us state and screen size right where the value is. Dark mode is just another variant. Container queries, :has(), OKLCH colors, and modern features all come along for free.

We learned to compose into framework components (with cva, clsx, tailwind-merge) instead of CSS components, to keep @apply rare, and to lean on Headless UI or Radix for accessible behavior. We got rid of dead CSS forever, because Tailwind only generates the classes our code actually uses.

Once that map is in your head, every Tailwind codebase starts to feel familiar, fast, and shockingly maintainable a year later. Tailwind stops feeling like "ugly inline styles" and starts feeling like the calm, constrained design system every team wishes it had.

Happy styling, and may your stylesheet stay small.

Top comments (0)