HextaUI

Button

Buttons in every variant and size, with a built-in loading, success and error flow that skips the spinner for fast requests.

"use client"

import { Button } from "@/components/ui/button"

function wait(ms: number) {
  return new Promise<void>((resolve) => setTimeout(resolve, ms))
}

async function fail(ms: number) {
  await wait(ms)
  throw new Error("Request failed")
}

export function ButtonDemo() {
  return (
    <div className="flex flex-wrap items-center justify-center gap-2">
      <Button feedback onClick={() => wait(900)}>
        Save changes
      </Button>
      <Button feedback variant="outline" onClick={() => fail(900)}>
        Request that fails
      </Button>
      <Button feedback variant="secondary" onClick={() => wait(80)}>
        Fast request
      </Button>
    </div>
  )
}
pnpm dlx shadcn@latest add https://hextaui.com/r/button.json

Adds the component, the HextaUI theme tokens and any HextaUI components it depends on.

import { Button } from "@/components/ui/button"
<Button feedback onClick={() => saveSettings()}>
  Save changes
</Button>

With feedback, return a promise from onClick and the button shows loading, then success or error, then resets on its own.

Variants

Six variants. destructive is a soft tint so a dangerous action reads clearly without shouting.

import { Button } from "@/components/ui/button"

export function ButtonVariants() {
  return (
    <div className="flex flex-wrap items-center justify-center gap-2">
      <Button>Default</Button>
      <Button variant="outline">Outline</Button>
      <Button variant="secondary">Secondary</Button>
      <Button variant="ghost">Ghost</Button>
      <Button variant="destructive">Destructive</Button>
      <Button variant="link">Link</Button>
    </div>
  )
}

Sizes

Text sizes xs to lg, and square icon-* sizes. Small icon buttons get a larger invisible touch area on touch screens.

import { IconPlus } from "@tabler/icons-react"

import { Button } from "@/components/ui/button"

export function ButtonSizes() {
  return (
    <div className="flex flex-col items-center gap-4">
      <div className="flex flex-wrap items-center justify-center gap-2">
        <Button size="xs" variant="outline">
          Extra small
        </Button>
        <Button size="sm" variant="outline">
          Small
        </Button>
        <Button variant="outline">Default</Button>
        <Button size="lg" variant="outline">
          Large
        </Button>
      </div>
      <div className="flex flex-wrap items-center justify-center gap-2">
        <Button size="icon-xs" variant="outline" aria-label="Add">
          <IconPlus />
        </Button>
        <Button size="icon-sm" variant="outline" aria-label="Add">
          <IconPlus />
        </Button>
        <Button size="icon" variant="outline" aria-label="Add">
          <IconPlus />
        </Button>
        <Button size="icon-lg" variant="outline" aria-label="Add">
          <IconPlus />
        </Button>
      </div>
    </div>
  )
}

With icon

Mark an icon with data-icon="inline-start" or "inline-end" and the padding on that side tightens to balance it.

import { IconArrowRight, IconGitBranch } from "@tabler/icons-react"

import { Button } from "@/components/ui/button"

export function ButtonWithIcon() {
  return (
    <div className="flex flex-wrap items-center justify-center gap-2">
      <Button variant="outline">
        <IconGitBranch data-icon="inline-start" />
        New branch
      </Button>
      <Button>
        Continue
        <IconArrowRight data-icon="inline-end" />
      </Button>
    </div>
  )
}

Disabled

focusableWhenDisabled keeps a disabled button in the tab order, so a tooltip or explanation can still be reached by keyboard.

import { Button } from "@/components/ui/button"

export function ButtonDisabled() {
  return (
    <div className="flex flex-wrap items-center justify-center gap-2">
      <Button disabled>Disabled</Button>
      <Button variant="outline" disabled focusableWhenDisabled>
        Focusable when disabled
      </Button>
    </div>
  )
}

Custom labels

loadingLabel, successLabel and errorLabel replace the text for each state. Each label flips in while the old one flips out.

"use client"

import { IconDeviceFloppy } from "@tabler/icons-react"

import { Button } from "@/components/ui/button"

function wait(ms: number) {
  return new Promise<void>((resolve) => setTimeout(resolve, ms))
}

async function fail(ms: number) {
  await wait(ms)
  throw new Error("Request failed")
}

export function ButtonCustomLabels() {
  return (
    <div className="flex flex-wrap items-center justify-center gap-2">
      <Button
        feedback
        loadingLabel="Saving…"
        successLabel="Saved"
        errorLabel="Couldn’t save"
        onClick={() => wait(1200)}
      >
        <IconDeviceFloppy data-icon="inline-start" />
        Save
      </Button>
      <Button
        feedback
        variant="outline"
        loadingLabel="Saving…"
        successLabel="Saved"
        errorLabel="Couldn’t save"
        onClick={() => fail(1200)}
      >
        <IconDeviceFloppy data-icon="inline-start" />
        Save
      </Button>
    </div>
  )
}

