Botón
Un elemento interactivo que ejecuta acciones - el componente interactivo más fundamental en cualquier aplicación.
Busca en todas las páginas de la documentación
Un elemento interactivo que ejecuta acciones - el componente interactivo más fundamental en cualquier aplicación.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
"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.
"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.
"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.
"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.
"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.
"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.
"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.
"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:
href alterna entre <button> y <Link> mientras se mantienen los tipos correctos de TypeScript para ambos.focus-visible:ring-2 focus-visible:ring-offset-2 muestra un anillo de foco solo en navegación por teclado, no en clics de ratón.aria-disabled y tabIndex={-1} lo simulan mientras pointer-events-none previene clics.filter(Boolean) mantiene las cosas legibles y evita espacios extras de strings vacíos condicionales.Falta type="button" en botones que no envían - Los botones dentro de un <form> por defecto tienen type="submit", lo que envía el formulario al hacer clic. Siempre añade type="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-label a 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} o pointer-events-none cuando 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 usa useCallback.
Revisado por Chris St. John·Última actualización: 16 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