Skip to main content
ASoc
Tutorial

Tailwind Button: 15 Utilities, 3 Variants, and Zero Buttons

The component called Button renders a link all 22 times it is used, while 44 real button elements sit elsewhere. The full class list, and the disabled state it was missing.

The ASoc Team9 min read

A Tailwind button is a set of utility classes on whatever element the interaction actually needs — and that element is the decision, not the classes. This storefront has 44 <button> elements and one component called Button, and they have never once been the same thing: the component renders a link, all 22 times it is used. Here is the class list it holds, why it is an anchor, and the three things it was missing.

The classes, in full

There is no mystery layer. The whole visual definition is two constants:

const base =
  "inline-flex items-center justify-center gap-2 rounded-lg px-6 py-3 text-base font-medium shadow-xs duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2";

const variants = {
  primary: "bg-primary text-white hover:bg-primary-600",
  outline: "border border-stroke-tertiary bg-white text-text-color hover:bg-gray-50 hover:text-gray-800",
  dark:    "bg-gray-900 text-white hover:bg-gray-800",
};

Fifteen base utilities and three variant strings — 3, 6 and 3 utilities each. Four of the fifteen are the focus ring, which is the part most hand-rolled Tailwind buttons drop:

  • focus-visible:outline-none removes the browser default,
  • focus-visible:ring-2 focus-visible:ring-primary draws a replacement,
  • focus-visible:ring-offset-2 gives it clearance from the fill.

focus-visible, not focus. The distinction is the whole reason the ring is acceptable to ship: focus fires on a mouse click too, so a plain focus:ring puts a halo on every button anyone clicks; focus-visible is the browser's own judgement about whether the user is navigating by keyboard. Removing an outline without replacing it is the single most common accessibility regression in Tailwind UI code, and outline-none alone is how it happens.

px-6 py-3 with text-base lands a 48px-tall control, which clears the 44px minimum for a touch target without anything special. gap-2 is there because half the CTAs carry an icon; inline-flex with items-center justify-center is what makes the label and icon share a baseline that actually looks centred.

Why it is an <a>, and when yours should not be

QuestionAnswerElement
Does it go to a URL?Yes<a> / <Link>
Does it change something on this page?Yes<button>
Does it submit a form?Yes<button type="submit">
Is it styled like a button either way?IrrelevantThe behaviour decides

Every one of the 22 call sites answers the first question: browse templates, see pricing, view the dashboard, sign in to buy, open a checkout, go home from a 404. So the atom renders an anchor, and the 44 real <button> elements elsewhere in src/ — accordion toggles, the mobile-menu trigger, carousel arrows, form submits — are separate, unstyled-by-this-atom controls that change state without going anywhere.

Getting this backwards is not a style problem. An anchor and a button differ in three behaviours a user relies on: the keys that activate them (Enter for a link; Enter and Space for a button), what the screen reader announces, and whether the browser offers "open in new tab". A <div onClick> with button classes fails all three at once, and no amount of cursor-pointer fixes any of them.

What the atom was missing

Three gaps turned up when every call site was read at once. All three are the same shape: the atom did not model a state, so each call site modelled it separately, and the copies disagreed.

1. No disabled state. Four CTAs need one — a checkout still loading, a checkout not configured, a coming-soon tier, a download the visitor has not bought. HTML has no disabled anchor, so each site improvised: href="#" plus aria-disabled="true" plus pointer-events-none, then two of them added an onClick that calls preventDefault, and one instead added tabIndex={-1}.

That last one is the bug. aria-disabled is chosen over removing the control precisely because the control stays reachable: a keyboard user tabbing the pricing table should land on "Coming soon" and hear that the tier exists. tabIndex={-1} deletes it from the tab order, so that tier became invisible to exactly the users the ARIA attribute was added for. Meanwhile href="#" in three places quietly contradicted this repo's own zero-href="#" rule, which is checked at the deploy gate.

The atom now owns it:

if (disabled) {
  return (
    <span role="link" aria-disabled="true" tabIndex={0} className={classes}>
      {children}
    </span>
  );
}

A span with the link role has no href to follow and needs no click handler to neutralise — which matters, because two of the four call sites are Server Components and cannot pass an event handler at all. All four now say disabled and nothing else, the three href="#" values are gone, and the tab order is the same at every one of them.