Smooth width

The button eases to the width of each label instead of reserving space for the longest one, so nothing around it jumps.

"use client"

import { Button } from "@/components/ui/button"

function wait(ms: number) {
  return new Promise<void>((resolve) => setTimeout(resolve, ms))
}

export function ButtonSmoothWidth() {
  return (
    <Button
      feedback
      loadingLabel="Publishing to 3 regions…"
      successLabel="Live"
      onClick={() => wait(1600)}
    >
      Publish
    </Button>
  )
}

Error details

Pass a function to errorLabel to show the rejection reason. While the pointer or keyboard focus stays on the button, the error stays on screen.

"use client"

import { Button } from "@/components/ui/button"

export function ButtonErrorDetails() {
  return (
    <Button
      feedback
      variant="outline"
      errorLabel={(error) =>
        error instanceof Error ? error.message : "Failed"
      }
      onClick={async () => {
        await new Promise((resolve) => setTimeout(resolve, 700))
        throw new Error("Card declined")
      }}
    >
      Pay $24
    </Button>
  )
}

Forms

For submit buttons, call track() from useButtonFeedback in onSubmit and spread buttonProps on the button. Remove the @ to see the error.

"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import { useButtonFeedback } from "@/hooks/use-button-feedback"

function wait(ms: number) {
  return new Promise<void>((resolve) => setTimeout(resolve, ms))
}

async function fail(ms: number) {
  await wait(ms)
  throw new Error("Invalid email")
}

export function ButtonForm() {
  const save = useButtonFeedback()
  const [email, setEmail] = React.useState("[email protected]")

  return (
    <form
      className="flex w-full max-w-sm items-center gap-2"
      onSubmit={(event) => {
        event.preventDefault()
        save.track(email.includes("@") ? wait(900) : fail(600))
      }}
    >
      <input
        aria-label="Email"
        value={email}
        onChange={(event) => setEmail(event.target.value)}
        className="h-9 min-w-0 flex-1 rounded-md border border-input bg-transparent px-3 text-sm transition-shadow outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:outline-hidden pointer-coarse:text-touch"
      />
      <Button
        type="submit"
        {...save.buttonProps}
        successLabel="Subscribed"
        errorLabel="Invalid email"
      >
        Subscribe
      </Button>
    </form>
  )
}

Icon buttons

Icon sizes swap only the icon for each state and keep their square shape. The aria-label stays the accessible name.

"use client"

import { IconDeviceFloppy } from "@tabler/icons-react"

import { Button } from "@/components/ui/button"

function wait(ms: number) {
  return new Promise<void>((resolve) => setTimeout(resolve, ms))
}

async function fail(ms: number) {
  await wait(ms)
  throw new Error("Request failed")
}

export function ButtonIconFeedback() {
  return (
    <div className="flex flex-wrap items-center justify-center gap-2">
      <Button
        feedback
        size="icon"
        variant="outline"
        aria-label="Save"
        onClick={() => wait(900)}
      >
        <IconDeviceFloppy />
      </Button>
      <Button
        feedback
        size="icon"
        variant="outline"
        aria-label="Save"
        onClick={() => fail(900)}
      >
        <IconDeviceFloppy />
      </Button>
    </div>
  )
}

Feedback on every variant

Filled variants turn green or red when done. ghost and link only change their text color.

"use client"

import { Button } from "@/components/ui/button"

function wait(ms: number) {
  return new Promise<void>((resolve) => setTimeout(resolve, ms))
}

const variants = [
  "default",
  "outline",
  "secondary",
  "ghost",
  "destructive",
  "link",
] as const

export function ButtonVariantsFeedback() {
  return (
    <div className="flex flex-wrap items-center justify-center gap-2">
      {variants.map((variant) => (
        <Button
          key={variant}
          feedback
          variant={variant}
          onClick={() => wait(900)}
        >
          {variant}
        </Button>
      ))}
    </div>
  )
}

Controlled loading

Set loading yourself when the work is tracked elsewhere. The button stays focusable and announces that it is busy.

"use client"

import * as React from "react"
import { IconSend } from "@tabler/icons-react"

import { Button } from "@/components/ui/button"

export function ButtonControlledLoading() {
  const [loading, setLoading] = React.useState(false)

  return (
    <div className="flex flex-wrap items-center justify-center gap-2">
      <Button loading={loading} onClick={() => setLoading(true)}>
        <IconSend data-icon="inline-start" />
        Send invite
      </Button>
      <Button variant="ghost" onClick={() => setLoading(false)}>
        Stop loading
      </Button>
    </div>
  )
}

