Tailwind CSS Button System: Building a Variant Library
AI generated
</>
tw
Tailwind CSS · Button System · Variant Library
Tailwind CSS Button System:
Building a variant library systematically

An inconsistent button system is one of the most common maintenance problems in Tailwind projects. Anyone who assembles buttons ad hoc ends up building a collection of barely distinguishable class combinations over the course of months. This tutorial shows how to design a scalable variant library with Tailwind CSS, from Primary to loading state.

12 min read Primary · Secondary · Ghost · Danger · Icon · Loading Tailwind CSS v4 · Alpine.js · Hyvä

1. Why a button system instead of ad-hoc classes

A Tailwind CSS button system is fundamentally different from a collection of randomly assembled utility classes. In projects without a clear convention, dozens of different button combinations pile up over weeks, some with rounded-lg, others with rounded-md, some with px-4, others with px-4 py-2. The result is visual inconsistency that signals to the user that the interface was not thought through. A structured Tailwind CSS button system solves this problem at the root by defining clear variants that look and behave identically in every context.

The second benefit of a Tailwind CSS button system lies in maintainability. When the design process introduces a new brand color set, it is enough to change the base variant, and every button in the project updates. Without a system, the same change means searching through hundreds of templates. Especially in Hyvä projects with numerous phtml files, the investment in a well-thought-out variant library pays off early. The following sections show the build-up step by step.

2. The foundation: base classes for all button variants

Every variant in the Tailwind CSS button system shares a common set of classes that defines size, font, focus ring and transition. These base classes should never be repeated across variants, that is the core of the system. In Tailwind v4 you work with the @layer components block in the CSS file for this, or, in HTML-centric projects, with a class convention that is documented. The base defines: inline-flex items-center justify-center gap-2 font-semibold text-sm rounded-xl transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2.

Why inline-flex instead of flex? A button is an inline element, it should fit into text flow and flex layouts alike without forcing its own block context. gap-2 ensures the spacing between icon and label without every button having to define individual padding for the icon. The focus ring with focus-visible:ring-2 is mandatory for accessibility, focus-visible shows the ring only for keyboard navigation, not for mouse clicks, which keeps things visually clean. In the Tailwind CSS button system, this base block is maintained in a central buttons.css file that is imported into the main CSS.


/* buttons.css, central button component definitions */
@layer components {
  /* Base shared by ALL button variants */
  .btn {
    @apply inline-flex items-center justify-center gap-2;
    @apply font-semibold text-sm leading-none;
    @apply rounded-xl transition-all duration-200;
    @apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2;
    @apply disabled:opacity-50 disabled:pointer-events-none;
    @apply select-none cursor-pointer;
  }

  /* Size modifiers, combine with any variant */
  .btn-xs  { @apply px-3 py-1.5 text-xs; }
  .btn-sm  { @apply px-4 py-2 text-sm; }
  .btn-md  { @apply px-5 py-2.5 text-sm; }
  .btn-lg  { @apply px-6 py-3 text-base; }
  .btn-xl  { @apply px-8 py-4 text-lg; }
}

3. Primary button, the default CTA

The Primary button is the most important variant in the Tailwind CSS button system. It represents the primary action of a page and should appear at most once per page section. In Tailwind you define it with a solid background in the brand color, white text and a hover: state that either lowers the brightness (hover:bg-sky-700) or increases the saturation. Important: the focus ring should stay in the same color family, focus-visible:ring-sky-500, so it is perceived as belonging together.

The Tailwind CSS button system separates color intent from size. You always combine the Primary button style with a size modifier: btn btn-primary btn-md. This prevents size from becoming part of the variant definition, which would otherwise force you to maintain .btn-primary-lg and .btn-primary-sm as separate classes. A common mistake: forgetting active: states. When clicking a button the user expects a reaction, active:scale-95 or active:brightness-95 produces the necessary tactile feedback.


/* Primary, main call-to-action, use once per section */
.btn-primary {
  @apply bg-sky-600 text-white;
  @apply hover:bg-sky-700 active:bg-sky-800;
  @apply focus-visible:ring-sky-500;
  @apply shadow-sm hover:shadow-md active:scale-95;
}

/* Secondary, supporting action, same weight as primary visually */
.btn-secondary {
  @apply bg-slate-800 text-white;
  @apply hover:bg-slate-700 active:bg-slate-900;
  @apply focus-visible:ring-slate-500;
  @apply shadow-sm hover:shadow-md active:scale-95;
}

4. Secondary and outline variant

The Secondary variant in the Tailwind CSS button system sits alongside the Primary button and signals an equally important but less preferred alternative. It shares the visual weight, both have a filled background, but differs in color. Anyone who defines Secondary with a dark slate background and Primary with the brand color creates a clear visual hierarchy without any further design work. The user intuitively understands: the bright (brand) button is the recommended action.

