Skip to content
C Codeloom
Tailwind

Tailwind Component Patterns: Buttons, Cards, and Forms

Practical patterns for building reusable buttons, cards, and form controls in Tailwind — including variants, the @apply debate, and when to extract a component.

·7 min read · By Yash Kesharwani
Intermediate 13 min read

What you'll learn

  • A buttery-smooth button system with variants and sizes
  • Card layouts that scale from one to many
  • Accessible form control styling — inputs, selects, textareas
  • The @apply debate and when it earns its keep
  • When to extract a React/Astro component vs. leave inline utilities

Tailwind’s strength is composing utilities. Its weakness is that the same composition shows up dozens of times in a real codebase. The answer is not to abandon utilities — it is to know when a pattern deserves a name. This post walks through the three most common components — buttons, cards, forms — and the decision of when to extract.

A solid button

Buttons carry a surprising amount of state: a default look, a hover, a focus ring, an active press, a disabled mode, and usually a few size variants. Build it once, well.

<button type="button"
        class="inline-flex items-center justify-center
               px-4 py-2 rounded-md
               text-sm font-medium text-white
               bg-blue-600 hover:bg-blue-700 active:bg-blue-800
               focus-visible:outline focus-visible:outline-2
               focus-visible:outline-offset-2 focus-visible:outline-blue-600
               disabled:opacity-50 disabled:cursor-not-allowed
               transition-colors">
  Save changes
</button>

What is doing the work:

  • inline-flex items-center justify-center keeps text and icons aligned even when content shifts.
  • Padding, rounding, and font weight give the shape.
  • hover:, active:, focus-visible:, and disabled: handle every state.
  • transition-colors softens the hover.
  • focus-visible:outline shows a clear ring for keyboard users — never outline-none without a replacement.

Variants without losing your mind

You usually need primary, secondary, and ghost variants, plus a few sizes. The trick is to define the common styles once and let variants override only what differs.

// React example using clsx
function Button({ variant = "primary", size = "md", className, ...props }) {
  const base =
    "inline-flex items-center justify-center rounded-md font-medium " +
    "transition-colors disabled:opacity-50 disabled:cursor-not-allowed " +
    "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2";

  const variants = {
    primary:
      "bg-blue-600 text-white hover:bg-blue-700 focus-visible:outline-blue-600",
    secondary:
      "bg-slate-100 text-slate-900 hover:bg-slate-200 focus-visible:outline-slate-400",
    ghost:
      "bg-transparent text-slate-700 hover:bg-slate-100 focus-visible:outline-slate-400",
  };

  const sizes = {
    sm: "px-3 py-1.5 text-xs",
    md: "px-4 py-2 text-sm",
    lg: "px-5 py-2.5 text-base",
  };

  return (
    <button
      className={clsx(base, variants[variant], sizes[size], className)}
      {...props}
    />
  );
}

The component renders to exactly the same DOM as the inline version, but the call site is clean:

<Button>Save</Button>
<Button variant="secondary" size="sm">Cancel</Button>
<Button variant="ghost">More</Button>

Card patterns

A card is just a styled container with some structure. Tailwind handles it directly:

<article class="bg-white rounded-xl shadow-sm border border-slate-200
                p-6 hover:shadow-md transition-shadow">
  <h3 class="text-lg font-semibold text-slate-900">Card title</h3>
  <p class="mt-2 text-sm text-slate-600">
    A short description of what this card is about.
  </p>
  <a href="#"
     class="mt-4 inline-flex items-center text-sm font-medium text-blue-600
            hover:text-blue-700">
    Read more →
  </a>
</article>

For a grid of cards, drop them into a grid container:

<section class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
  <article class="bg-white rounded-xl border p-6">…</article>
  <article class="bg-white rounded-xl border p-6">…</article>
  <article class="bg-white rounded-xl border p-6">…</article>
</section>

For a card with an image, structure the regions explicitly:

<article class="bg-white rounded-xl border border-slate-200 overflow-hidden">
  <img src="/cover.jpg" alt="" class="w-full aspect-video object-cover">
  <div class="p-6">
    <h3 class="text-lg font-semibold">Title</h3>
    <p class="mt-2 text-sm text-slate-600">Body</p>
  </div>
</article>

overflow-hidden on the card keeps the image corners rounded. aspect-video enforces a 16:9 ratio so the grid stays even.

Try it. Build a 3-card grid using the snippets above. Resize the window: notice how the grid collapses to 2, then 1 column with the sm: and lg: prefixes doing the work.

Form controls

Forms are where Tailwind feels most verbose. Each input has padding, borders, focus ring, and state styling to specify. Build one nice input class set and reuse it.

