Botão
Um elemento clicável que aciona ações - o componente interativo mais fundamental em qualquer aplicativo.
Busque em todas as páginas da documentação
Um elemento clicável que aciona ações - o componente interativo mais fundamental em qualquer aplicativo.
🤖 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>
);
}Um botão mínimo com estados hover e active. O onClick é opcional para que o botão também possa ser usado como type="submit" dentro de formulários sem a necessidade de um manipulador.
"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 um Record para mapear nomes de variantes para classes do Tailwind. Estende ButtonHTMLAttributes para que todos os atributos nativos (disabled, type, form, etc.) sejam passados automaticamente.
"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 o tamanho da variante mantém a lógica das classes gerenciável. Combine com o padrão de variante para um sistema completo de botões.
"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>
);
}O SVG do spinner está inline, então nenhuma biblioteca de ícones é necessária. O botão se desabilita durante o carregamento para evitar cliques duplos.
"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="Fechar menu" 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>Botões apenas com ícones exigem aria-label para acessibilidade. O h-10 w-10 fixo mantém o alvo do clique consistente, independentemente do tamanho do ícone.
"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>
);
}Uma união discriminada em href permite que o mesmo componente renderize como um <button> ou um <Link> do Next.js. O TypeScript garante que href e onClick não se misturem incorretamente.
"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 deve ser chamado em um componente que seja um filho de um <form>. Ele lê o estado pendente da ação do formulário pai, então você não precisa passar props de carregamento 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 chave:
href alterna entre <button> e <Link>, mantendo os tipos TypeScript corretos para ambos.focus-visible:ring-2 focus-visible:ring-offset-2 mostra um anel de foco apenas na navegação por teclado, não em cliques do mouse.aria-disabled e tabIndex={-1} os simulam, enquanto pointer-events-none impede cliques.filter(Boolean) mantém as coisas legíveis e evita espaços extras de strings condicionais vazias.Falta de type="button" em botões não de envio - Botões dentro de um <form> são type="submit" por padrão, o que envia o formulário ao clicar. Sempre adicione type="button" para botões que não devem enviar.
Usar <a> em vez de <Link> para navegação interna - Tags <a> simples causam recarregamento completo da página. Use <Link> do Next.js para navegação do lado do cliente.
Botões desabilitados engolindo eventos - Um botão desabilitado não dispara onClick, o que quebra gatilhos de tooltip ou popover. Envolva o botão em um <span> se precisar de eventos em um botão desabilitado.
Nome acessível ausente em botões com ícone - Um botão com apenas um ícone SVG não tem texto para leitores de tela. Sempre adicione aria-label a botões que contenham apenas ícones.
Estado de carregamento sem desabilitar - Mostrar um spinner, mas não desabilitar o botão, permite envios duplos. Sempre defina disabled={true} ou pointer-events-none quando estiver carregando.
Funções de seta inline causando re-renderizações desnecessárias - onClick={() => doSomething(id)} cria uma nova função a cada renderização. Isso raramente importa, mas em uma lista de mais de 100 botões, extraia o manipulador ou use useCallback.
Revisado por Chris St. John·Última atualização: 16 de jul. de 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