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-nonestops 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>| Element | Key classes | Why |
|---|---|---|
| Button base | inline-flex items-center gap-2 | Icon and label aligned |
| Card | rounded-xl border p-5 | One radius and one padding for all cards |
| Badge | rounded-full px-2.5 py-0.5 text-xs | Pill shape reads as a status |
| Alert | flex gap-3 p-4 ring-1 | A ring, not a border, so it does not shift layout |
| Table cell | px-3 py-2 text-sm tabular-nums | Numbers align in a column |
| Empty state | flex flex-col items-center gap-2 py-12 | Consistent, 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
- Third use: extract. One or two repetitions are not a pattern, they are a coincidence.
- 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. - Keep the utilities inside the component. A caller that passes
p-8to overridep-4has created a specificity conversation about class order. - Do not extract a layout wrapper. A
PageContainerthat only addsmx-auto max-w-7xl px-4hides a decision that is clearer in the markup. - 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.Related
Core layout utilities: flex, grid and spacing Tailwind with component libraries and variant helpers
Last refreshed 2026-09-18.