Button
Componente Button de shadcn — variantes, tamaños, estados de carga y botones con icono.
Receta
Tarjeta de referencia rápida — lista para copiar y pegar.
npx shadcn@latest add buttonimport { Button } from "@/components/ui/button";
// Variantes
<Button variant="default">Primario</Button>
<Button variant="secondary">Secundario</Button>
<Button variant="destructive">Eliminar</Button>
<Button variant="outline">Contorno</Button>
<Button variant="ghost">Discreto</Button>
<Button variant="link">Enlace</Button>
// Tamaños
<Button size="sm">Pequeño</Button>
<Button size="default">Predeterminado</Button>
<Button size="lg">Grande</Button>
<Button size="icon">Icono</Button>
// Como enlace
<Button asChild>
<a href="/about">Acerca de</a>
</Button>
// Deshabilitado
<Button disabled>Deshabilitado</Button>Cuándo usarlo: Para cada acción clicable en tu app — el componente Button ofrece estilos consistentes, accesibilidad y soporte de variantes.
Ejemplo funcional
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Loader2, Plus, Trash2, Download, ExternalLink } from "lucide-react";
export function ButtonShowcase() {
const [loading, setLoading] = useState(false);
async function handleClick() {
setLoading(true);
await new Promise((r) => setTimeout(r, 2000));
setLoading(false);
}
return (
<div className="space-y-6">
{/* Botón con carga */}
<div className="flex gap-3">
<Button onClick={handleClick} disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{loading ? "Guardando..." : "Guardar cambios"}
</Button>
</div>
{/* Botones con icono */}
<div className="flex gap-2">
<Button size="icon" variant="outline" aria-label="Añadir elemento">
<Plus className="h-4 w-4" />
</Button>
<Button size="icon" variant="destructive" aria-label="Eliminar elemento">
<Trash2 className="h-4 w-4" />
</Button>
</div>
{/* Botón con icono y texto */}
<div className="flex gap-3">
<Button>
<Download className="mr-2 h-4 w-4" />
Descargar
</Button>
<Button variant="outline" asChild>
<a href="https://example.com" target="_blank" rel="noopener noreferrer">
Visitar sitio
<ExternalLink className="ml-2 h-4 w-4" />
</a>
</Button>
</div>
{/* Grupo de botones */}
<div className="inline-flex rounded-md shadow-sm">
<Button variant="outline" className="rounded-r-none border-r-0">Izquierda</Button>
<Button variant="outline" className="rounded-none border-r-0">Centro</Button>
<Button variant="outline" className="rounded-l-none">Derecha</Button>
</div>
{/* Ancho completo */}
<Button className="w-full" size="lg">
Botón de ancho completo
</Button>
</div>
);
}Lo que demuestra:
- Estado de carga con icono spinner y estado deshabilitado
- Botones solo con icono con
aria-labelpara accesibilidad - Botones con iconos al inicio y al final
asChildpara renderizar como etiqueta anchor- Grupo de botones con manipulación de bordes
- Botón de ancho completo
Profundización
Cómo funciona
- Button usa
class-variance-authority(cva) para definir combinaciones de variant y size asChildusa el componenteSlotde Radix para fusionar props en el elemento hijo- Cada variant se mapea a un conjunto de clases de Tailwind que incluyen estados hover, focus y disabled
- La utilidad
cn()permite sobrescribir cualquier clase predeterminada mediante la propclassName - Button renderiza un
<button>nativo por defecto, heredando todos los atributos HTML de botón
Variaciones
Variante personalizada mediante cva:
// components/ui/button.tsx — añade una variante personalizada
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
// ... otras variantes
success: "bg-green-600 text-white hover:bg-green-700",
warning: "bg-amber-500 text-white hover:bg-amber-600",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
xs: "h-7 rounded px-2 text-xs",
},
},
}
);Patrón de confirmar para eliminar:
function ConfirmDeleteButton({ onConfirm }: { onConfirm: () => void }) {
const [confirming, setConfirming] = useState(false);
if (confirming) {
return (
<div className="flex gap-2">
<Button variant="destructive" size="sm" onClick={onConfirm}>
Confirmar
</Button>
<Button variant="ghost" size="sm" onClick={() => setConfirming(false)}>
Cancelar
</Button>
</div>
);
}
return (
<Button variant="outline" size="sm" onClick={() => setConfirming(true)}>
<Trash2 className="mr-2 h-3 w-3" /> Eliminar
</Button>
);
}Botón de envío con useFormStatus:
"use client";
import { useFormStatus } from "react-dom";
function SubmitButton() {
const { pending } = useFormStatus();
return (
<Button type="submit" disabled={pending}>
{pending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{pending ? "Enviando..." : "Enviar"}
</Button>
);
}Notas de TypeScript
// Tipo ButtonProps
import { Button, type ButtonProps } from "@/components/ui/button";
// Extracción del tipo variant
import { type VariantProps } from "class-variance-authority";
type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
// "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"
// Polimórfico con asChild
<Button asChild>
<Link href="/about">Acerca de</Link> {/* Next.js Link */}
</Button>Errores comunes
-
asChildelimina el elemento button — El elemento hijo recibe todas las props del botón pero no está envuelto en un<button>. Solución: Asegúrate de que el hijo pueda aceptaronClick,classNamey otras props de botón. -
Los botones con icono necesitan
aria-label— Un botón solo con icono no tiene texto visible. Solución: Añade siemprearia-labelpara lectores de pantalla. -
disabledimpide todos los eventos — A diferencia dearia-disabled, el atributo nativodisabledelimina el botón del orden de tabulación. Solución: Usaaria-disabledsi necesitas que el botón siga siendo enfocable mientras está inactivo. -
Un botón dentro de un formulario envía por defecto — Los botones sin
typeusantype="submit"por defecto. Solución: Usatype="button"para botones que no envían dentro de formularios. -
Sobrescribir variantes —
className="bg-red-500"en un botón convariant="default"puede no ganar por especificidad. Solución: La utilidadcn()lo gestiona mediantetailwind-merge.
Alternativas
| Alternativa | Úsala cuando | No la uses cuando |
|---|---|---|
<button> nativo | Necesitas un botón puntual sin el sistema de variantes | Quieres estilos consistentes en toda la app |
| Radix Toggle | Necesitas un botón de alternancia con estado pressed | Un botón normal es suficiente |
<a> con estilo de botón | La acción navega a una URL | La acción dispara un evento del lado del cliente |
| Bibliotecas de botones con icono | Necesitas comportamiento especializado de botones con icono | shadcn Button con size="icon" lo cubre |
FAQs
¿Cuáles son las variantes de Button disponibles y cuándo debes usar cada una?
default— acciones principales (enviar, guardar)secondary— acciones menos prominentesdestructive— eliminar o acciones peligrosasoutline— acciones con borde y bajo énfasisghost— estilo mínimo, a menudo para barras de herramientaslink— con estilo de hipervínculo
¿Cómo renderizas un Button como enlace o como componente Link de Next.js?
Usa la prop asChild para delegar el renderizado al elemento hijo:
<Button asChild>
<a href="/about">Acerca de</a>
</Button>¿Cómo implementas un estado de carga en un Button?
<Button onClick={handleClick} disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{loading ? "Guardando..." : "Guardar cambios"}
</Button>¿Qué hace realmente la prop asChild por debajo?
- Usa el componente
Slotde Radix para fusionar las props del Button en su único elemento hijo - Se elimina el elemento
<button>; el hijo recibeonClick,className, etc. - Esto te permite renderizar como
<a>,<Link>o cualquier elemento manteniendo los estilos de Button
¿Cómo creas una variante personalizada de Button como "success" o "warning"?
Añade la variante a la definición cva de buttonVariants en components/ui/button.tsx:
success: "bg-green-600 text-white hover:bg-green-700",
warning: "bg-amber-500 text-white hover:bg-amber-600",¿Qué biblioteca usa internamente el componente Button para gestionar variantes?
class-variance-authority(cva) define combinaciones de variant y size- La utilidad
cn()(impulsada portailwind-merge) fusiona y deduplica clases de Tailwind
Trampa: ¿Por qué un Button dentro de un formulario podría enviarse inesperadamente?
- Los botones sin un atributo
typeexplícito usantype="submit"por defecto - Solución: usa
type="button"para botones que no envían dentro de formularios
¿Cómo construyes un grupo de botones con bordes conectados?
<div className="inline-flex rounded-md shadow-sm">
<Button variant="outline" className="rounded-r-none border-r-0">Izquierda</Button>
<Button variant="outline" className="rounded-none border-r-0">Centro</Button>
<Button variant="outline" className="rounded-l-none">Derecha</Button>
</div>¿Por qué los botones solo con icono deben tener siempre un aria-label?
- Los botones solo con icono no tienen texto visible para lectores de pantalla
- Sin
aria-label, la tecnología asistiva anuncia el botón sin un nombre significativo - Añade siempre
aria-label="Descripción"a los botones consize="icon"
¿Cómo extraes el variant de Button como una unión de TypeScript?
import { type VariantProps } from "class-variance-authority";
type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
// "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"¿Cómo se integra useFormStatus con Button para formularios con server actions?
function SubmitButton() {
const { pending } = useFormStatus();
return (
<Button type="submit" disabled={pending}>
{pending ? "Enviando..." : "Enviar"}
</Button>
);
}Trampa: ¿Por qué className="bg-red-500" podría no sobrescribir el color de fondo de una variante?
- La especificidad de clases de Tailwind puede causar conflictos cuando tanto la variante como
classNameestablecen la misma propiedad - La utilidad
cn()usatailwind-mergepara resolverlo, así que debería funcionar — pero solo si pasas porcn() - Si omites
cn(), gana la última clase en la hoja de estilos, que puede no ser tu sobrescritura