Tailwind Buttons: 18 Examples of Styles, States and Groups

Tailwind doesn't ship a button. A Tailwind button is the native button element styled with utility classes, and v4 changed two defaults that catch people out: buttons now use cursor: default, and the ring utility dropped from a 3px blue ring to 1px in currentColor.

Below are 18 Tailwind button examples you can copy, followed by a guide to variants, states, sizing, accessibility and reuse.

By Bogdan Sandu · Updated October 29, 2026

Every example is built with Tailwind CSS v4.3 utility classes. To try one quickly, add the Play CDN tags below to your page and paste the example's HTML, CSS and JS. The dark variant line makes dark: classes follow a .dark class instead of the system setting. For production, install Tailwind with Vite or the CLI.

<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<style type="text/tailwindcss">
  @custom-variant dark (&:where(.dark, .dark *));
</style>

Variants, sizes and hierarchy

Solid, soft, outline and ghost buttons built only from color, padding and radius utilities, plus a size scale, a brand color registered with @theme and one set that switches between light and dark.

Primary, secondary and tertiary actions

  • bg-indigo-600
  • ring-inset
  • flex-col-reverse
  • sm:mr-auto

One solid primary, one white secondary with an inset ring-1 border and one tertiary text button: the three weights differ only in background and border utilities. flex-col-reverse stacks the primary on top on phones, sm:flex-row lines them up from 640px, and sm:mr-auto pushes the tertiary action to the far left.

View code
<div class="w-full max-w-lg rounded-2xl bg-white p-6 shadow-lg ring-1 ring-slate-900/5">
  <h3 class="text-lg font-semibold text-slate-900">Publish “Autumn tasting menu”?</h3>
  <p class="mt-2 text-sm/6 text-slate-600">The post goes live on the Larder &amp; Lane blog and lands in 2,184 subscriber inboxes at 9:00 AM tomorrow.</p>
  <div class="mt-6 flex flex-col-reverse gap-3 sm:flex-row sm:items-center">
    <button type="button" class="min-h-11 cursor-pointer rounded-lg px-3 text-sm font-semibold text-slate-700 hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 sm:mr-auto">Save as draft</button>
    <button type="button" class="min-h-11 cursor-pointer rounded-lg bg-white px-4 text-sm font-semibold text-slate-900 shadow-xs ring-1 ring-slate-300 ring-inset hover:bg-slate-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">Preview</button>
    <button type="button" class="min-h-11 cursor-pointer rounded-lg bg-indigo-600 px-4 text-sm font-semibold text-white shadow-xs hover:bg-indigo-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 active:bg-indigo-800">Publish now</button>
  </div>
</div>

Five-step size scale

  • px-* py-*
  • text-xs..text-base
  • pointer-coarse:
  • gap-*

Each size is a padding pair plus a text size, and every other class stays the same, so the scale reads as one family. The two smallest sizes add pointer-coarse:min-h-11, which grows them to 44px only on touch screens, and the icon gap grows with the size.

View code
<div class="w-full max-w-2xl rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-900/5">
  <h3 class="text-base font-semibold text-slate-900">Button sizes</h3>
  <p class="mt-1 text-sm text-slate-600">Same color, radius and weight. Only padding and type change.</p>
  <div class="mt-5 flex flex-wrap items-end gap-x-4 gap-y-5">
    <div class="flex flex-col items-start gap-2">
      <button type="button" class="inline-flex cursor-pointer items-center gap-1 rounded-md bg-teal-700 px-2 py-1 text-xs font-semibold text-white hover:bg-teal-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-700 pointer-coarse:min-h-11"><svg class="size-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>Add</button>
      <span class="font-mono text-xs text-slate-500">xs</span>
    </div>
    <div class="flex flex-col items-start gap-2">
      <button type="button" class="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-teal-700 px-3 py-1.5 text-sm font-semibold text-white hover:bg-teal-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-700 pointer-coarse:min-h-11"><svg class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>Add</button>
      <span class="font-mono text-xs text-slate-500">sm</span>
    </div>
    <div class="flex flex-col items-start gap-2">
      <button type="button" class="inline-flex cursor-pointer items-center gap-2 rounded-md bg-teal-700 px-4 py-2 text-sm font-semibold text-white hover:bg-teal-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-700"><svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>Add stop</button>
      <span class="font-mono text-xs text-slate-500">md</span>
    </div>
    <div class="flex flex-col items-start gap-2">
      <button type="button" class="inline-flex cursor-pointer items-center gap-2 rounded-md bg-teal-700 px-5 py-2.5 text-base font-semibold text-white hover:bg-teal-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-700"><svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>Add stop</button>
      <span class="font-mono text-xs text-slate-500">lg</span>
    </div>
    <div class="flex flex-col items-start gap-2">
      <button type="button" class="inline-flex cursor-pointer items-center gap-2.5 rounded-lg bg-teal-700 px-6 py-3 text-base font-semibold text-white hover:bg-teal-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-700"><svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>Add stop</button>
      <span class="font-mono text-xs text-slate-500">xl</span>
    </div>
  </div>
</div>

Soft tinted pill buttons

  • rounded-full
  • bg-*-50
  • ring-*/20
  • hover:bg-*-100

Soft buttons keep the hue and drop the saturation: a 50 background, an 800 label and a ring at 20 percent opacity from the same color family. rounded-full turns them into pills, and the reply count is a nested span with its own tint, which a pill button can hold because it is a real element.

View code
<article class="w-full max-w-xl rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-900/5">
  <div class="flex items-start gap-3">
    <svg class="size-10 shrink-0" viewBox="0 0 40 40" aria-hidden="true"><circle cx="20" cy="20" r="20" fill="#e0e7ff"/><text x="20" y="25" text-anchor="middle" font-size="14" font-weight="600" fill="#3730a3" font-family="system-ui, sans-serif">PN</text></svg>
    <div class="min-w-0">
      <p class="text-sm"><span class="font-semibold text-slate-900">Priya Nair</span> <span class="text-slate-500">on Sourdough starter, day 5</span></p>
      <p class="mt-1 text-sm/6 text-slate-700">Mine doubled overnight once I moved it next to the oven. Is that too warm for a rye starter?</p>
    </div>
  </div>
  <div class="mt-4 flex flex-wrap gap-2">
    <button type="button" class="inline-flex min-h-9 cursor-pointer items-center gap-1.5 rounded-full bg-emerald-50 px-3.5 text-sm font-semibold text-emerald-800 ring-1 ring-emerald-600/20 ring-inset hover:bg-emerald-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-700"><svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg>Approve</button>
    <button type="button" class="inline-flex min-h-9 cursor-pointer items-center gap-1.5 rounded-full bg-amber-50 px-3.5 text-sm font-semibold text-amber-800 ring-1 ring-amber-600/25 ring-inset hover:bg-amber-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-700"><svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="13" r="7"/><path d="M12 10v3l2 2M9 3h6"/></svg>Snooze</button>
    <button type="button" class="inline-flex min-h-9 cursor-pointer items-center gap-1.5 rounded-full bg-rose-50 px-3.5 text-sm font-semibold text-rose-800 ring-1 ring-rose-600/20 ring-inset hover:bg-rose-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-rose-700"><svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 21V4h11l-2 4 2 4H5"/></svg>Flag</button>
    <button type="button" class="inline-flex min-h-9 cursor-pointer items-center gap-2 rounded-full bg-sky-50 py-1 pr-1.5 pl-3.5 text-sm font-semibold text-sky-800 ring-1 ring-sky-600/20 ring-inset hover:bg-sky-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700">Replies <span class="rounded-full bg-sky-700 px-2 py-0.5 text-xs text-white">12</span></button>
  </div>
</article>

Outline and ghost buttons

  • border (currentColor)
  • hover:bg-stone-800
  • hover:bg-stone-200/70
  • divide-y

The outline buttons use a bare border class with no border color, because in v4 the default border color is currentColor and the border follows the stone-800 text. They fill solid on hover, while the ghost buttons have no border and only gain a translucent stone background on hover.

