← Componentes

Button

Cuatro tamaños, tres variantes, y los estados que un botón real necesita — disabled, loading, ícono solo.

Playground

Variant
Size
State
<Button variant="primary">
  Acción
</Button>

Todas las combinaciones

xssmmdlg
primary
outline
ghost
"use client";

import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "./lib/cn";
import { ButtonGroupContext } from "./lib/button-group-context";

// exportado para estilizar un elemento que no puede ser <button> — un
// <Link> que se ve como botón, por ejemplo — sin duplicar las clases a mano
export const buttonVariants = cva(
  "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 [&>svg]:shrink-0",
  {
    variants: {
      variant: {
        primary: "bg-primary text-primary-foreground hover:opacity-90",
        outline: "border border-border bg-background hover:bg-muted",
        ghost: "hover:bg-muted",
        success: "bg-success text-success-foreground hover:opacity-90",
        danger: "bg-danger text-danger-foreground hover:opacity-90",
        warning: "bg-warning text-warning-foreground hover:opacity-90",
        info: "bg-info text-info-foreground hover:opacity-90",
      },
      // el ícono escala con el tamaño del botón — un solo lugar decide
      // cuánto mide, no una prop de tamaño de ícono aparte
      size: {
        xs: "h-7 px-2 text-xs [&>svg]:h-3.5 [&>svg]:w-3.5",
        sm: "h-8 px-3 [&>svg]:h-4 [&>svg]:w-4",
        md: "h-10 px-4 [&>svg]:h-4 [&>svg]:w-4",
        lg: "h-12 px-6 text-base [&>svg]:h-5 [&>svg]:w-5",
      },
      // composes with size instead of duplicating its height as a fixed
      // "icon" size — aspect-square copies whatever height xs/sm/md/lg
      // already set, so there's one source of truth for each scale step
      iconOnly: {
        true: "aspect-square px-0",
        false: "",
      },
    },
    defaultVariants: {
      variant: "primary",
      size: "md",
      iconOnly: false,
    },
  },
);

function Spinner({ className }: { className?: string }) {
  return (
    <svg className={cn("h-4 w-4 animate-spin", className)} viewBox="0 0 24 24" fill="none" aria-hidden="true">
      <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
      <path className="opacity-90" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
    </svg>
  );
}

export type ButtonVariant = NonNullable<VariantProps<typeof buttonVariants>["variant"]>;

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  /** shows a spinner and disables the button — for an in-flight action */
  loading?: boolean;
}

export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, iconOnly, loading, disabled, children, ...props }, ref) => {
    // dentro de un ButtonGroup, el size del grupo manda salvo que este
    // botón puntual traiga el suyo propio — mismo criterio que AvatarGroup
    const groupCtx = React.useContext(ButtonGroupContext);
    const effectiveSize = size ?? groupCtx?.size;
    return (
      <button
        ref={ref}
        className={cn(buttonVariants({ variant, size: effectiveSize, iconOnly }), className)}
        disabled={disabled || loading}
        aria-busy={loading || undefined}
        {...props}
      >
        {loading && <Spinner />}
        {children}
      </button>
    );
  },
);
Button.displayName = "Button";

Código real de packages/ui/src/button.tsx.

Con ícono

No hay prop icon — el ícono es un hijo más, antes o después del texto. El tamaño del ícono ya escala solo con size ([&>svg] en la clase base), así que no hay que ajustarlo a mano en cada botón.

<Button variant="danger">
  <TrashIcon />
  Eliminar
</Button>

<Button variant="outline">
  Agregar
  <PlusIcon />
</Button>

Solo ícono

iconOnly se combina con cualquier size — no es un tamaño aparte, así que escala en los mismos xs / sm / md / lg en vez de quedar fijo en uno solo. Sin texto visible, así que aria-label deja de ser opcional.

<Button size="sm" iconOnly aria-label="Agregar">
  <PlusIcon />
</Button>

Estados

success / danger / warning / info son tokens compartidos, no algo que Button inventó — aquí Button los pinta como relleno sólido. Ver Colores para la paleta completa y cómo los lee cada componente.

<Button variant="danger">Eliminar</Button>
<Button variant="success">Confirmar</Button>
<Button variant="warning">Revisar</Button>
<Button variant="info">Ver detalles</Button>

Uso

import { Button } from "./components/ui/button";

<Button variant="primary" size="md">
  Acción
</Button>

API

PropTipoDefault
variantprimary | outline | ghost | success | danger | warning | infoprimary
sizexs | sm | md | lgmd
iconOnlybooleanfalse
loadingbooleanfalse
disabledbooleanfalse

El resto son los atributos nativos de <button> — se pasan directo.