2. No dark: anything. The fifteen base utilities and the three variants contain not one dark: prefix, on a site whose dark mode is a class on <html>. So dark mode is patched from outside: seven call sites paste dark: utilities into className inline, and two components built named channels to carry them — a ctaDarkClass lookup keyed by variant in the pricing card, and a darkClassName prop threaded through the buy button. This one is a known, deliberate debt rather than a defect; the workaround is documented, and consolidating it means touching every dark variant of every CTA at once.

3. No routing. Covered in full in 40 Links, and the CTAs the lint rule cannot see — in short, the atom rendered a raw <a>, so every CTA was a full page load, and the Next.js lint rule that catches that cannot see through a component boundary.

Building the same thing yourself

The shape that survives contact with a real codebase:

const base = "inline-flex items-center justify-center gap-2 rounded-lg px-6 py-3 text-base font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2";
const variants = { primary: "…", outline: "…", dark: "…" };

export default function Button({ variant = "primary", className = "", ...rest }) {
  return <a className={`${base} ${variants[variant]} ${className}`.trim()} {...rest} />;
}

Three rules behind it. Keep base and variants as separate strings, so a variant can never accidentally drop the focus ring. Accept className last in the template literal, so a call site can extend without a cn()/clsx merge helper — and accept that two conflicting utilities then resolve by CSS source order, not by argument order, which is the reason merge helpers exist and the reason this atom's call sites only ever add (widths, dark variants, opacity), never override a colour. And type the variant as a union keyed into a Record, so a typo is a build error rather than an unstyled button.

Common mistakes

MistakeWhat happensFix
focus:outline-none with no replacementKeyboard users lose all focus indicationfocus-visible:ring-2 + ring-offset-2
<div> or <span> with an onClickNo Enter/Space, no role, no new-tab<button>, or <a> if it navigates
<button> for something that navigatesNo middle-click, no crawlable href<Link> / <a>
disabled via tabIndex={-1}Control vanishes for keyboard and SR usersaria-disabled + keep it focusable
pointer-events-none as the disableStops the mouse, not the Enter keyRemove the href, or preventDefault
One giant class string per buttonVariants drift; focus ring gets droppedSplit base from variants
hover: styles with no focus-visible: twinMouse gets feedback, keyboard does notPair them

Frequently asked questions

Does Tailwind ship a button component? No. Tailwind is a utility CSS framework; it has no components. Anything called a "Tailwind button component" is someone's composition of utilities — including this one. That is why the class list above is the entire specification, and why copying it is a legitimate way to consume it.

Should the disabled state use aria-disabled or the disabled attribute? On a real <button>, use the disabled attribute — it blocks activation natively. Anchors and spans have no such attribute, so aria-disabled="true" is the only way to say it, and it announces the state without removing the control. Keep it focusable; a disabled control that cannot be reached cannot be discovered.

How do I add dark mode to a button atom cleanly? Put the dark: utilities in the variant strings, next to the light ones — "bg-primary text-white dark:bg-primary-500" — not at the call sites. The failure mode this codebase demonstrates is the alternative: once one call site patches dark mode inline, every new one copies it, and the atom stops being the place the button is defined.

Is a button an anchor or an anchor a button, for a "Download" CTA? An anchor, when the download is a URL the browser fetches — which is the case here: the CTA points at a Route Handler that streams a zip. It becomes a <button> only if a click has to run JavaScript first (fetching a signed URL, say) before anything is fetched.

Templates where this pattern ships

ASoc Blueprint, ASoc Brief and ASoc Byte each repeat a primary CTA across every section of a long landing page, which is the case that makes a single button atom worth the file — one focus ring, one disabled state, one place to change them.

Browse the full sets: Next.js landing page templates, Tailwind landing page templates. For the routing half of the same atom, read 40 Links and the CTAs the lint rule cannot see; for where these utilities come from, what is a Tailwind class and 41 design tokens; for the focus behaviour behind the ring, one of two dialogs actually traps Tab.

Keep reading

Tutorial10 min read

What Is a Tailwind Class? 658 className Attributes, One Compiler Rule

A Tailwind class is a compiler-generated CSS rule that only exists because a literal string appeared in your source. Audited from 658 className attributes and 91 components.

Read more
Tutorial9 min read

Tailwind Container Queries: One @container, Zero Queries

Our own home page had the container context and none of the queries — so a card loses a third of its width at lg while its padding never moves. The arithmetic and the fix.

Read more