<form class="space-y-4 max-w-md">
  <div>
    <label for="email" class="block text-sm font-medium text-slate-700">
      Email
    </label>
    <input id="email" name="email" type="email" required
           class="mt-1 block w-full rounded-md
                  border border-slate-300 px-3 py-2
                  text-sm text-slate-900 placeholder-slate-400
                  focus:border-blue-500 focus:ring focus:ring-blue-500/30
                  focus:outline-none
                  disabled:bg-slate-50 disabled:text-slate-500">
  </div>

  <div>
    <label for="role" class="block text-sm font-medium text-slate-700">
      Role
    </label>
    <select id="role" name="role"
            class="mt-1 block w-full rounded-md border border-slate-300
                   px-3 py-2 text-sm">
      <option>Developer</option>
      <option>Designer</option>
    </select>
  </div>

  <div>
    <label for="notes" class="block text-sm font-medium text-slate-700">
      Notes
    </label>
    <textarea id="notes" name="notes" rows="4"
              class="mt-1 block w-full rounded-md border border-slate-300
                     px-3 py-2 text-sm"></textarea>
  </div>

  <button type="submit"
          class="bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium
                 px-4 py-2 rounded-md">
    Submit
  </button>
</form>

A few details that matter:

  • Every input has a real <label> with for matching id. See HTML Accessibility Basics for why.
  • space-y-4 on the form gives even vertical spacing without margin on each field.
  • focus:ring focus:ring-blue-500/30 produces a soft halo that meets contrast requirements.
  • disabled: styling tells the user the field is locked.

The @apply debate

@apply lets you bake utility classes into a regular CSS class:

.btn-primary {
  @apply bg-blue-600 text-white px-4 py-2 rounded-md font-medium;
  @apply hover:bg-blue-700 transition-colors;
}
<button class="btn-primary">Save</button>

It looks like a clean win. It is also somewhat controversial in the Tailwind community. The arguments:

For @apply:

  • Cleaner HTML for components you literally render everywhere (a base button, a .prose block).
  • Easier hand-off when designers prefer to read traditional class names.
  • A reasonable way to style third-party components that emit their own markup.

Against @apply:

  • It recreates the problem Tailwind was solving — distance between HTML and the style that applies to it.
  • It makes overrides harder. You cannot easily say “this one button is bigger” without writing more CSS.
  • A real component (React/Astro/Vue) gives you the same encapsulation and keeps the utility-first benefits at the call site.

The pragmatic stance:

  • For reusable visual primitives (a button, an input, a card) — prefer a real component over @apply.
  • For typography in user-generated content (Markdown output) — @apply or the Tailwind Typography plugin is fine.
  • For one-off pages — neither; just write utilities inline.

When to extract a component

You will hit the question every day: leave the utilities inline or wrap them in a component? A rough rubric:

Leave it inline when:

  • The pattern appears once or twice.
  • The element is a layout container that varies per page.
  • Splitting it would require many props for tiny differences.

Extract a component when:

  • The exact same combination of classes appears in three or more places.
  • The element has interactive state (disabled, loading, focus) you want to enforce consistently.
  • A non-designer is going to use it and you want them not to invent new variants.
  • The element has accessibility requirements you do not want to re-implement (a real button, a modal, a tab list).

The good news: extracting is cheap. It is rarely wrong to wait until the third copy and only then turn it into a component. Premature extraction is a common source of overly flexible, prop-heavy components nobody understands.

Try it. Search your project for the most-repeated Tailwind class string. If it appears more than three times, extract a component for it. If it appears once or twice, leave it alone.

A simple card component in Astro

For a content site, Astro components are a great fit — zero runtime cost and clean call sites:

---
// src/components/Card.astro
const { title, href } = Astro.props;
---
<a href={href}
   class="block bg-white rounded-xl border border-slate-200 p-6
          hover:shadow-md transition-shadow">
  <h3 class="text-lg font-semibold text-slate-900">{title}</h3>
  <div class="mt-2 text-sm text-slate-600">
    <slot />
  </div>
</a>
<Card title="Accessibility" href="/blog/html-accessibility-basics">
  Build pages everyone can use.
</Card>

The styling is centralized, the call site is readable, and you still have the option to add a class prop for one-off overrides.

Recap

  • Build buttons with explicit hover:, focus-visible:, active:, and disabled: states.
  • Encode variants and sizes in a real component, not in your head.
  • Cards are a styled container plus structured content — combine with grid for layouts.
  • Forms need real <label>s, decent focus rings, and disabled styling.
  • @apply is fine for content-area styles but rarely the best answer for components.
  • Extract when the same pattern appears three or more times. Earlier is usually too soon.

Next steps

Questions or feedback? Email codeloomdevv@gmail.com.