View code
<div class="w-full max-w-xl divide-y divide-stone-200 rounded-2xl bg-stone-50 ring-1 ring-stone-200">
  <div class="flex flex-wrap items-center gap-4 p-5">
    <span class="grid size-11 shrink-0 place-items-center rounded-lg bg-stone-800 text-xs font-bold text-stone-50">PDF</span>
    <div class="min-w-0 flex-1">
      <p class="truncate font-semibold text-stone-900">q3-supplier-report.pdf</p>
      <p class="text-sm text-stone-600">2.4 MB, edited by Tomás Reyes</p>
    </div>
  </div>
  <div class="flex flex-wrap gap-2 p-5">
    <button type="button" class="inline-flex min-h-11 cursor-pointer items-center gap-2 rounded-lg border px-4 text-sm font-semibold text-stone-800 transition-colors hover:bg-stone-800 hover:text-stone-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-stone-800"><svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4v11m0 0l-4-4m4 4l4-4M5 19h14"/></svg>Download</button>
    <button type="button" class="inline-flex min-h-11 cursor-pointer items-center gap-2 rounded-lg border px-4 text-sm font-semibold text-stone-800 transition-colors hover:bg-stone-800 hover:text-stone-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-stone-800"><svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 15V4m0 0L8 8m4-4l4 4M6 12v6a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-6"/></svg>Share</button>
    <button type="button" class="inline-flex min-h-11 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm font-semibold text-stone-700 transition-colors hover:bg-stone-200/70 hover:text-stone-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-stone-800"><svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 20h4L19 9l-4-4L4 16v4z"/></svg>Rename</button>
    <button type="button" class="inline-flex min-h-11 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm font-semibold text-stone-700 transition-colors hover:bg-stone-200/70 hover:text-stone-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-stone-800"><svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>Move</button>
  </div>
</div>

Brand color from an @theme token

  • @theme
  • bg-tbt-clay
  • text-tbt-clay-dark
  • outline-tbt-clay

Three --color-tbt-clay-* variables in @theme turn into real utilities: bg-tbt-clay, text-tbt-clay-dark, border-tbt-clay and outline-tbt-clay all exist as soon as the tokens do, with hover: and focus-visible: working as usual. The clay value keeps 5.7:1 contrast with white text, and the soft and outline buttons reuse the same tokens.

View code
<div class="w-full max-w-md rounded-2xl bg-white p-6 shadow-sm ring-1 ring-stone-900/5">
  <p class="text-sm font-semibold text-tbt-clay-dark">Saturday, 10:00 AM</p>
  <h3 class="mt-1 font-serif text-2xl text-stone-900">Wheel throwing for beginners</h3>
  <p class="mt-2 text-sm/6 text-stone-600">Three hours at the Kiln Yard studio. Clay, glazes and two firings included. 4 of 10 seats left.</p>
  <div class="mt-6 flex flex-wrap gap-3">
    <button type="button" class="min-h-11 w-full sm:w-auto sm:flex-1 cursor-pointer rounded-full bg-tbt-clay px-5 text-sm font-semibold text-white hover:bg-tbt-clay-dark focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tbt-clay active:translate-y-px">Book a seat, $64</button>
    <button type="button" class="min-h-11 cursor-pointer rounded-full bg-tbt-clay-soft px-5 text-sm font-semibold text-tbt-clay-dark hover:bg-tbt-clay-dark/10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tbt-clay">Save</button>
    <button type="button" class="min-h-11 cursor-pointer rounded-full border border-tbt-clay px-5 text-sm font-semibold text-tbt-clay hover:bg-tbt-clay-soft focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tbt-clay">Gift it</button>
  </div>
</div>

Light and dark button set

  • dark:
  • @custom-variant dark
  • aria-pressed
  • dark:hover:

Use the Dark mode button to switch the panel.

Every button carries its light classes plus dark: overrides, so one class string serves both themes. The panel starts with the dark class on its wrapper, and the toggle button removes it, which is exactly what a site-wide switch does on the html element with the @custom-variant dark line. In dark mode the primary flips to indigo-400 with near-black text to keep its contrast.

View code
<div id="tbt-theme" class="dark w-full max-w-xl">
  <div class="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-900/5 dark:bg-slate-900 dark:ring-white/10">
    <div class="flex items-start justify-between gap-4">
      <div class="min-w-0">
        <h3 class="text-base font-semibold text-slate-900 dark:text-white">Release v2.8.0 to production</h3>
        <p class="mt-1 text-sm text-slate-600 dark:text-slate-400">Build 1482 passed 214 checks, 3 minutes ago.</p>
      </div>
      <button type="button" id="tbt-theme-btn" aria-pressed="true" class="inline-flex min-h-11 shrink-0 cursor-pointer items-center gap-2 rounded-full px-3 text-sm font-medium text-slate-700 ring-1 ring-slate-300 ring-inset hover:bg-slate-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:text-slate-200 dark:ring-white/15 dark:hover:bg-white/10 dark:focus-visible:outline-indigo-300"><svg class="size-4 dark:hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg><svg class="hidden size-4 dark:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" aria-hidden="true"><path d="M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5z"/></svg><span class="max-sm:sr-only">Dark mode</span></button>
    </div>
    <div class="mt-6 flex flex-wrap gap-3">
      <button type="button" class="min-h-11 cursor-pointer rounded-lg bg-indigo-600 px-4 text-sm font-semibold text-white hover:bg-indigo-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-400 dark:text-slate-950 dark:hover:bg-indigo-300 dark:focus-visible:outline-indigo-300">Deploy now</button>
      <button type="button" class="min-h-11 cursor-pointer rounded-lg bg-white px-4 text-sm font-semibold text-slate-900 ring-1 ring-slate-300 ring-inset hover:bg-slate-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-white/5 dark:text-white dark:ring-white/15 dark:hover:bg-white/10 dark:focus-visible:outline-indigo-300">Schedule</button>
      <button type="button" class="min-h-11 cursor-pointer rounded-lg px-3 text-sm font-semibold text-slate-700 hover:bg-slate-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:text-slate-300 dark:hover:bg-white/10 dark:focus-visible:outline-indigo-300">View diff</button>
      <button type="button" class="min-h-11 cursor-pointer rounded-lg px-3 text-sm font-semibold text-red-700 hover:bg-red-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-700 sm:ml-auto dark:text-red-300 dark:hover:bg-red-500/10 dark:focus-visible:outline-red-300">Roll back</button>
    </div>
  </div>
</div>

States and feedback

Focus rings, press effects, loading, disabled, copied, pressed and confirm states, styled with Tailwind variants that read real HTML and ARIA attributes.

Focus rings and press effects

  • focus-visible:outline-*
  • outline-offset-*
  • active:translate-y-*
  • motion-safe:transition

Press Tab to move focus, then hold a button down.

Three keyboard focus styles: an outline with a 2px offset, an inset white outline drawn inside a dark fill with a negative offset, and a thick 3px outline in a contrasting color. The press effects use active: to shift the button down and shrink its hard shadow, wrapped in motion-safe:transition so reduced-motion users get the change without the animation.

View code
<div class="grid w-full max-w-2xl gap-4 sm:grid-cols-3">
  <div class="rounded-2xl bg-white p-5 text-center shadow-sm ring-1 ring-slate-900/5">
    <button type="button" class="tbt-focus-a min-h-11 w-full cursor-pointer rounded-lg bg-blue-700 px-4 text-sm font-semibold text-white hover:bg-blue-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-700 active:scale-[.97] motion-safe:transition-transform">Add to cart</button>
    <p class="mt-3 text-xs text-slate-600">Offset outline, active:scale</p>
  </div>
  <div class="rounded-2xl bg-white p-5 text-center shadow-sm ring-1 ring-slate-900/5">
    <button type="button" class="tbt-focus-b min-h-11 w-full cursor-pointer rounded-lg bg-slate-900 px-4 text-sm font-semibold text-white shadow-[0_4px_0_var(--color-slate-600)] hover:bg-slate-800 focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-white active:translate-y-1 active:shadow-none motion-safe:transition-all">Continue</button>
    <p class="mt-3 text-xs text-slate-600">Inset outline, pressed shadow</p>
  </div>
  <div class="rounded-2xl bg-white p-5 text-center shadow-sm ring-1 ring-slate-900/5">
    <button type="button" class="tbt-focus-c min-h-11 w-full cursor-pointer rounded-lg border-2 border-slate-900 bg-yellow-300 px-4 text-sm font-bold text-slate-900 shadow-[4px_4px_0_var(--color-slate-900)] focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-fuchsia-700 active:translate-x-1 active:translate-y-1 active:shadow-none motion-safe:transition-all">Join waitlist</button>
    <p class="mt-3 text-xs text-slate-600">Thick outline, hard shadow</p>
  </div>
