HextaUI

Button

Des boutons dans toutes les variantes et tailles, avec un flux de chargement, de succès et d’erreur intégré qui évite le spinner pour les requêtes rapides.

"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

Ajoute le composant, les tokens de thème HextaUI et les composants HextaUI dont il dépend.

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

Avec feedback, retournez une promesse depuis onClick : le bouton affiche le chargement, puis le succès ou l’erreur, puis se réinitialise tout seul.

Variantes

Sept variantes. destructive est une teinte douce : une action dangereuse se lit clairement sans crier, et ghost-destructive est la version discrète pour les actions répétées sur des lignes comme Sign out ou 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>
  )
}

Tailles

Tailles de texte de xs à lg, et tailles carrées icon-*. Les petits boutons-icônes reçoivent une zone tactile invisible plus grande sur écran tactile.

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

Pill

shape="pill" arrondit entièrement les extrémités, et les tailles d’icône deviennent des cercles. Idéal pour les boutons placés dans des surfaces arrondies, comme un champ de saisie de conversation.

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

Avec icône

Marquez une icône avec data-icon="inline-start" ou "inline-end" et le remplissage de ce côté se resserre pour l’équilibrer.

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

Désactivé

focusableWhenDisabled garde un bouton désactivé dans l’ordre de tabulation, pour qu’un tooltip ou une explication reste accessible au clavier.

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

Libellés personnalisés

loadingLabel, successLabel et errorLabel remplacent le texte de chaque état. Chaque libellé bascule vers l’intérieur pendant que l’ancien bascule vers l’extérieur.

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

Largeur fluide

Le bouton s’adapte progressivement à la largeur de chaque libellé au lieu de réserver la place du plus long : rien autour de lui ne saute.

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

Détails de l’erreur

Passez une fonction à errorLabel pour afficher la raison du rejet. Tant que le pointeur ou le focus clavier reste sur le bouton, l’erreur reste affichée.

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

Formulaires

Pour les boutons de soumission, appelez track() de useButtonFeedback dans onSubmit et propagez buttonProps sur le bouton. Retirez le @ pour voir l’erreur.

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

Boutons-icônes

Les tailles d’icône ne remplacent que l’icône pour chaque état et gardent leur forme carrée. L’aria-label reste le nom accessible.

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

Retour d’état sur chaque variante

Les variantes pleines passent au vert ou au rouge une fois terminées. ghost et link ne changent que la couleur de leur texte.

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

Chargement contrôlé

Définissez vous-même loading lorsque le travail est suivi ailleurs. Le bouton reste focusable et annonce qu’il est occupé.

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

Statut contrôlé

Pilotez status directement, par exemple à partir de l’état de soumission d’une bibliothèque de formulaires.

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

Passez une ancre à render et définissez nativeButton={false} pour que le bouton conserve la sémantique d’un lien.

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 droite à gauche

Les icônes et les libellés d’état suivent le sens de lecture.

"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>
  )
}
ToucheAction
EnterSpaceActive le bouton. Ignoré pendant qu’une requête de retour d’état est en cours.
TabDéplace le focus. Un bouton en chargement reste focusable, et donner le focus à une erreur la garde à l’écran jusqu’à ce que vous le déplaciez.
  • Chaque changement d’état est annoncé via une région live polie : le chargement, puis le libellé de succès ou d’erreur.
  • Pendant le chargement, le bouton définit aria-busy et reste focusable : le focus n’est donc jamais perdu en pleine requête.
  • Le spinner n’apparaît qu’après 150 ms puis reste au moins 400 ms : les requêtes rapides ne provoquent aucun flash et les lentes aucun scintillement.
  • Avec la réduction des animations, les libellés d’état se fondent au lieu de basculer et la secousse d’erreur est ignorée.

Construit sur le bouton de Base UI. Il rend un <button> et accepte tous ses attributs.

PropTypePar défaut
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"
feedbackSuit la promesse retournée par onClick et affiche son statut.
booleanfalse
onClickRetournez une promesse pour piloter le retour d’état.
(event) => unknown–
loadingÉtat de chargement contrôlé.
boolean–
statusStatut contrôlé. Prioritaire sur loading.
"idle" | "loading" | "success" | "error"–
onStatusChange
(status: ButtonStatus) => void–
onErrorAppelé avec la raison du rejet.
(error: unknown) => void–
resetAfterMillisecondes avant de revenir à l’état inactif.
number | { success?: number; error?: number }{ success: 2000, error: 4000 }
loadingLabelAffiché à côté du spinner. Masqué sur les tailles d’icône.
ReactNode–
successLabel
ReactNode"Done"
errorLabel
ReactNode | (error: unknown) => ReactNode"Failed"
disabled
booleanfalse
focusableWhenDisabledToujours true pendant le chargement.
booleanfalse
nativeButtonÀ définir sur false lorsque render n’est pas un <button>.
booleantrue
render
ReactElement | (props, state) => ReactElement<button>
AttributDescription
data-slot="button"Ciblez les boutons en CSS.
data-statusidle, loading, success ou error. Présent dès que feedback, loading ou status est utilisé.
data-disabledPrésent lorsque le bouton est désactivé.
aria-busyPrésent pendant le chargement.

Exécute le même flux de retour d’état depuis n’importe où, comme le onSubmit d’un formulaire. Accepte resetAfter, onStatusChange et onError. Consultez le guide de useButtonFeedback pour le détail des durées.

Valeur de retourDescription
track(action)Passez une promesse ou une fonction qui en retourne une. Les appels effectués pendant qu’une requête est en cours sont ignorés.
buttonPropsÀ propager sur <Button> pour afficher le statut et suspendre la réinitialisation au survol et au focus.
statusLe ButtonStatus actuel.
errorLa dernière raison de rejet.
reset()Annule la requête et revient à l’état inactif.
isPending()Indique si une requête est en cours.

Utilisé dans les blocks

Des blocks qui s’appuient sur Button.