The outline variant is a third gradation: it has no background but a visible border and text color in the brand color. In Tailwind: border border-sky-600 text-sky-700 hover:bg-sky-50 active:bg-sky-100. This variant is suited for tertiary actions or for contexts where a filled button would disturb the visual calm, such as in cards or tables. The Tailwind CSS button system distinguishes clearly: Primary equals main action, Secondary equals equally weighted alternative, Outline equals tertiary or context-sensitive action. These three tiers cover 90 percent of all UI scenarios.

5. Ghost button, subtle and context-aware

The Ghost button is the most subtle variant of the Tailwind CSS button system. It has neither background nor border, only showing text, often with a hover state that suggests a light background. In Tailwind: text-slate-700 hover:bg-slate-100 active:bg-slate-200. Ghost buttons are suited for actions that need to exist but should not compete with Primary actions, such as "Cancel", "Show more" or navigation links that need button semantics.

A common mistake in the Tailwind CSS button system: using Ghost buttons on dark backgrounds. Anyone who places a Ghost button on a slate-800 background gets barely visible text. The solution: a ghost-on-dark variant with text-white hover:bg-white/10 active:bg-white/20. Alternatively, Tailwind's dark: prefix helps if the project supports dark mode. In Hyvä projects without a dark mode toggle, the explicit variant used directly in the template when the context is dark is sufficient.

6. Danger button for destructive actions

Destructive actions, such as deleting, resetting, or closing an account, need their own visual language in the Tailwind CSS button system. The Danger button communicates through color (red) that the action cannot be undone. In Tailwind: bg-red-600 text-white hover:bg-red-700 active:bg-red-800 focus-visible:ring-red-500. Placement matters: Danger buttons should never sit directly next to Primary buttons, spatial distance protects against accidental clicks. The Tailwind CSS button system recommends placing Danger buttons inside dialogs rather than directly in the main interface.

For two-step confirmation, a pattern combining Alpine.js and the Tailwind CSS button system is a good fit: the first click changes the button text and state, the second click executes the action. The signal to the user is unambiguous, "Are you sure?" without a modal dialog. The button switches from btn-danger to a state with a pulsing ring (ring-2 ring-red-400 animate-pulse), which avoids immediate pressure to act and gives time to reconsider. This UX approach is particularly relevant in e-commerce, where deleting order line items has an immediate impact.


<!-- Two-step confirmation pattern with Alpine.js -->
<button
  x-data="{ confirmed: false }"
  x-on:click="
    if (!confirmed) {
      confirmed = true;
      setTimeout(() => confirmed = false, 3000);
    } else {
      $dispatch('delete-item');
    }
  "
  x-bind:class="confirmed
    ? 'btn btn-md bg-red-700 text-white ring-2 ring-red-400 ring-offset-2'
    : 'btn btn-danger btn-md'"
  x-text="confirmed ? 'Really delete?' : 'Delete'"
  type="button"
></button>

7. Icon-only and icon+text buttons

Icon buttons are a special case in the Tailwind CSS button system: they have equal width and height and need a separate size definition. An icon-only button of size "md" should have w-10 h-10 p-0, so the icon sits centered. In Tailwind this is easily solved with an additional modifier class: btn-icon-md combines the square dimensions with the flex layout of the base. Important for accessibility: every icon button needs aria-label or title, since there is no visible text.

Icon+text combinations use the gap-2 already defined in the base and work without additional classes. The SVG icon sits to the left of the label and inherits the button's text color through currentColor, no separate color definition needed. In the Tailwind CSS button system, it is recommended to fix icons at w-4 h-4 or w-5 h-5 rather than scaling them via text-size. This prevents icon and text from drifting apart in size when the font-size modifier changes. All icons in the system should share the same stroke style, for example all stroke-based icons from Heroicons.

8. Loading state and disabled state

Interactive buttons need loading states in the Tailwind CSS button system to signal to the user that a request is in progress. The simplest approach: replace the button label with a spinner and disable the button at the same time. In Alpine.js this is an x-bind:disabled="loading" and an x-show toggling between spinner and label. The spinner itself is an SVG element with animate-spin, Tailwind's built-in animation that needs no extra CSS definition. The button keeps its width through min-w-[8rem], so no layout shift occurs when the text is replaced by the spinner.

The disabled state is already covered in the base of the Tailwind CSS button system through disabled:opacity-50 disabled:pointer-events-none. The HTML attribute disabled is enough, no manual styling needed. Important: for links styled as buttons (<a class="btn btn-primary">), the disabled attribute has no effect. Here, aria-disabled="true" together with pointer-events-none opacity-50 must be set manually as a class. The Tailwind CSS button system defines a helper class .btn-disabled for this, containing exactly this combination.


<!-- Loading state button with Alpine.js -->
<button
  x-data="{ loading: false }"
  x-on:click="
    loading = true;
    fetch('/api/action')
      .then(r => r.json())
      .finally(() => loading = false)
  "
  x-bind:disabled="loading"
  class="btn btn-primary btn-md min-w-[9rem]"
  type="button"
