Botón
Un elemento interactivo que ejecuta acciones — el componente interactivo más fundamental en cualquier aplicación.
Casos de uso
- Enviar formularios (login, signup, checkout)
- Disparar navegación o cambios de ruta
- Abrir modales, dropdowns o drawers
- Confirmar acciones destructivas (delete, cancel)
- Alternar estados (like/unlike, follow/unfollow)
- Copiar texto al portapapeles
- Descargar archivos o exportar datos
Implementación más simple
"use client";
interface ButtonProps {
children: React.ReactNode;
onClick?: () => void;
}
export function Button({ children, onClick }: ButtonProps) {
return (
<button
onClick={onClick}
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 active:bg-blue-800"
>
{children}
</button>
);
}Un botón mínimo con estados hover y active. El onClick es opcional para que el botón también pueda usarse como type="submit" dentro de formularios sin necesidad de un manejador.
Variaciones
Estilos de variante
"use client";
type Variant = "primary" | "secondary" | "danger" | "ghost" | "outline";
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant;
children: React.ReactNode;
}
const variantClasses: Record<Variant, string> = {
primary: "bg-blue-600 text-white hover:bg-blue-700 active:bg-blue-800",
secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200 active:bg-gray-300",
danger: "bg-red-600 text-white hover:bg-red-700 active:bg-red-800",
ghost: "bg-transparent text-gray-700 hover:bg-gray-100 active:bg-gray-200",
outline: "border border-gray-300 bg-transparent text-gray-700 hover:bg-gray-50 active:bg-gray-100",
};
export function Button({ variant = "primary", children, className, ...rest }: ButtonProps) {
return (
<button
className={`rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50 disabled:pointer-events-none ${variantClasses[variant]} ${className ?? ""}`}
{...rest}
>
{children}
</button>
);
}Usa un Record para mapear nombres de variante a clases Tailwind. Extiende ButtonHTMLAttributes para que todos los atributos nativos (disabled, type, form, etc.) pasen a través automáticamente.
Variantes de tamaño
"use client";
type Size = "sm" | "md" | "lg";
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
size?: Size;
children: React.ReactNode;
}
const sizeClasses: Record<Size, string> = {
sm: "px-3 py-1.5 text-xs",
md: "px-4 py-2 text-sm",
lg: "px-6 py-3 text-base",
};
export function Button({ size = "md", children, ...rest }: ButtonProps) {
return (
<button
className={`rounded-lg bg-blue-600 font-medium text-white hover:bg-blue-700 ${sizeClasses[size]}`}
{...rest}
>
{children}
</button>
);
}Separar el tamaño de la variante mantiene la lógica de clases manejable. Combina con el patrón de variante para un sistema de botones completo.
Estado de carga
"use client";
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
loading?: boolean;
children: React.ReactNode;
}
export function Button({ loading, children, disabled, ...rest }: ButtonProps) {
return (
<button
disabled={loading || disabled}
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50 disabled:pointer-events-none"
{...rest}
>
{loading && (
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
)}
{children}
</button>
);
}El SVG del spinner está integrado para no necesitar una librería de iconos. El botón se deshabilita a sí mismo mientras está cargando para prevenir dobles clics.
Botón de icono
"use client";
interface IconButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
label: string;
children: React.ReactNode;
}
export function IconButton({ label, children, ...rest }: IconButtonProps) {
return (
<button
aria-label={label}
className="inline-flex h-10 w-10 items-center justify-center rounded-lg text-gray-600 hover:bg-gray-100 active:bg-gray-200"
{...rest}
>
{children}
</button>
);
}
// Uso
<IconButton label="Cerrar menú" onClick={onClose}>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</IconButton>Los botones solo de icono requieren aria-label para accesibilidad. El tamaño fijo h-10 w-10 mantiene el objetivo de clic consistente independientemente del tamaño del icono.
Botón como enlace (polimórfico)
"use client";
import Link from "next/link";
type ButtonAsLinkProps = {
href: string;
children: React.ReactNode;
className?: string;
};
type ButtonAsButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
href?: never;
children: React.ReactNode;
};
type ButtonProps = ButtonAsLinkProps | ButtonAsButtonProps;
export function Button(props: ButtonProps) {
const base = "inline-flex items-center justify-center rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700";
if ("href" in props && props.href) {
const { href, children, className } = props;
return (
<Link href={href} className={`${base} ${className ?? ""}`}>
{children}
</Link>
);
}
const { children, className, ...rest } = props as ButtonAsButtonProps;
return (
<button className={`${base} ${className ?? ""}`} {...rest}>
{children}
</button>
);
}Una unión discriminada en href permite que el mismo componente se renderice como un <button> o un <Link> de Next.js. TypeScript hace cumplir que href y onClick no se mezclen incorrectamente.
Botón de envío de formulario con useFormStatus
"use client";
import { useFormStatus } from "react-dom";
export function SubmitButton({ children }: { children: React.ReactNode }) {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{pending && (
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
)}
{children}
</button>
);
}useFormStatus debe ser llamado en un componente que es un hijo de un <form>. Lee el estado pending de la acción del formulario padre, por lo que no necesitas pasar props de carga manualmente.
Implementación compleja
"use client";
import { forwardRef } from "react";
import Link from "next/link";
type Variant = "primary" | "secondary" | "danger" | "ghost" | "outline";
type Size = "sm" | "md" | "lg" | "icon";
interface BaseProps {
variant?: Variant;
size?: Size;
loading?: boolean;
fullWidth?: boolean;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
children?: React.ReactNode;
}
type ButtonAsButton = BaseProps &
Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, keyof BaseProps> & {
href?: never;
};
type ButtonAsLink = BaseProps &
Omit<React.ComponentPropsWithoutRef<typeof Link>, keyof BaseProps> & {
href: string;
disabled?: boolean;
};
type ButtonProps = ButtonAsButton | ButtonAsLink;
const variantClasses: Record<Variant, string> = {
primary: "bg-blue-600 text-white hover:bg-blue-700 active:bg-blue-800 focus-visible:ring-blue-500",
secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200 active:bg-gray-300 focus-visible:ring-gray-400",
danger: "bg-red-600 text-white hover:bg-red-700 active:bg-red-800 focus-visible:ring-red-500",
ghost: "bg-transparent text-gray-700 hover:bg-gray-100 active:bg-gray-200 focus-visible:ring-gray-400",
outline: "border border-gray-300 text-gray-700 hover:bg-gray-50 active:bg-gray-100 focus-visible:ring-gray-400",
};
const sizeClasses: Record<Size, string> = {
sm: "h-8 px-3 text-xs gap-1.5",
md: "h-10 px-4 text-sm gap-2",
lg: "h-12 px-6 text-base gap-2.5",
icon: "h-10 w-10 p-0",
};
function Spinner() {
return (
<svg className="h-4 w-4 animate-spin" 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-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
);
}
export const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>(
function Button(props, ref) {
const {
variant = "primary",
size = "md",
loading = false,
fullWidth = false,
leftIcon,
rightIcon,
children,
className,
disabled,
...rest
} = props;
const classes = [
"inline-flex items-center justify-center rounded-lg font-medium transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
"disabled:opacity-50 disabled:pointer-events-none",
variantClasses[variant],
sizeClasses[size],
fullWidth ? "w-full" : "",
loading ? "pointer-events-none" : "",
className ?? "",
]
.filter(Boolean)
.join(" ");
const content = (
<>
{loading ? <Spinner /> : leftIcon}
{children}
{!loading && rightIcon}
</>
);
if ("href" in rest && rest.href) {
const { href, ...linkRest } = rest as ButtonAsLink;
return (
<Link
ref={ref as React.Ref<HTMLAnchorElement>}
href={href}
className={classes}
aria-disabled={disabled || loading}
tabIndex={disabled || loading ? -1 : undefined}
{...linkRest}
>
{content}
</Link>
);
}
return (
<button
ref={ref as React.Ref<HTMLButtonElement>}
disabled={disabled || loading}
className={classes}
{...(rest as Omit<ButtonAsButton, keyof BaseProps>)}
>
{content}
</button>
);
}
);Aspectos clave:
- Renderizado polimórfico — una unión discriminada en
hrefalterna entre<button>y<Link>mientras se mantienen los tipos correctos de TypeScript para ambos. - forwardRef — permite que los componentes padre adjunten refs para gestión del foco, scroll-to o librerías de animación.
- Anillo focus-visible —
focus-visible:ring-2 focus-visible:ring-offset-2muestra un anillo de foco solo en navegación por teclado, no en clics de ratón. - Carga reemplaza leftIcon — el spinner ocupa el lugar del leftIcon, previniendo cambios de diseño durante la carga.
- Patrón de enlace deshabilitado — los enlaces no pueden ser verdaderamente deshabilitados, así que
aria-disabledytabIndex={-1}lo simulan mientraspointer-events-nonepreviene clics. - Composición de clases — un array de strings de clase unidos con
filter(Boolean)mantiene las cosas legibles y evita espacios extras de strings vacíos condicionales.
Trampas
-
Falta
type="button"en botones que no envían — Los botones dentro de un<form>por defecto tienentype="submit", lo que envía el formulario al hacer clic. Siempre añadetype="button"para botones que no están destinados a enviar. -
Usar
<a>en lugar de<Link>para navegación interna — Las etiquetas<a>simples causan una recarga de página completa. Usa<Link>de Next.js para navegación del lado del cliente. -
Botones deshabilitados tragando eventos — Un botón deshabilitado no dispara
onClick, lo que rompe los activadores de tooltip o popover. Envuelve el botón en un<span>si necesitas eventos en un botón deshabilitado. -
Falta de nombre accesible en botones de icono — Un botón con solo un icono SVG no tiene texto para lectores de pantalla. Siempre añade
aria-labela botones solo de icono. -
Estado de carga sin deshabilitar — Mostrar un spinner pero no deshabilitar el botón permite envíos dobles. Siempre establece
disabled={true}opointer-events-nonecuando está cargando. -
Funciones flecha en línea causando re-renderizados innecesarios —
onClick={() => doSomething(id)}crea una nueva función en cada renderizado. Esto rara vez importa, pero en una lista de 100+ botones, extrae el manejador o usauseCallback.
Relacionado
- Formularios — Patrones de envío de formularios que usan botones
- Manejo de eventos — Tipado de onClick y manejador de eventos
- Utilidades de Tailwind — Clases de utilidad utilizadas en el estilo de botones