Controlled status

Drive status directly, for example from a form library’s submit state.

"use client"

import * as React from "react"
import { IconRocket } from "@tabler/icons-react"

import { Button, type ButtonStatus } from "@/components/ui/button"

const statuses: ButtonStatus[] = ["idle", "loading", "success", "error"]

export function ButtonControlledStatus() {
  const [status, setStatus] = React.useState<ButtonStatus>("idle")

  return (
    <div className="flex flex-col items-center gap-3">
      <Button
        status={status}
        onStatusChange={setStatus}
        successLabel="Deployed"
        errorLabel="Deploy failed"
      >
        <IconRocket data-icon="inline-start" />
        Deploy
      </Button>
      <div className="flex flex-wrap justify-center gap-2">
        {statuses.map((next) => (
          <Button
            key={next}
            size="xs"
            variant={status === next ? "secondary" : "ghost"}
            onClick={() => setStatus(next)}
          >
            {next}
          </Button>
        ))}
      </div>
    </div>
  )
}

Pass an anchor to render and set nativeButton={false} so the button keeps link semantics.

import { IconArrowUpRight } from "@tabler/icons-react"

import { Button } from "@/components/ui/button"

export function ButtonLink() {
  return (
    <Button variant="outline" nativeButton={false} render={<a href="#" />}>
      Read the docs
      <IconArrowUpRight data-icon="inline-end" />
    </Button>
  )
}

Right to left

Icons and state labels follow the reading direction.

"use client"

import { IconDeviceFloppy } from "@tabler/icons-react"

import { Button } from "@/components/ui/button"

function wait(ms: number) {
  return new Promise<void>((resolve) => setTimeout(resolve, ms))
}

export function ButtonRtl() {
  return (
    <div dir="rtl" className="flex flex-wrap items-center justify-center gap-2">
      <Button
        feedback
        successLabel="تم الحفظ"
        errorLabel="فشل الحفظ"
        onClick={() => wait(900)}
      >
        <IconDeviceFloppy data-icon="inline-start" />
        حفظ
      </Button>
    </div>
  )
}
KeyAction
EnterSpaceActivates the button. Ignored while a feedback request is in flight.
TabMoves focus. A loading button stays focusable, and focusing an error keeps it on screen until you move away.
  • Every state change is announced through a polite live region: loading, then the success or error label.
  • While loading, the button sets aria-busy and stays focusable, so focus is never lost mid-request.
  • The spinner appears only after 150ms and then stays for at least 400ms, so fast requests never flash and slow ones never flicker.
  • With reduced motion, state labels fade instead of flipping and the error shake is skipped.

Built on the Base UI button. It renders a <button> and accepts all of its attributes.

PropTypeDefault
variant
"default" | "outline" | "secondary" | "ghost" | "destructive" | "link""default"
size
"xs" | "sm" | "default" | "lg" | "icon-xs" | "icon-sm" | "icon" | "icon-lg""default"
feedbackTrack the promise returned from onClick and show its status.
booleanfalse
onClickReturn a promise to drive feedback.
(event) => unknown–
loadingControlled loading state.
boolean–
statusControlled status. Takes priority over loading.
"idle" | "loading" | "success" | "error"–
onStatusChange
(status: ButtonStatus) => void–
onErrorCalled with the rejection reason.
(error: unknown) => void–
resetAfterMilliseconds before returning to idle.
number | { success?: number; error?: number }{ success: 2000, error: 4000 }
loadingLabelShown next to the spinner. Hidden on icon sizes.
ReactNode–
successLabel
ReactNode"Done"
errorLabel
ReactNode | (error: unknown) => ReactNode"Failed"
disabled
booleanfalse
focusableWhenDisabledAlways true while loading.
booleanfalse
nativeButtonSet to false when render is not a <button>.
booleantrue
render
ReactElement | (props, state) => ReactElement<button>
AttributeDescription
data-slot="button"Target buttons in CSS.
data-statusidle, loading, success or error. Present once feedback, loading or status is used.
data-disabledPresent when the button is disabled.
aria-busyPresent while loading.

Runs the same feedback flow from anywhere, such as a form’s onSubmit. Accepts resetAfter, onStatusChange and onError. See the useButtonFeedback guide for the full timing.

ReturnsDescription
track(action)Pass a promise or a function returning one. Calls while a request is in flight are ignored.
buttonPropsSpread on <Button> to show the status and pause the reset on hover and focus.
statusThe current ButtonStatus.
errorThe last rejection reason.
reset()Cancels the request and returns to idle.
isPending()Whether a request is in flight.