>
  <!-- Spinner shown during loading -->
  <svg
    x-show="loading"
    class="w-4 h-4 animate-spin"
    xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
    aria-hidden="true"
  >
    <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
    <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"/>
  </svg>
  <!-- Label hidden during loading -->
  <span x-show="!loading">Save</span>
  <span x-show="loading">Saving...</span>
</button>

9. Button variants side by side

A well-built Tailwind CSS button system makes the choice of the right variant for each context unambiguous. The following table shows when to use which variant and which Tailwind classes fundamentally make it up.

Variant Classes (core) Use case Max. per section
Primary bg-sky-600 text-white hover:bg-sky-700 Main action, CTA 1x
Secondary bg-slate-800 text-white hover:bg-slate-700 Alternative main action 1 to 2x
Outline border border-sky-600 text-sky-700 hover:bg-sky-50 Tertiary action, cards Unlimited
Ghost text-slate-700 hover:bg-slate-100 Cancel, navigation Unlimited
Danger bg-red-600 text-white hover:bg-red-700 Delete, destructive actions In dialogs

The Tailwind CSS button system only delivers its full benefit if every developer on the team knows this table and acts accordingly. A short design token document in the project wiki that contains this table and points to the buttons.css definitions prevents new team members from inventing their own variants. Code review checklists can include a point "Button variant correct?" to catch deviations early.

Mironsoft

Tailwind CSS, Hyvä Themes and Alpine.js for Magento 2

Scalable Tailwind components for your project?

We design complete Tailwind CSS component libraries with a consistent button system, a unified variant architecture and documented design tokens, built directly for Hyvä themes on Magento 2.

Component audit

Stocktake of inconsistent button classes and creation of a unified variant library

Design system

Complete Tailwind CSS button system with sizes, variants, loading states and accessibility

Team onboarding

Documentation, code review checklists and workshop for consistent component usage

10. Summary

A well-thought-out Tailwind CSS button system with a clear variant library reduces inconsistencies, speeds up development and makes design adjustments maintainable. The base classes define shared properties, size, focus ring, transition, while variants add only color and intent. Primary for main actions, Secondary for alternatives, Outline for tertiary actions, Ghost for context-sensitive elements, Danger for destructive operations. Loading states and disabled states are mandatory for interactive buttons in real applications.

The most important convention: size and style are separate modifiers. btn btn-primary btn-lg is the right structure, not btn-primary-large. This separation keeps the number of classes in the CSS file linear rather than exponential. Anyone who documents the Tailwind CSS button system and communicates it within the team prevents a dozen special-case variants from appearing again in three months, following no convention at all.

Tailwind CSS Button System: The essentials at a glance

Base classes

inline-flex, gap-2, focus-visible:ring-2, disabled:opacity-50, shared by all variants, never repeated.

Variant principle

Style (Primary, Ghost, Danger) and size (xs, sm, md, lg) are separate modifiers. Never maintain them as combined classes.

Interactivity

Loading state with animate-spin and x-bind:disabled. Two-step confirmation for Danger actions via Alpine.js.

Accessibility

focus-visible:ring-2 for keyboard navigation. aria-label for icon-only buttons. aria-disabled for link buttons.

11. FAQ: Tailwind CSS button system and variant library

1How many button variants does a project need?
5 variants are enough: Primary, Secondary, Outline, Ghost, Danger. Combined with 4 to 5 size modifiers, they cover all UI scenarios.
2@apply or direct classes in the HTML?
@apply in a central buttons.css for template-heavy projects (Hyvä/Magento). In framework components, maintain direct classes in variables.
3Prevent button sprawl in the team?
Variant documentation in the wiki plus a code review checklist plus a short onboarding document. Anyone who knows the convention will not invent new variants.
4Why focus-visible instead of focus?
focus-visible shows the ring only for keyboard navigation, the correct accessibility convention. focus shows it on every focus, including mouse click.
5Loading state without a JS framework?
aria-busy='true' with CSS [aria-busy='true'] button { animate } works without a framework. In Hyvä with Alpine.js, x-bind:disabled plus x-show on spinner/label is the simplest solution.
6Best icon library for Tailwind buttons?
Heroicons (Tailwind Labs) fit best, SVG-based, currentColor, consistent stroke style. Phosphor Icons as an alternative with a broader selection.
7aria-label for buttons with text?
No. aria-label only for icon-only buttons without visible text. For buttons with a label, the button text automatically becomes the accessible name.
8Dark mode variants in the button system?
dark: prefix in Tailwind: dark:bg-sky-500 dark:hover:bg-sky-400. Or a separate btn-ghost-on-dark variant for explicit dark contexts without a dark mode toggle.
9active:scale-95 or active:opacity-80?
scale-95 gives tactile press feedback that matches the native mobile pattern. Both work, scale-95 is the more modern and intuitive convention.
10Button classes on <a> elements?
Yes, but the disabled attribute has no effect. Set aria-disabled='true' plus pointer-events-none opacity-50 manually. Semantically: navigation targets go to a, actions go to button.