HextaUI

Button

Botones en todas las variantes y tamaños, con un flujo integrado de carga, éxito y error que omite el spinner en las peticiones rápidas.

"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

Añade el componente, los tokens del tema de HextaUI y los componentes de HextaUI de los que depende.

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

Con feedback, devuelve una promesa desde onClick y el botón muestra la carga, luego el éxito o el error, y después se reinicia por sí solo.

Variantes

Siete variantes. destructive es un tinte suave para que una acción peligrosa se lea con claridad sin gritar, y ghost-destructive es la versión discreta para acciones de fila repetidas como Sign out o Remove.

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="ghost-destructive">Ghost destructive</Button>
      <Button variant="link">Link</Button>
    </div>
  )
}

Tamaños

Tamaños de texto de xs a lg, y tamaños cuadrados icon-*. Los botones de icono pequeños obtienen un área táctil invisible mayor en pantallas táctiles.

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>
        <Button size="icon-xl" variant="outline" aria-label="Add">
          <IconPlus />
        </Button>
      </div>
    </div>
  )
}

Píldora

shape="pill" redondea los extremos por completo, y los tamaños de icono se vuelven círculos. Va bien en botones que están dentro de superficies redondeadas, como un compositor de chat.

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

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

export function ButtonPill() {
  return (
    <div className="flex flex-wrap items-center gap-2">
      <Button shape="pill">Get started</Button>
      <Button shape="pill" variant="outline">
        <IconPlus data-icon="inline-start" />
        New chat
      </Button>
      <Button shape="pill" size="icon" aria-label="Send">
        <IconArrowUp />
      </Button>
      <Button shape="pill" size="icon-sm" variant="ghost" aria-label="Add">
        <IconPlus />
      </Button>
    </div>
  )
}

Con icono

Marca un icono con data-icon="inline-start" o "inline-end" y el relleno de ese lado se reduce para equilibrarlo.

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>
  )
}

Deshabilitado

focusableWhenDisabled mantiene un botón deshabilitado en el orden de tabulación, de modo que un tooltip o una explicación sigan siendo accesibles con el teclado.

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>
  )
}

Etiquetas personalizadas

loadingLabel, successLabel y errorLabel reemplazan el texto de cada estado. Cada etiqueta entra con un giro mientras la anterior sale con otro.

"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>
  )
}

Ancho suave

El botón se ajusta suavemente al ancho de cada etiqueta en lugar de reservar espacio para la más larga, así que nada a su alrededor salta.

"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>
  )
}

Detalles del error

Pasa una función a errorLabel para mostrar el motivo del rechazo. Mientras el puntero o el foco del teclado permanezcan en el botón, el error se mantiene en pantalla.

"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>
  )
}

Formularios

Para los botones de envío, llama a track() de useButtonFeedback en onSubmit y esparce buttonProps en el botón. Quita la @ para ver el 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>
  )
}

Botones de icono

Los tamaños de icono solo cambian el icono en cada estado y conservan su forma cuadrada. El aria-label sigue siendo el nombre accesible.

"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 en cada variante

Las variantes rellenas pasan a verde o rojo al terminar. ghost y link solo cambian el color del texto.

"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>
  )
}

Carga controlada

Define loading tú mismo cuando el trabajo se rastree en otro lugar. El botón sigue pudiendo recibir foco y anuncia que está ocupado.

"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>
  )
}

Estado controlado

Controla status directamente, por ejemplo desde el estado de envío de una librería de formularios.

"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>
  )
}

Pasa un ancla a render y define nativeButton={false} para que el botón conserve la semántica de enlace.

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>
  )
}

De derecha a izquierda

Los iconos y las etiquetas de estado siguen la dirección de lectura.

"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>
  )
}
KeyAcción
EnterSpaceActiva el botón. Se ignora mientras hay una petición de feedback en curso.
TabMueve el foco. Un botón en carga sigue pudiendo recibir foco, y enfocar un error lo mantiene en pantalla hasta que te muevas.
  • Cada cambio de estado se anuncia mediante una región activa polite: primero la carga y luego la etiqueta de éxito o de error.
  • Durante la carga, el botón define aria-busy y sigue pudiendo recibir foco, así que el foco nunca se pierde a mitad de la petición.
  • El spinner aparece solo después de 150 ms y luego permanece al menos 400 ms, así que las peticiones rápidas nunca lo muestran por un instante y las lentas nunca parpadean.
  • Con movimiento reducido, las etiquetas de estado se desvanecen en lugar de voltearse y se omite la sacudida del error.

Construido sobre el botón de Base UI. Renderiza un <button> y acepta todos sus atributos.

PropTipoPredeterminado
variant
"default" | "outline" | "secondary" | "ghost" | "ghost-destructive" | "destructive" | "link""default"
size
"xs" | "sm" | "default" | "lg" | "icon-xs" | "icon-sm" | "icon" | "icon-lg" | "icon-xl""default"
shape
"default" | "pill""default"
feedbackRastrea la promesa devuelta por onClick y muestra su estado.
booleanfalse
onClickDevuelve una promesa para controlar el feedback.
(event) => unknown–
loadingEstado de carga controlado.
boolean–
statusEstado controlado. Tiene prioridad sobre loading.
"idle" | "loading" | "success" | "error"–
onStatusChange
(status: ButtonStatus) => void–
onErrorSe llama con el motivo del rechazo.
(error: unknown) => void–
resetAfterMilisegundos antes de volver al estado de reposo.
number | { success?: number; error?: number }{ success: 2000, error: 4000 }
loadingLabelSe muestra junto al spinner. Oculto en los tamaños de icono.
ReactNode–
successLabel
ReactNode"Done"
errorLabel
ReactNode | (error: unknown) => ReactNode"Failed"
disabled
booleanfalse
focusableWhenDisabledSiempre true durante la carga.
booleanfalse
nativeButtonPonlo en false cuando render no sea un <button>.
booleantrue
render
ReactElement | (props, state) => ReactElement<button>
AtributoDescripción
data-slot="button"Selecciona los botones en CSS.
data-statusidle, loading, success o error. Presente una vez que se usa feedback, loading o status.
data-disabledPresente cuando el botón está deshabilitado.
aria-busyPresente durante la carga.

Ejecuta el mismo flujo de feedback desde cualquier lugar, como el onSubmit de un formulario. Acepta resetAfter, onStatusChange y onError. Consulta la guía de useButtonFeedback para ver los tiempos completos.

DevuelveDescripción
track(action)Pasa una promesa o una función que devuelva una. Las llamadas mientras hay una petición en curso se ignoran.
buttonPropsEspárcelo en <Button> para mostrar el estado y pausar el reinicio al pasar el cursor y al recibir foco.
statusEl ButtonStatus actual.
errorEl último motivo de rechazo.
reset()Cancela la petición y vuelve al estado de reposo.
isPending()Si hay una petición en curso.

Usado en bloques

Bloques que se construyen sobre Button.