</div>

Loading buttons with aria-busy

  • group-aria-busy:
  • animate-spin
  • motion-reduce:
  • aria-disabled

Click, press or Tab to the buttons to see the state change.

The script only sets aria-busy and aria-disabled; Tailwind does the rest with group-aria-busy: variants that show the spinner, swap the cursor and dim the fill. The first button changes its label, the second keeps its width by hiding the label with invisible and centering the spinner on top of it. Both stay focusable, and a role=status line announces the progress.

View code
<div class="w-full max-w-md rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-900/5">
  <h3 class="text-base font-semibold text-slate-900">Orders, July to September</h3>
  <p class="mt-1 text-sm text-slate-600">1,240 orders from three warehouses.</p>
  <div class="mt-5 flex flex-wrap gap-3">
    <button type="button" class="tbt-load group inline-flex min-h-11 cursor-pointer items-center gap-2 rounded-lg bg-violet-700 px-4 text-sm font-semibold text-white hover:bg-violet-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-700 aria-busy:cursor-wait aria-busy:bg-violet-600" data-busy-text="Exporting…" data-busy-status="Exporting 1,240 orders." data-done-status="Export ready: orders-q3.csv">
      <svg class="hidden size-4 animate-spin group-aria-busy:block motion-reduce:animate-none" viewBox="0 0 24 24" fill="none" aria-hidden="true"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="3" class="opacity-25"/><path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" stroke-width="3" stroke-linecap="round"/></svg>
      <svg class="size-4 group-aria-busy:hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4v11m0 0l-4-4m4 4l4-4M5 19h14"/></svg>
      <span class="tbt-load-label">Export CSV</span>
    </button>
    <button type="button" class="tbt-load group relative inline-flex min-h-11 cursor-pointer items-center justify-center rounded-lg bg-white px-4 text-sm font-semibold text-violet-800 ring-1 ring-violet-300 ring-inset hover:bg-violet-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-700 aria-busy:cursor-wait" data-busy-status="Sending the report to 3 managers." data-done-status="Report sent to 3 managers.">
      <span class="tbt-load-label group-aria-busy:invisible">Email report</span>
      <svg class="absolute inset-0 m-auto hidden size-5 animate-spin group-aria-busy:block motion-reduce:animate-none" viewBox="0 0 24 24" fill="none" aria-hidden="true"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="3" class="opacity-25"/><path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" stroke-width="3" stroke-linecap="round"/></svg>
    </button>
  </div>
  <p id="tbt-load-status" role="status" class="mt-4 min-h-5 text-sm text-slate-600"></p>
</div>

disabled vs aria-disabled

  • disabled:
  • aria-disabled:
  • aria-describedby
  • role=status

Click, press or Tab to the buttons to see the state change.

The left button uses the native disabled attribute and the disabled: variant: it cannot be focused or clicked. The right button uses aria-disabled="true" and the aria-disabled: variant, so it stays in the tab order, explains itself through aria-describedby, and the click handler returns a message instead of placing the order until a payment method is added.

View code
<div class="grid w-full max-w-2xl gap-4 sm:grid-cols-2">
  <div class="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-900/5">
    <p class="text-xs font-semibold text-slate-500">Native disabled</p>
    <h3 class="mt-1 text-base font-semibold text-slate-900">Check your inbox</h3>
    <p class="mt-1 text-sm text-slate-600">We sent a 6-digit code to sam@example.com.</p>
    <button type="button" disabled class="mt-4 min-h-11 w-full cursor-pointer rounded-lg bg-sky-700 px-4 text-sm font-semibold text-white hover:bg-sky-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700 disabled:cursor-not-allowed disabled:bg-slate-200 disabled:text-slate-500">Resend code in 0:42</button>
  </div>
  <div class="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-900/5">
    <p class="text-xs font-semibold text-slate-500">aria-disabled, still focusable</p>
    <h3 class="mt-1 text-base font-semibold text-slate-900">Order total $86.40</h3>
    <p id="tbt-place-why" class="mt-1 text-sm text-slate-600">Add a payment method to continue.</p>
    <div class="mt-4 flex flex-col gap-2">
      <button type="button" id="tbt-place" aria-disabled="true" aria-describedby="tbt-place-why" class="min-h-11 w-full cursor-pointer rounded-lg bg-sky-700 px-4 text-sm font-semibold text-white hover:bg-sky-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700 aria-disabled:cursor-not-allowed aria-disabled:bg-slate-100 aria-disabled:text-slate-600 aria-disabled:ring-1 aria-disabled:ring-slate-300">Place order</button>
      <button type="button" id="tbt-add-card" class="min-h-11 w-full cursor-pointer rounded-lg px-4 text-sm font-semibold text-sky-800 hover:bg-sky-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700">Add payment method</button>
    </div>
    <p id="tbt-place-msg" role="status" class="mt-2 min-h-5 text-sm font-medium text-slate-700"></p>
  </div>
</div>

Copy button with copied feedback

  • data-copied:
  • group-data-copied:
  • size-11
  • role=status

Click, press or Tab to the buttons to see the state change.

Clicking sets a data-copied attribute for 2.5 seconds, and data-copied: plus group-data-copied: variants swap the copy icon for a check, change the label and tint the button green. The copy itself is simulated here; in a real page call navigator.clipboard.writeText() in the same handler. The icon-only button carries an aria-label and the status line repeats the result for screen readers.

View code
<div class="w-full max-w-md rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-900/5">
  <h3 class="text-base font-semibold text-slate-900">Give friends 20% off</h3>
  <p class="mt-1 text-sm text-slate-600">They save on their first box, you get a free one when they order.</p>
  <label for="tbt-code" class="mt-5 block text-sm font-medium text-slate-800">Referral code</label>
  <div class="mt-1.5 flex gap-2">
    <input id="tbt-code" type="text" readonly value="LANE-7KQ2" class="min-h-11 w-full min-w-0 rounded-lg bg-slate-50 px-3 font-mono text-sm text-slate-900 ring-1 ring-slate-300 ring-inset focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-900">
    <button type="button" data-copy="tbt-code" class="tbt-copy group inline-flex min-h-11 shrink-0 cursor-pointer items-center gap-2 rounded-lg bg-slate-900 px-4 text-sm font-semibold text-white hover:bg-slate-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-900 data-copied:bg-emerald-700"><svg class="size-4 group-data-copied:hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/></svg><svg class="hidden size-4 group-data-copied:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg><span class="tbt-copy-label">Copy</span></button>
  </div>
  <label for="tbt-link" class="mt-4 block text-sm font-medium text-slate-800">Share link</label>
  <div class="mt-1.5 flex gap-2">
    <input id="tbt-link" type="text" readonly value="larderlane.example/r/7KQ2" class="min-h-11 w-full min-w-0 rounded-lg bg-slate-50 px-3 text-sm text-slate-900 ring-1 ring-slate-300 ring-inset focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-900">
    <button type="button" data-copy="tbt-link" aria-label="Copy share link" class="tbt-copy group grid size-11 shrink-0 cursor-pointer place-items-center rounded-lg text-slate-700 ring-1 ring-slate-300 ring-inset hover:bg-slate-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-900 data-copied:bg-emerald-50 data-copied:text-emerald-800 data-copied:ring-emerald-600/40"><svg class="size-5 group-data-copied:hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/></svg><svg class="hidden size-5 group-data-copied:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg></button>
  </div>
  <p id="tbt-copy-status" role="status" class="mt-3 min-h-5 text-sm text-emerald-800"></p>
</div>

Toggle buttons with aria-pressed

  • aria-pressed:
  • role=toolbar
  • aria-label
  • size-11

