Component patterns without a component library

Build buttons, cards, badges, alerts and tables that stay consistent, and know when a class list deserves to become a component.

Buttons with real variants

<!-- base: focus ring, disabled state, and no layout shift -->
<button class="inline-flex items-center justify-center gap-2 rounded-md px-3.5 py-2
               text-sm font-medium transition
               focus-visible:outline-2 focus-visible:outline-offset-2
               disabled:pointer-events-none disabled:opacity-50">
  <svg class="size-4" aria-hidden="true">...</svg>
  <span>Save changes</span>
</button>

<!-- primary -->
<button class="... bg-sky-600 text-white hover:bg-sky-500 focus-visible:outline-sky-600">
  Save changes
</button>

<!-- secondary -->
<button class="... border border-slate-300 bg-white text-slate-900 hover:bg-slate-50
               focus-visible:outline-slate-400">
  Cancel
</button>

<!-- destructive -->
<button class="... bg-red-600 text-white hover:bg-red-500 focus-visible:outline-red-600">
  Delete account
</button>
  • Define the base once, then vary only the colour and border. Repeating the base in every variant is how the padding drifts apart over six months.
  • focus-visible: puts the ring on keyboard focus only, which is what makes a ring acceptable on a mouse-driven interface.
  • disabled:pointer-events-none stops the hover style from firing on a disabled control, which otherwise looks clickable.
  • Keep the icon and label on one baseline with inline-flex items-center; a bare inline SVG sits on the text baseline and looks off by a pixel or two.

Cards, badges and alerts

<article class="group flex flex-col gap-4 rounded-xl border border-slate-200
                        bg-white p-5 shadow-xs transition
                        hover:border-slate-300 hover:shadow-sm">
  <header class="flex items-start justify-between gap-3">
    <h3 class="font-semibold text-slate-900">Invoice 1042</h3>
    <span class="inline-flex items-center rounded-full bg-emerald-50 px-2.5 py-0.5
                 text-xs font-medium text-emerald-700 ring-1 ring-emerald-600/20">
      Paid
    </span>
  </header>
  <p class="text-sm text-slate-600">Due 21 September 2026</p>
  <p class="text-2xl font-semibold tabular-nums">£39.98</p>
  <a class="mt-auto text-sm font-medium text-sky-700 underline-offset-4
            group-hover:underline" href="/invoices/1042">View invoice</a>
</article>
<!-- alerts: a role, an icon and a colour that matches the meaning -->
<div role="status" class="flex gap-3 rounded-lg bg-emerald-50 p-4 ring-1 ring-emerald-600/20">
  <svg class="size-5 shrink-0 text-emerald-600" aria-hidden="true">...</svg>
  <div>
    <p class="font-medium text-emerald-900">Invoice sent</p>
    <p class="text-sm text-emerald-800">The customer will receive it within a minute.</p>
  </div>
</div>

<div role="alert" class="flex gap-3 rounded-lg bg-red-50 p-4 ring-1 ring-red-600/20">
  <p class="font-medium text-red-900">Payment failed</p>
</div>
ElementKey classesWhy
Button baseinline-flex items-center gap-2Icon and label aligned
Cardrounded-xl border p-5One radius and one padding for all cards
Badgerounded-full px-2.5 py-0.5 text-xsPill shape reads as a status
Alertflex gap-3 p-4 ring-1A ring, not a border, so it does not shift layout
Table cellpx-3 py-2 text-sm tabular-numsNumbers align in a column
Empty stateflex flex-col items-center gap-2 py-12Consistent, deliberate spacing
💡
A ring draws outside the box and does not affect layout, while a border does. Using a ring for emphasis states - focus, selection, alerts - means the element does not change size when the state changes, which is what stops a row of cards jittering on hover.

When to extract a component

  1. Third use: extract. One or two repetitions are not a pattern, they are a coincidence.
  2. Extract behind a small API - variant, size - rather than accepting a raw class string from the caller, or the component will drift exactly as the markup did.
  3. Keep the utilities inside the component. A caller that passes p-8 to override p-4 has created a specificity conversation about class order.
  4. Do not extract a layout wrapper. A PageContainer that only adds mx-auto max-w-7xl px-4 hides a decision that is clearer in the markup.
  5. If the class list genuinely needs variants across frameworks, reach for a variant helper rather than a template string.
<!-- before: the caller decides everything -->
<div class="flex items-center gap-2 rounded-md px-3.5 py-2 bg-sky-600 text-white">Save</div>
<div class="flex items-center gap-2 rounded-md px-3.5 py-2 bg-red-600 text-white">Delete</div>
<div class="flex items-center gap-2 rounded-md px-3 py-2 bg-slate-100 text-slate-500">Cancel</div>

<!-- after: one component, three variants -->
<Button variant="primary">Save</Button>
<Button variant="danger">Delete</Button>
<Button variant="ghost" size="sm">Cancel</Button>
A note on class soup:

Long class lists are a real cost, but the alternative - a stylesheet of
bespoke classes - moves the same complexity somewhere harder to see.
The productive middle is: utilities for one-off layout, a component for
anything used three times with more than two variants, and tokens for
anything that must stay consistent across both.

FAQ

Is a long class list really maintainable?
It is honest, which counts for a lot: everything the element looks like is in one place, and there is no dead CSS to hunt for. The maintenance cost appears when the same list is pasted twenty times with small differences - that is the signal to extract a component, not to abandon utilities.
Should I use @apply in a component class?
Sparingly, and mostly for a third-party markup you cannot edit. In your own components, @apply hides the styling behind a name that readers have to go and look up, and it loses the connection to the responsive and state variants at the point of use.

Core layout utilities: flex, grid and spacing Tailwind with component libraries and variant helpers

Last refreshed 2026-09-18.