Click, press or Tab to the buttons to see the state change.

Each toggle is a button whose aria-pressed value flips on click, and the aria-pressed: variant paints the pressed state, so the style can never drift from what screen readers announce. Labels stay the same in both states, as the W3C pattern requires, and the icon-only toggles get their names from aria-label.

View code
<div class="w-full max-w-lg overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-slate-900/5">
  <div role="toolbar" aria-label="Text formatting" class="flex flex-wrap items-center gap-1 border-b border-slate-200 bg-slate-50 p-2">
    <button type="button" aria-pressed="true" aria-label="Bold" data-style="font-bold" class="tbt-toggle grid size-11 cursor-pointer place-items-center rounded-lg text-slate-700 hover:bg-slate-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-900 aria-pressed:bg-slate-900 aria-pressed:text-white"><svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linejoin="round" aria-hidden="true"><path d="M7 5h6a3.5 3.5 0 0 1 0 7H7zm0 7h7a3.5 3.5 0 0 1 0 7H7z"/></svg></button>
    <button type="button" aria-pressed="false" aria-label="Italic" data-style="italic" class="tbt-toggle grid size-11 cursor-pointer place-items-center rounded-lg text-slate-700 hover:bg-slate-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-900 aria-pressed:bg-slate-900 aria-pressed:text-white"><svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M11 5h8M5 19h8M15 5L9 19"/></svg></button>
    <button type="button" aria-pressed="false" aria-label="Underline" data-style="underline" class="tbt-toggle grid size-11 cursor-pointer place-items-center rounded-lg text-slate-700 hover:bg-slate-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-900 aria-pressed:bg-slate-900 aria-pressed:text-white"><svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M7 4v7a5 5 0 0 0 10 0V4M5 20h14"/></svg></button>
    <span class="mx-1 h-6 w-px bg-slate-300" aria-hidden="true"></span>
    <button type="button" aria-pressed="false" data-show="tbt-wc" class="tbt-toggle inline-flex min-h-11 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm font-medium text-slate-700 hover:bg-slate-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-900 aria-pressed:bg-amber-100 aria-pressed:text-amber-900 aria-pressed:ring-1 aria-pressed:ring-amber-400 aria-pressed:ring-inset">Word count</button>
  </div>
  <div class="p-5">
    <p id="tbt-sample" class="text-base/7 text-slate-800 font-bold">Fold the butter into the dough in three turns, chilling for twenty minutes between each one.</p>
    <p id="tbt-wc" class="mt-3 hidden text-sm text-slate-500">17 words, 1 sentence</p>
  </div>
</div>

Two-step destructive button

  • data-armed:
  • border-red-*
  • hidden attribute
  • role=status

Click, press or Tab to the buttons to see the state change.

The first click arms the button: a data-armed attribute switches it from a red outline to a solid red-700 fill through data-armed: variants, the label names the exact consequence and a Cancel button appears. Only the second click deletes, and the status line tells assistive technology what happened at each step.

View code
<section class="w-full max-w-lg rounded-2xl bg-white p-6 shadow-sm ring-1 ring-red-200">
  <h3 class="text-base font-semibold text-slate-900">Delete workspace</h3>
  <p class="mt-1 text-sm/6 text-slate-600">Removes Harbor Lab, its 38 projects and all shared files for 12 members. This cannot be undone.</p>
  <div class="mt-5 flex flex-wrap gap-3">
    <button type="button" id="tbt-del" class="inline-flex min-h-11 cursor-pointer items-center gap-2 rounded-lg border border-red-700 px-4 text-sm font-semibold text-red-700 hover:bg-red-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-700 data-armed:bg-red-700 data-armed:text-white data-armed:hover:bg-red-800 data-done:cursor-default data-done:border-slate-300 data-done:bg-slate-100 data-done:text-slate-600 data-done:hover:bg-slate-100"><svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3"/></svg><span id="tbt-del-label">Delete workspace</span></button>
    <button type="button" id="tbt-del-cancel" hidden class="min-h-11 cursor-pointer rounded-lg px-4 text-sm font-semibold text-slate-700 ring-1 ring-slate-300 ring-inset hover:bg-slate-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-900">Cancel</button>
  </div>
  <p id="tbt-del-status" role="status" class="mt-3 min-h-5 text-sm text-slate-700"></p>
</section>

Groups, menus and real-world layouts

Joined button groups, a radio-based segmented control, a split button with a Popover API menu, icon buttons, a floating action button and a generic sign-in stack.

Joined button groups

  • first:rounded-l-*
  • last:rounded-r-*
  • -ml-px
  • focus-visible:z-10

Pick an option or open the menu.

Each group is an inline-flex wrapper with role="group" and an aria-label. The buttons share inset rings, -ml-px overlaps neighbouring borders, first: and last: round only the outer corners, and focus-visible:z-10 lifts the focused button so its outline is not hidden under the next one. A few lines of script move the month and the zoom level.

View code
<div class="w-full max-w-xl rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-900/5">
  <div class="flex flex-wrap items-center justify-between gap-4">
    <h3 id="tbt-cal-title" class="text-lg font-semibold text-slate-900" aria-live="polite">October 2026</h3>
    <div role="group" aria-label="Change month" class="isolate inline-flex rounded-lg shadow-xs">
      <button type="button" id="tbt-cal-prev" aria-label="Previous month" class="relative -ml-px grid min-h-11 w-11 cursor-pointer place-items-center bg-white text-slate-600 ring-1 ring-slate-300 ring-inset first:ml-0 first:rounded-l-lg last:rounded-r-lg hover:bg-slate-50 focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"><svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 6l-6 6 6 6"/></svg></button>
      <button type="button" id="tbt-cal-today" class="relative -ml-px min-h-11 cursor-pointer bg-white px-4 text-sm font-semibold text-slate-900 ring-1 ring-slate-300 ring-inset first:ml-0 first:rounded-l-lg last:rounded-r-lg hover:bg-slate-50 focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">Today</button>
      <button type="button" id="tbt-cal-next" aria-label="Next month" class="relative -ml-px grid min-h-11 w-11 cursor-pointer place-items-center bg-white text-slate-600 ring-1 ring-slate-300 ring-inset first:ml-0 first:rounded-l-lg last:rounded-r-lg hover:bg-slate-50 focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"><svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg></button>
    </div>
  </div>
  <div class="mt-5 flex flex-wrap items-center gap-3 border-t border-slate-200 pt-5">
    <span class="text-sm text-slate-600">Zoom</span>
    <div role="group" aria-label="Zoom" class="isolate inline-flex rounded-full bg-slate-900 p-1">
      <button type="button" id="tbt-zoom-out" aria-label="Zoom out" class="grid size-10 cursor-pointer place-items-center rounded-full text-white hover:bg-white/15 focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-300"><svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M5 12h14"/></svg></button>
      <output id="tbt-zoom" class="grid min-w-14 place-items-center text-sm font-semibold text-white tabular-nums">100%</output>
      <button type="button" id="tbt-zoom-in" aria-label="Zoom in" class="grid size-10 cursor-pointer place-items-center rounded-full text-white hover:bg-white/15 focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-300"><svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg></button>
    </div>
  </div>
</div>

Segmented control from radio buttons

  • has-checked:
  • has-focus-visible:
  • group-has-[#id:checked]:
  • sr-only

Pick an option or open the menu.

The segments are labels wrapping visually hidden radio inputs, so arrow keys, the checked state and form submission come from the browser. has-checked: styles the selected label as a raised white tab, has-focus-visible: draws the keyboard outline, and group-has-[#tbt-bill-y:checked]: swaps the price on the card without any JavaScript.

View code
<div class="group w-full max-w-sm rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-900/5">
  <fieldset>
    <legend class="sr-only">Billing period</legend>
    <div class="grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1">
      <label class="flex min-h-11 cursor-pointer items-center justify-center rounded-lg text-sm font-semibold text-slate-600 hover:text-slate-900 has-checked:bg-white has-checked:text-slate-900 has-checked:shadow-sm has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-indigo-600"><input type="radio" name="tbt-bill" id="tbt-bill-m" value="monthly" class="sr-only" checked>Monthly</label>
      <label id="tbt-bill-y-label" class="flex min-h-11 cursor-pointer items-center justify-center gap-2 rounded-lg text-sm font-semibold text-slate-600 hover:text-slate-900 has-checked:bg-white has-checked:text-slate-900 has-checked:shadow-sm has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-indigo-600"><input type="radio" name="tbt-bill" id="tbt-bill-y" value="yearly" class="sr-only">Yearly <span class="rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-semibold text-emerald-800">-20%</span></label>
    </div>
  </fieldset>
  <h3 class="mt-6 text-sm font-semibold text-indigo-700">Studio plan</h3>
  <p class="mt-2 flex items-baseline gap-1">
    <span class="text-4xl font-bold tracking-tight text-slate-900 group-has-[#tbt-bill-y:checked]:hidden">$25</span>
    <span id="tbt-price-y" class="hidden text-4xl font-bold tracking-tight text-slate-900 group-has-[#tbt-bill-y:checked]:inline">$20</span>
    <span class="text-sm text-slate-600">per seat / month</span>
  </p>
  <p class="mt-1 min-h-5 text-sm text-slate-600"><span class="group-has-[#tbt-bill-y:checked]:hidden">Billed monthly, cancel anytime.</span><span class="hidden group-has-[#tbt-bill-y:checked]:inline">Billed $240 per seat once a year.</span></p>
  <button type="button" class="mt-6 min-h-11 w-full cursor-pointer rounded-lg bg-indigo-600 px-4 text-sm font-semibold text-white hover:bg-indigo-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">Start 14-day trial</button>
</div>

Split button with a Popover API menu

  • popovertarget
  • aria-checked:
  • group-aria-expanded:rotate-180
  • inset-auto

Pick an option or open the menu.

The arrow half opens a native popover through popovertarget, so outside clicks and Escape close it with no code. The menu items are menuitemradio buttons whose aria-checked: variant shows the check mark, the script places the popover under the arrow, keeps aria-expanded in sync for the group-aria-expanded:rotate-180 chevron and adds arrow-key movement.

View code
<div class="w-full max-w-md self-start rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-900/5">
  <h3 class="text-base font-semibold text-slate-900">This branch has no conflicts</h3>
  <p class="mt-1 text-sm text-slate-600">feature/saved-searches, 6 commits, 2 approvals</p>
  <div class="mt-5 inline-flex max-w-full rounded-lg shadow-xs">
    <button type="button" id="tbt-merge" class="min-h-11 min-w-0 cursor-pointer truncate rounded-l-lg bg-emerald-700 px-4 text-sm font-semibold text-white hover:bg-emerald-800 focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-700">Create a merge commit</button>
    <button type="button" id="tbt-merge-more" popovertarget="tbt-merge-menu" aria-haspopup="menu" aria-expanded="false" aria-label="More merge options" class="group grid min-h-11 w-11 shrink-0 cursor-pointer place-items-center rounded-r-lg border-l border-emerald-900/40 bg-emerald-700 text-white hover:bg-emerald-800 focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-700"><svg class="size-4 group-aria-expanded:rotate-180 motion-safe:transition-transform" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg></button>
  </div>
  <div id="tbt-merge-menu" popover role="menu" aria-label="Merge method" class="inset-auto m-0 w-72 max-w-[calc(100vw-1rem)] rounded-xl bg-white p-1.5 shadow-lg ring-1 ring-slate-900/10">
    <button type="button" role="menuitemradio" aria-checked="true" data-label="Create a merge commit" class="tbt-merge-opt group flex w-full cursor-pointer gap-3 rounded-lg p-2.5 text-left hover:bg-slate-100 focus-visible:bg-slate-100 focus-visible:outline-none"><svg class="invisible mt-0.5 size-4 shrink-0 text-emerald-700 group-aria-checked:visible" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg><span><span class="block text-sm font-semibold text-slate-900">Create a merge commit</span><span class="block text-xs text-slate-600">Keep all 6 commits and add a merge commit.</span></span></button>
    <button type="button" role="menuitemradio" aria-checked="false" data-label="Squash and merge" class="tbt-merge-opt group flex w-full cursor-pointer gap-3 rounded-lg p-2.5 text-left hover:bg-slate-100 focus-visible:bg-slate-100 focus-visible:outline-none"><svg class="invisible mt-0.5 size-4 shrink-0 text-emerald-700 group-aria-checked:visible" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg><span><span class="block text-sm font-semibold text-slate-900">Squash and merge</span><span class="block text-xs text-slate-600">Combine the commits into one.</span></span></button>
    <button type="button" role="menuitemradio" aria-checked="false" data-label="Rebase and merge" class="tbt-merge-opt group flex w-full cursor-pointer gap-3 rounded-lg p-2.5 text-left hover:bg-slate-100 focus-visible:bg-slate-100 focus-visible:outline-none"><svg class="invisible mt-0.5 size-4 shrink-0 text-emerald-700 group-aria-checked:visible" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg><span><span class="block text-sm font-semibold text-slate-900">Rebase and merge</span><span class="block text-xs text-slate-600">Replay the commits on top of main.</span></span></button>
  </div>
</div>

Icon-only and icon-with-label buttons

  • aria-label
  • size-11
  • backdrop-blur-sm
  • inline-flex gap-2

Pick an option or open the menu.

The round buttons over the photo are icon-only, so each has an aria-label and a decorative SVG marked aria-hidden, at size-11 for a 44px target. bg-white/85 with backdrop-blur-sm keeps them readable on any photo. Below, inline-flex with gap-2 places a leading or trailing icon next to a text label.

View code
<article class="w-full max-w-md overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-slate-900/5">
  <div class="relative">
    <img src="canal-houses.webp" width="800" height="600" alt="Small boats moored on a canal lined with old brick and plastered houses under a clear sky" class="aspect-[4/3] w-full object-cover">
    <div class="absolute top-3 right-3 flex gap-2">
      <button type="button" aria-label="Save to collection" class="grid size-11 cursor-pointer place-items-center rounded-full bg-white/85 text-slate-800 shadow-sm backdrop-blur-sm hover:bg-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"><svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" aria-hidden="true"><path d="M7 4h10v16l-5-4-5 4z"/></svg></button>
      <button type="button" aria-label="Share photo" class="grid size-11 cursor-pointer place-items-center rounded-full bg-white/85 text-slate-800 shadow-sm backdrop-blur-sm hover:bg-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"><svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 15V4m0 0L8 8m4-4l4 4M6 12v6a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-6"/></svg></button>
    </div>
  </div>
  <div class="p-5">
    <h3 class="text-base font-semibold text-slate-900">Morning canal walk</h3>
    <p class="mt-1 text-sm text-slate-600">2.5 km loop, mostly flat, 40 minutes</p>
    <div class="mt-4 flex flex-wrap gap-2">
      <button type="button" class="inline-flex min-h-11 cursor-pointer items-center gap-2 rounded-lg bg-cyan-800 px-4 text-sm font-semibold text-white hover:bg-cyan-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cyan-800"><svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" aria-hidden="true"><path d="M4 11l16-7-7 16-2-7z"/></svg>Directions</button>
      <button type="button" class="inline-flex min-h-11 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm font-semibold text-cyan-900 hover:bg-cyan-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cyan-800">Open gallery<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14m-5-5l5 5-5 5"/></svg></button>
      <button type="button" aria-label="More options" class="ml-auto grid size-11 cursor-pointer place-items-center rounded-lg text-slate-600 hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cyan-800"><svg class="size-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/></svg></button>
    </div>
  </div>
</article>

Floating action button with speed dial

  • absolute bottom-* right-*
  • group-data-open:
  • aria-expanded
  • motion-safe:transition

Pick an option or open the menu.

The 56px round button sits in the corner of the app screen with absolute positioning and a colored shadow. Clicking it sets aria-expanded and a data-open attribute on the wrapper, and group-data-open: variants rotate the plus into a close icon and reveal the three labelled actions. Hidden actions use invisible, so they are skipped by Tab until the dial opens, and Escape closes it.

View code
<div class="relative h-[28rem] w-full max-w-xs overflow-hidden rounded-[2rem] bg-amber-50 ring-8 ring-slate-900">
  <div class="px-5 pt-6">
    <h3 class="text-xl font-bold text-slate-900">Notes</h3>
    <ul class="mt-4 space-y-3">
      <li class="rounded-xl bg-white p-3 shadow-xs"><p class="text-sm font-semibold text-slate-900">Market list</p><p class="text-xs text-slate-600">Leeks, rye flour, 2 lemons</p></li>
      <li class="rounded-xl bg-white p-3 shadow-xs"><p class="text-sm font-semibold text-slate-900">Garden shed</p><p class="text-xs text-slate-600">Measure the door before Sunday</p></li>
      <li class="rounded-xl bg-white p-3 shadow-xs"><p class="text-sm font-semibold text-slate-900">Book club</p><p class="text-xs text-slate-600">Chapters 9 to 12, host is Ana</p></li>
    </ul>
    <p id="tbt-dial-status" role="status" class="mt-3 min-h-4 text-xs font-medium text-slate-700"></p>
  </div>
  <div id="tbt-dial" class="group absolute right-5 bottom-5 flex flex-col items-end gap-3">
    <div class="flex flex-col items-end gap-2">
      <button type="button" class="tbt-dial-item invisible inline-flex min-h-11 translate-y-2 cursor-pointer items-center gap-2 rounded-full bg-white px-4 text-sm font-semibold text-slate-800 opacity-0 shadow-md ring-1 ring-slate-900/5 group-data-open:visible group-data-open:translate-y-0 group-data-open:opacity-100 hover:bg-slate-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-orange-700 motion-safe:transition-all"><svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 8h3l2-3h6l2 3h3v11H4z"/><circle cx="12" cy="13" r="3.5"/></svg>Photo note</button>
      <button type="button" class="tbt-dial-item invisible inline-flex min-h-11 translate-y-2 cursor-pointer items-center gap-2 rounded-full bg-white px-4 text-sm font-semibold text-slate-800 opacity-0 shadow-md ring-1 ring-slate-900/5 group-data-open:visible group-data-open:translate-y-0 group-data-open:opacity-100 hover:bg-slate-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-orange-700 motion-safe:transition-all"><svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v3"/></svg>Voice memo</button>
      <button type="button" class="tbt-dial-item invisible inline-flex min-h-11 translate-y-2 cursor-pointer items-center gap-2 rounded-full bg-white px-4 text-sm font-semibold text-slate-800 opacity-0 shadow-md ring-1 ring-slate-900/5 group-data-open:visible group-data-open:translate-y-0 group-data-open:opacity-100 hover:bg-slate-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-orange-700 motion-safe:transition-all"><svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 6h11M9 12h11M9 18h11M4 6l1 1 2-2M4 12l1 1 2-2M4 18l1 1 2-2"/></svg>Checklist</button>
    </div>
    <button type="button" id="tbt-fab" aria-expanded="false" aria-label="Create" class="grid size-14 cursor-pointer place-items-center rounded-full bg-orange-700 text-white shadow-lg shadow-orange-900/30 hover:bg-orange-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-orange-700 active:scale-95 motion-safe:transition"><svg class="size-6 group-data-open:rotate-45 motion-safe:transition-transform" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg></button>
  </div>
</div>

Generic sign-in button stack

  • grid gap-3
  • ring-1 ring-inset
  • size-5 icons
  • focus-visible:outline-*

Pick an option or open the menu.

A sign-in stack in the style of social login buttons, with generic icons instead of brand logos: the passkey option is the solid primary and the rest are white buttons with inset rings. Each button is full width with the icon pinned left by a three-column grid, so labels line up even when the icons differ in width.

View code
<div class="w-full max-w-sm rounded-2xl bg-white p-6 shadow-lg ring-1 ring-slate-900/5 sm:p-8">
  <h3 class="text-xl font-semibold text-slate-900">Sign in to Fernway</h3>
  <p class="mt-1 text-sm text-slate-600">Pick up your trip plans where you left off.</p>
  <div class="mt-6 grid gap-3">
    <button type="button" class="grid min-h-12 cursor-pointer grid-cols-[1.25rem_1fr_1.25rem] items-center gap-3 rounded-xl bg-slate-900 px-4 text-sm font-semibold text-white hover:bg-slate-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-900"><svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="8" cy="15" r="4"/><path d="M11 12l9-9m-3 3l3 3m-6 0l2 2"/></svg><span>Continue with a passkey</span></button>
    <button type="button" class="grid min-h-12 cursor-pointer grid-cols-[1.25rem_1fr_1.25rem] items-center gap-3 rounded-xl bg-white px-4 text-sm font-semibold text-slate-900 ring-1 ring-slate-300 ring-inset hover:bg-slate-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-900"><svg class="size-5 text-slate-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 7l9 6 9-6"/></svg><span>Continue with email</span><span class="sr-only">(last used)</span><span class="size-2 justify-self-center rounded-full bg-emerald-600" aria-hidden="true"></span></button>
    <button type="button" class="grid min-h-12 cursor-pointer grid-cols-[1.25rem_1fr_1.25rem] items-center gap-3 rounded-xl bg-white px-4 text-sm font-semibold text-slate-900 ring-1 ring-slate-300 ring-inset hover:bg-slate-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-900"><svg class="size-5 text-slate-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><rect x="7" y="2.5" width="10" height="19" rx="2"/><path d="M11 18h2"/></svg><span>Continue with phone</span></button>
    <button type="button" class="grid min-h-12 cursor-pointer grid-cols-[1.25rem_1fr_1.25rem] items-center gap-3 rounded-xl bg-white px-4 text-sm font-semibold text-slate-900 ring-1 ring-slate-300 ring-inset hover:bg-slate-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-900"><svg class="size-5 text-slate-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 21V5a1 1 0 0 1 1-1h9a1 1 0 0 1 1 1v16M15 9h4a1 1 0 0 1 1 1v11M3 21h18M8 8h3M8 12h3M8 16h3"/></svg><span>Continue with company SSO</span></button>
  </div>
  <p class="mt-6 text-center text-sm text-slate-600">New here? <a href="#" class="font-semibold text-slate-900 underline decoration-slate-300 underline-offset-4 hover:decoration-slate-900 focus-visible:rounded-xs focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-900">Create an account</a></p>
</div>

Does Tailwind CSS include a button component?

No. Tailwind has no button component, only the native button element and the utilities you put on it.

The Tailwind v1 component examples page said so plainly, and the v4.3 documentation says it through its structure. Its utility groups run from layout and spacing to SVG and accessibility, and none of them is a button.

The reason comes down to how the framework works. Tailwind is a utility-first framework, so each class sets one declaration and the markup composes everything else.

The framework does supply utilities for padding, color, radius, shadow and type, plus state variants like hover, focus-visible and disabled. Preflight, the base reset, strips the browser’s own button styling.

The rest is your job.

  • Choosing the element and its type attribute
  • Defining variants and sizes
  • Focus visibility and contrast
  • Reusing the class string across the project

The native element brings real behavior with it. A button takes focus with Tab, activates on Enter and Space, and submits its form unless the type is set to button.

Bootstrap goes the other way, with ready-made button classes that carry their own look. Tailwind gives up the pre-built look in exchange for control over every property. The official editor extension, Tailwind CSS IntelliSense, autocompletes class names as you type, which takes most of the sting out of the long strings.

Which utility classes make a basic Tailwind button?

A basic button needs px-4, py-2, text-sm, font-medium, rounded-md, bg-blue-600 and text-white. That’s the whole recipe.

<button type="button" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white">
  Save changes
  </button>

You need all of them because Preflight wipes the browser look. Per the Preflight stylesheet in the Tailwind repository, buttons inherit font and color, get a border radius of 0, and start with a transparent background.

Skip the utilities and the button reads as plain text.

  • px-4 compiles to padding-inline of calc(var(–spacing) * 4), which is 1rem at the default 0.25rem spacing value
  • text-sm and font-medium handle the type
  • rounded-md gives the shape
  • The background and text color need enough contrast between them

Radius and shadow names moved in v4. Per the Tailwind CSS upgrade guide, rounded became rounded-sm, rounded-sm became rounded-xs, and shadow-sm became shadow-xs, so a copied v3 snippet that uses the -sm names looks different until it is renamed.

Set type=”button” on any button inside a form, since the default type submits the form. And let the Prettier plugin for Tailwind CSS sort classes into a fixed order, so diffs stay readable.

How to build solid, outline, ghost, and destructive button variants

A variant changes only color utilities (background, text, border and the hover shade). Padding, radius and type stay identical across the set.

VariantBase utilitiesHoverTypical use
Solidbg-blue-600 text-whitehover:bg-blue-700Primary action
Softbg-blue-50 text-blue-700hover:bg-blue-100Secondary action
Outlineborder border-blue-600 text-blue-600hover:bg-blue-50Alternative action
Ghosttext-blue-600hover:bg-blue-50Toolbar, low emphasis
Destructivebg-red-600 text-whitehover:bg-red-700Delete, remove

Solid and soft variants

Solid is the default for the main action on a screen. It carries the weight a call-to-action button needs, so use it once per view.

Soft keeps the hue but drops the saturation, which makes it a clear secondary choice next to a solid button without competing with it.

Outline and ghost variants

Outline is a border plus a matching text color, with a light fill on hover. In v4 a bare border follows the text color, because the default border color changed from gray-200 to currentColor (Tailwind CSS upgrade guide), so the separate border color utility can be dropped.

Ghost has no border and no fill until hover. It’s the quietest variant in the set.

Ordered this way, the variants build a visual hierarchy, with solid at the top, ghost at the bottom, and soft and outline in between.

Destructive variant

Destructive is just the solid pattern on the red scale, with the same padding, radius and type as the primary button. Label it with words that name the action, since color alone shouldn’t carry the meaning.

Use soft red for reversible actions and solid red for the final confirm step.

How to style hover, focus, active, and disabled states

Each state gets its own prefixed utility. hover:, focus-visible:, active: and disabled: apply a class only while that condition holds.

<button type="button" class="bg-blue-600 text-white transition-colors hover:bg-blue-700 active:bg-blue-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:cursor-not-allowed disabled:opacity-50">
  Save changes
  </button>

The hover variant fires with a pointer over the button, on devices that support hover. focus-visible reacts to keyboard focus only, so mouse clicks leave no ring. active is the moment of pressing, and disabled matches the native disabled attribute.

Version 4.0 changed the hover variant. It now applies only when the primary input device supports hover (Tailwind CSS upgrade guide). A tap on a touch screen no longer triggers hover styles, and a project that depended on tap-to-hover restores the old behavior with @custom-variant hover (&:hover);.

I’d go with focus-visible over plain focus for buttons. In Chrome and Firefox, plain focus matches after a mouse click, which leaves a ring behind every press.

The disabled: variant matches only elements with the native disabled attribute. A link styled as a button needs aria-disabled: instead, a variant Tailwind includes for aria-disabled=”true”.

Headless UI’s Button takes a different route and exposes state as data attributes (data-hover, data-focus, data-active, data-disabled), which Tailwind reads with variants like data-hover:bg-sky-500 (Headless UI documentation).

Hover and transition effects

The transition-colors utility runs for 150ms with a cubic-bezier(0.4, 0, 0.2, 1) easing by default (Tailwind CSS docs, v4.3).

That short fade is one of the small micro-interactions that tell a user the button registered the pointer.

In v4, transition and transition-colors include outline-color, so a focus outline with a custom color animates from the default color unless the color is set unconditionally (Tailwind CSS upgrade guide). Reduced-motion users are the other catch, and motion-safe:transition drops the effect under their system setting.

Loading state with animate-spin

A loading button is a disabled button with a spinner inside it. The Tailwind documentation example places an SVG with animate-spin before the label and hides it under reduced motion with motion-reduce:hidden.

  1. Set the disabled attribute so a second click cannot fire
  2. Add animate-spin to the SVG
  3. Change the label to a progress word such as “Processing…”

If you’d rather not build the spinner yourself, a dedicated Tailwind spinner component drops straight into the button.

How to size buttons, add icons, and make them full width

Sizing comes down to a padding and text-size pair. One scale from xs to xl keeps every button in the project consistent.

Size scale

  • xs: px-2 py-1 text-xs
  • sm: px-3 py-1.5 text-sm
  • md: px-4 py-2 text-sm
  • lg: px-5 py-2.5 text-base
  • xl: px-6 py-3 text-base

Padding comes from the –spacing theme variable (Tailwind CSS docs, v4.3). Changing it rescales every spacing utility in the project, not only buttons.

Icon and icon-only buttons

Put inline-flex and items-center on the button, add gap-2 for spacing, then give the SVG size-4. The size-* utilities set width and height together (Tailwind CSS docs, v4.3).

An icon-only button swaps the horizontal padding for square padding such as p-2 and needs an aria-label, because an SVG alone gives screen readers no name.

Full-width and responsive width

Don’t use sm: to target phones. Put w-full on the button for phones and sm:w-auto for 640px and up.

Tailwind is mobile-first, so unprefixed utilities apply at every width and prefixed ones apply from their breakpoint upward. The sm breakpoint sits at 40rem (640px), per the Tailwind CSS responsive design docs.

For touch screens, the pointer-coarse: variant (for example pointer-coarse:py-3) enlarges the target only on devices with an imprecise pointer.

How to keep a Tailwind button accessible

Tailwind leaves accessibility to the class list. Use a native element for actions, and make sure the focus indicator and label text have enough contrast. The target also needs to be big enough for a thumb.

Button, anchor, or input

Actions use button. Navigation uses a.

An input with type=”submit” is the legacy third option, and MDN Web Docs explains why button wins. It accepts child markup such as an SVG icon, while input only carries a value attribute for its label.

A link styled as a button keeps the a element and its href with the same classes applied, and screen readers still announce a link, which is correct for navigation. The reverse is a button styled as a link. It still announces as a button and fires an action, so reserve link-style classes for actions.

Focus ring and contrast

Deleting the outline without a replacement strands keyboard users. Version 4 also changed what the old class does. outline-none now sets outline-style to none, and the invisible-outline behavior moved to outline-hidden, which still shows in forced colors mode (Tailwind CSS upgrade guide).

Test the focus color against both the button fill and the page behind it, and give the indicator an offset so it separates from the fill. Hover and active fills need their own contrast check as well.

MDN advises leaving the default focus ring alone unless the replacement has enough contrast, and WCAG 1.4.11 sets the bar at 3:1 for an author-styled indicator (W3C). Run each fill and ring pair through a color contrast checker before shipping a variant.

Disabled vs aria-disabled

The native disabled attribute blocks pressing and focus, keeps the button’s value out of form submission, and applies browser disabled styling automatically.

aria-disabled=”true” does less. It exposes the disabled state to assistive technology and nothing else. The button stays focusable and clickable, so a handler has to return early, and since no styling comes with it, the dimmed look is yours to write.

aria-disabled earns its place when a button must stay in the tab order, such as a submit button whose action is not available yet (MDN Web Docs). The attribute comes from ARIA, so it describes state to assistive technology and leaves behavior to your code.

WCAG 1.4.3 exempts text on inactive components from contrast requirements (W3C), which makes disabled:opacity-50 safe on native disabled buttons. MDN draws the line differently for aria-disabled buttons that stay in the focus order, since those labels still need to be legible.

Key figures to check before shipping

  • 4.5:1 for label text, 3:1 for large-scale text (W3C, WCAG 2.2 SC 1.4.3)
  • 3:1 for component boundaries, state changes, and author-styled focus indicators (W3C, SC 1.4.11)
  • 24 by 24 CSS pixels as the minimum target size at Level AA (W3C, SC 2.5.8)
  • 44 by 44 CSS pixels at Level AAA (W3C, SC 2.5.5), which is also the size MDN recommends for buttons

At the default 0.25rem spacing value and a 16px root, min-h-6 and min-w-6 give 24 pixels, and min-h-11 and min-w-11 give 44.

An icon-only button with p-2 and a size-4 icon measures 32 by 32. It clears the AA floor but falls short of the 44 pixel recommendation.

Reusing button styles: components, @apply, or @utility

In a framework project, build a button component and keep the classes inside it. In a templating language, use a template partial. Reach for custom CSS, @apply, or @utility only when a partial feels heavy or you need to override styles you do not control, which matches the order of preference in the Tailwind CSS docs.

MethodWhere styles liveBest fitMain drawback
Framework componentComponent fileReact, Vue projectsNeeds a framework
Template partialPartial fileBlade, ERB, Twig, NunjucksVariants need template logic
Component class in @layer componentsCSS fileOne small button where a partial feels heavyBack to writing CSS
@utilityCSS fileCustom class that must work with hover, focus, lgv4 API, replaces the v3 layer approach
@applyCSS fileOverriding third-party markupNeeds @reference in Vue, Svelte, CSS modules

Component with variant props

class-variance-authority (cva) turns a variant map into a function. Besides plain variants it supports compound and default ones, and its docs cover boolean variants such as disabled.

const button = cva("inline-flex items-center rounded-md font-medium", {
  variants: {
    intent: {
      solid: "bg-blue-600 text-white hover:bg-blue-700",
      ghost: "text-blue-600 hover:bg-blue-50",
    },
    size: {
      sm: "px-3 py-1.5 text-sm",
      md: "px-4 py-2 text-sm",
    },
  },
  defaultVariants: { intent: "solid", size: "md" },
});
  1. Put the classes every variant shares in the base string
  2. List each variant and size as its own key
  3. Set defaultVariants so a bare button renders the primary look
  4. Add compoundVariants only for combinations that need their own classes

In a React project, setting up Tailwind CSS in React comes first, and the component file then becomes the single source of truth. A Vue project follows the same pattern with a single-file component.

A shared variant map is also the first piece of a design system, since every screen reads the same intents and sizes.

@apply and @utility

@apply inlines existing utilities into your own CSS. The Tailwind docs demonstrate it on third-party library styles such as select2 dropdowns. From v4 on, Vue, Svelte and CSS modules also need @reference, because separately bundled stylesheets cannot see theme variables, custom utilities, or custom variants (Tailwind CSS upgrade guide).

@utility registers a custom class that works with hover, focus, and lg, and sorts so ordinary utilities override it (Tailwind CSS upgrade guide). A .btn utility can therefore take px-6 on a single instance without a fight.

Merging conflicting classes

Class order in the attribute decides nothing.

When two classes set the same property, the one that appears later in the generated stylesheet wins, so grid flex renders as display: grid (Tailwind CSS docs). A component that accepts a className prop cannot trust bg-red-600 to beat bg-blue-600.

tailwind-merge fixes that by keeping the last conflicting class in the string you pass it. Its README turns px-2 py-1 bg-red hover:bg-dark-red plus p-3 bg-[#B91C1C] into hover:bg-dark-red p-3 bg-[#B91C1C], and the current release, 3.7.0, supports Tailwind v4.0 through v4.3.

clsx joins conditional class strings and tailwind-merge resolves the result, so the two usually appear together.

The Tailwind docs suggest a different route. Expose specific props for style changes instead of accepting arbitrary classes. That is the better default for a button, because a closed set of intents and sizes cannot conflict.

Where Tailwind button styles break

Most breakage comes from v3 habits meeting v4 defaults, as the Tailwind CSS upgrade guide lays out.

Defaultv3v4
Button cursorpointerdefault
Ring width3px1px
Ring colorblue-500currentColor

The pointer cursor is gone

Preflight no longer gives buttons cursor: pointer, so a button shows the arrow cursor like the browser default.

@layer base {
  button:not(:disabled),
  [role="button"]:not(:disabled) {
    cursor: pointer;
  }
}

The shadcn/ui documentation flags the same change and offers an init –pointer option for projects that want the old behavior.

Whether to restore it is a design-system call. Links styled as buttons keep the pointer either way, because browsers give every link one.

Copied focus rings look thinner

A v3 snippet with focus:ring now draws a 1px ring in the current text color instead of a 3px blue one.

  • Swap ring for ring-3 to restore the width
  • Add ring-blue-500 wherever the old default color was relied on
  • Or set –default-ring-width and –default-ring-color in @theme, which Tailwind labels a compatibility option and not idiomatic v4

The new color default is the dangerous part. On a solid button with white label text, a currentColor ring renders white, so it vanishes against a white page and fails the 3:1 requirement for focus indicators (W3C, SC 1.4.11).

Older browsers

Tailwind v4.0 targets Safari 16.4, Chrome 111, and Firefox 128, and it will not work in older browsers.

The upgrade guide tells projects that need older browsers to stay on v3.4, because core features depend on @property and color-mix(). Check cross-browser compatibility against that list before upgrading, since the whole stylesheet depends on those features and not only the buttons.

Which prebuilt Tailwind button libraries are worth using?

Use a library when a project needs several button variants and nobody maintains a shared component. For one or two button styles, the utility strings are shorter than the install.

LibraryProsCons
daisyUIShort btn classes with color, style, and size modifiers; works on a, button, and inputbtn-disabled only changes the look
FlowbiteReady variants: default, pills, gradient, shadows, sizes; dark: variants in the markupLong utility strings per button unless wrapped
shadcn/uiSource copied into your repo; variant and size props; buttonVariants for linksReact-first; you maintain the copied code
Headless UIUnstyled, accessible primitive with no visual opinionsEvery class is still yours to write
Tailwind PlusOfficial components and templates from Tailwind LabsClosed to new customers since September 9, 2026

Tailwind Labs announced on September 9, 2026 that it is joining Shopify and closing sign-ups for Tailwind Plus and ui.sh. Existing customers keep their access, and Tailwind CSS stays MIT-licensed.

That removes Tailwind Plus as an option for new projects.

Some fine print separates the others. daisyUI’s docs show a disabled button built with the btn-disabled class that still needs tabindex and aria-disabled added by hand. The shadcn/ui docs warn against rendering the Base UI version of Button as an anchor because it always applies role=”button”, which overrides the link role.

  • For one or two button styles, write the utilities and skip the dependency
  • A React app that wants to own its button source suits shadcn/ui
  • Server-rendered pages that want short class names do well with daisyUI
  • Flowbite gives you a ready visual set with dark variants in the markup
  • If the design is yours and you only want accessible primitives, Headless UI fits

Tailwind Button FAQ

How do I make a gradient button?

Add a direction utility and color stops, for example bg-linear-to-r from-blue-500 to-indigo-600. In v4 the old bg-gradient-to-r name became bg-linear-to-r, and a variant such as dark:from-blue-500 now overrides only that stop instead of resetting the whole gradient.

Can Tailwind build a button group with joined edges?

Yes, although it has no group component. Wrap the buttons in an inline-flex container, pull the borders together with a negative margin, and use first:rounded-l-md and last:rounded-r-md so only the outer ends stay rounded.

How do I style a button for dark mode?

Add a dark: variant for each property that changes, such as dark:bg-blue-500 and dark:hover:bg-blue-400. By default the variant follows the operating system through prefers-color-scheme, so a class-based toggle needs the dark variant redefined with @custom-variant.

Does onClick work on a Tailwind-styled button in React?

Yes. onClick is an ordinary React prop, and Tailwind touches the element only through className.

Its variants are pure CSS, so hover, active, and disabled styling respond without any extra event handlers.

Is Tailwind Plus free?

No. Tailwind Plus was a one-time purchase with no subscription, and since September 9, 2026 new customers cannot buy it at all. Tailwind CSS itself stays free under the MIT license.

How to Test a Tailwind Button Before Shipping

Test in a fixed order, because each check depends on the one before it.

  • Keyboard operation with Tab, Enter, and Space
  • Focus ring visible on every background
  • Contrast in hover, active, and disabled states

Keyboard comes first. A button that cannot receive focus makes the ring and state checks meaningless.

The trade-off is visual weight. A ring that reads on both light and dark sections usually needs an offset utility and a second color class per variant, which makes the class list longer.

This order was verified against Tailwind CSS v4.3 on October 23, 2026. A change to the focus-visible or hover defaults in a later release would shift the sequence, so rerun all three checks after each major upgrade.