Referência React & Next.js
Exemplos práticos de código para consulta rápida, componentes, hooks, tipagem, formulários e mais.
JSX & Composição
JSX é açúcar sintático sobre React.createElement. Composição é o principal padrão para reutilização em React.
// JSX básico :: retorna elementos React
function Greeting({ name }: { name: string }) {
return <h1>Olá, {name}!</h1>;
}
// Composição via children
function Card({ children }: { children: React.ReactNode }) {
return (
<div className="rounded-lg border p-4">
{children}
</div>
);
}
// Composição via render props / slot nomeado
function Layout({
sidebar,
children,
}: {
sidebar: React.ReactNode;
children: React.ReactNode;
}) {
return (
<div className="grid grid-cols-[240px_1fr] gap-6">
<aside>{sidebar}</aside>
<main>{children}</main>
</div>
);
}
// Uso
function App() {
return (
<Layout sidebar={<nav>Menu</nav>}>
<Card>
<Greeting name="Marco" />
<p>Conteúdo principal aqui.</p>
</Card>
</Layout>
);
}Props tipadas com TypeScript
// Props opcionais com defaults
type ButtonProps = {
children: React.ReactNode;
variant?: "primary" | "secondary" | "ghost";
size?: "sm" | "md" | "lg";
disabled?: boolean;
onClick?: () => void;
type?: "button" | "submit" | "reset";
};
export function Button({
children,
variant = "primary",
size = "md",
disabled = false,
onClick,
type = "button",
}: ButtonProps) {
const base = "inline-flex items-center justify-center font-medium rounded-lg transition-colors";
const variants = {
primary: "bg-sky-500 text-white hover:bg-sky-400 disabled:bg-sky-900",
secondary: "border border-slate-700 text-slate-200 hover:bg-slate-800",
ghost: "text-slate-400 hover:text-slate-100 hover:bg-slate-800",
};
const sizes = {
sm: "text-xs px-3 py-1.5",
md: "text-sm px-4 py-2",
lg: "text-base px-5 py-2.5",
};
return (
<button
type={type}
disabled={disabled}
onClick={onClick}
className={`${base} ${variants[variant]} ${sizes[size]}`}
>
{children}
</button>
);
}// Union discriminada para variantes mutuamente exclusivas
type AlertProps =
| { variant: "info"; message: string }
| { variant: "success"; message: string; onDismiss: () => void }
| { variant: "error"; message: string; retry?: () => void };
function Alert(props: AlertProps) {
if (props.variant === "success") {
return (
<div className="flex justify-between">
<span>{props.message}</span>
<button onClick={props.onDismiss}>✕</button>
</div>
);
}
return <div>{props.message}</div>;
}useState
ℹ️ Info
"use client";
import { useState } from "react";
// Estado primitivo
function Counter() {
const [count, setCount] = useState(0);
return (
<div className="flex items-center gap-3">
<button onClick={() => setCount((c) => c - 1)}>−</button>
<span>{count}</span>
<button onClick={() => setCount((c) => c + 1)}>+</button>
</div>
);
}
Estado de objeto :: sempre espalhe o estado anterior!
type Form = { name: string; email: string };
function ContactForm() {
const [form, setForm] = useState<Form>({ name: "", email: "" });
const update = (field: keyof Form) =>
(e: React.ChangeEvent<HTMLInputElement>) =>
setForm((prev) => ({ ...prev, [field]: e.target.value }));
return (
<form>
<input value={form.name} onChange={update("name")} placeholder="Nome" />
<input value={form.email} onChange={update("email")} placeholder="Email" />
</form>
);
}
// Estado derivado :: evite derivar estado com useState
// ✅ calcule na renderização
function FilteredList({ items }: { items: string[] }) {
const [query, setQuery] = useState("");
const filtered = items.filter((i) => i.includes(query)); // derivado direto
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ul>{filtered.map((i) => <li key={i}>{i}</li>)}</ul>
</>
);
}useEffect
⚠️ Atenção
"use client";
import { useState, useEffect } from "react";
// Sincronização com API externa
function useWindowWidth() {
const [width, setWidth] = useState(0);
useEffect(() => {
setWidth(window.innerWidth);
const handler = () => setWidth(window.innerWidth);
window.addEventListener("resize", handler);
return () => window.removeEventListener("resize", handler); // cleanup!
}, []); // [] = roda apenas na montagem
return width;
}
// Effect com dependência
function UserCard({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
let cancelled = false; // evita race condition
fetch(`/api/users/${userId}`)
.then((r) => r.json())
.then((data) => { if (!cancelled) setUser(data); });
return () => { cancelled = true; };
}, [userId]); // re-executa quando userId muda
if (!user) return <div>Carregando...</div>;
return <div>{user.name}</div>;
}
// ❌ NÃO faça isso :: use TanStack Query / SWR em vez disso
// useEffect para data fetching é propenso a bugsuseMemo & useCallback
useMemo memoriza o resultado de um cálculo caro. useCallback memoriza a referência de uma função. Ambos são otimizações :: não os use por padrão, use quando há problema de performance.
"use client";
import { useState, useMemo, useCallback, memo } from "react";
// useMemo :: cálculo caro
function SortedList({ items }: { items: number[] }) {
const [multiplier, setMultiplier] = useState(1);
// Só recalcula quando items ou multiplier mudam
const processed = useMemo(
() => items.map((n) => n * multiplier).sort((a, b) => a - b),
[items, multiplier]
);
return <ul>{processed.map((n) => <li key={n}>{n}</li>)}</ul>;
}
// useCallback :: estabiliza referência de função para componente filho memorizado
const Row = memo(function Row({
item,
onDelete,
}: {
item: string;
onDelete: (item: string) => void;
}) {
return (
<li>
{item}
<button onClick={() => onDelete(item)}>Remover</button>
</li>
);
});
function List({ items }: { items: string[] }) {
const [list, setList] = useState(items);
// Sem useCallback, Row re-renderiza em cada render do List
const handleDelete = useCallback((item: string) => {
setList((prev) => prev.filter((i) => i !== item));
}, []);
return (
<ul>
{list.map((i) => (
<Row key={i} item={i} onDelete={handleDelete} />
))}
</ul>
);
}useRef
useRef tem dois usos principais: referência a um elemento DOM e manter valor mutável entre renders sem causar re-render.
"use client";
import { useRef, useEffect } from "react";
// Ref de elemento DOM
function AutoFocusInput() {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
return <input ref={inputRef} placeholder="Focado automaticamente" />;
}
// Ref de valor mutable (não causa re-render)
function Stopwatch() {
const [running, setRunning] = useState(false);
const [elapsed, setElapsed] = useState(0);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
function start() {
setRunning(true);
intervalRef.current = setInterval(() => {
setElapsed((e) => e + 10);
}, 10);
}
function stop() {
setRunning(false);
if (intervalRef.current) clearInterval(intervalRef.current);
}
return (
<div>
<span>{(elapsed / 1000).toFixed(2)}s</span>
<button onClick={running ? stop : start}>
{running ? "Parar" : "Iniciar"}
</button>
</div>
);
}
// forwardRef :: expor ref para componente pai
import { forwardRef } from "react";
const Input = forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
(props, ref) => <input ref={ref} className="border rounded px-3 py-2" {...props} />
);
Input.displayName = "Input";Context API
💡 Dica
"use client";
import { createContext, useContext, useState, ReactNode } from "react";
// 1. Definir o tipo e criar o contexto
type Theme = "dark" | "light";
type ThemeContextType = {
theme: Theme;
toggle: () => void;
};
const ThemeContext = createContext<ThemeContextType | null>(null);
// 2. Provider
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>("dark");
const toggle = () => setTheme((t) => (t === "dark" ? "light" : "dark"));
return (
<ThemeContext.Provider value={{ theme, toggle }}>
{children}
</ThemeContext.Provider>
);
}
// 3. Hook de consumo (garante que está dentro do Provider)
export function useTheme(): ThemeContextType {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme deve ser usado dentro de ThemeProvider");
return ctx;
}
// 4. Uso em componente filho
function ThemeToggle() {
const { theme, toggle } = useTheme();
return (
<button onClick={toggle}>
Tema atual: {theme}
</button>
);
}Custom Hooks
Custom hooks encapsulam lógica stateful reutilizável. Devem começar com use para que o React aplique as regras dos hooks.
"use client";
import { useState, useEffect, useCallback } from "react";
// Hook de fetch genérico
function useFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetch(url)
.then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json() as Promise<T>;
})
.then((d) => { if (!cancelled) { setData(d); setLoading(false); } })
.catch((e) => { if (!cancelled) { setError(e); setLoading(false); } });
return () => { cancelled = true; };
}, [url]);
return { data, loading, error };
}
// Hook de debounce
function useDebounce<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
// Hook de local storage
function useLocalStorage<T>(key: string, initial: T) {
const [stored, setStored] = useState<T>(() => {
if (typeof window === "undefined") return initial;
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : initial;
} catch { return initial; }
});
const setValue = useCallback((value: T | ((prev: T) => T)) => {
setStored((prev) => {
const next = typeof value === "function" ? (value as (p: T) => T)(prev) : value;
localStorage.setItem(key, JSON.stringify(next));
return next;
});
}, [key]);
return [stored, setValue] as const;
}
// Uso
function SearchBox() {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 400);
const { data, loading } = useFetch<string[]>(`/api/search?q=${debouncedQuery}`);
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
{loading ? "Buscando..." : data?.map((r) => <div key={r}>{r}</div>)}
</div>
);
}Server Components vs Client Components
Server Components (padrão no App Router)
- Renderizados no servidor, nunca enviados ao browser como JS
- Podem acessar banco de dados, APIs privadas, filesystem diretamente
- Sem bundle no cliente, melhor performance
- Não podem usar hooks, eventos, browser APIs
Client Components ("use client")
- Hydratados no browser após o HTML inicial
- Podem usar hooks, useState, eventos
- Aumentam o bundle do cliente, use com moderação
// Server Component (sem "use client")
// Pode fazer fetch diretamente, sem useEffect!
async function UserProfile({ userId }: { userId: string }) {
// Fetch direto no servidor, não vai para o bundle do browser
const user = await db.users.findById(userId);
const posts = await db.posts.findByUserId(userId, { limit: 5 });
if (!user) notFound();
return (
<div>
<h1>{user.name}</h1>
<p>{user.bio}</p>
{/* Passa dados para um Client Component que precisa de interatividade */}
<LikeButton postIds={posts.map((p) => p.id)} />
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
);
}"use client";
// Client Component, precisa de interatividade
import { useState } from "react";
export function LikeButton({ postIds }: { postIds: string[] }) {
const [liked, setLiked] = useState<Set<string>>(new Set());
const toggle = (id: string) =>
setLiked((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
return (
<div>
{postIds.map((id) => (
<button
key={id}
onClick={() => toggle(id)}
className={liked.has(id) ? "text-red-500" : "text-slate-400"}
>
{liked.has(id) ? "❤️" : "🤍"} {id}
</button>
))}
</div>
);
}"use client" quando usar e quando evitar
⚠️ Atenção
"use client" marca um componente e TODOS os seus descendentes como Client Components. Coloque-o o mais profundo possível na árvore.// ❌ Evite marca a página inteira como client
"use client";
export default function Page() {
return (
<div>
<HeavyServerContent /> {/* perdeu os benefícios de Server Component */}
<Counter />
</div>
);
}
// ✅ Correto : isola "use client" no menor componente possível
// app/page.tsx (Server Component)
import Counter from "./Counter"; // client component isolado
export default function Page() {
return (
<div>
<HeavyServerContent /> {/* ainda é Server Component */}
<Counter /> {/* somente Counter é client */}
</div>
);
}
// Counter.tsx
"use client";
import { useState } from "react";
export default function Counter() {
const [n, setN] = useState(0);
return <button onClick={() => setN(n + 1)}>{n}</button>;
}Formulários
"use client";
import { useState, FormEvent } from "react";
type FormState = {
name: string;
email: string;
message: string;
};
type SubmitState = "idle" | "loading" | "success" | "error";
export function ContactForm() {
const [form, setForm] = useState<FormState>({ name: "", email: "", message: "" });
const [status, setStatus] = useState<SubmitState>("idle");
const [errors, setErrors] = useState<Partial<FormState>>({});
const validate = (): boolean => {
const e: Partial<FormState> = {};
if (!form.name.trim()) e.name = "Nome é obrigatório";
if (!form.email.includes("@")) e.email = "Email inválido";
if (form.message.length < 10) e.message = "Mínimo 10 caracteres";
setErrors(e);
return Object.keys(e).length === 0;
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (!validate()) return;
setStatus("loading");
try {
await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
setStatus("success");
} catch {
setStatus("error");
}
};
const field = (key: keyof FormState) => ({
value: form[key],
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
setForm((prev) => ({ ...prev, [key]: e.target.value })),
});
if (status === "success") return <p>Mensagem enviada! ✓</p>;
return (
<form onSubmit={handleSubmit} noValidate className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium mb-1">Nome</label>
<input id="name" type="text" {...field("name")}
aria-describedby={errors.name ? "name-err" : undefined}
className="w-full border rounded px-3 py-2 bg-slate-900"
/>
{errors.name && <p id="name-err" role="alert" className="text-red-400 text-xs mt-1">{errors.name}</p>}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">Email</label>
<input id="email" type="email" {...field("email")}
className="w-full border rounded px-3 py-2 bg-slate-900"
/>
{errors.email && <p role="alert" className="text-red-400 text-xs mt-1">{errors.email}</p>}
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium mb-1">Mensagem</label>
<textarea id="message" rows={4} {...field("message")}
className="w-full border rounded px-3 py-2 bg-slate-900 resize-none"
/>
{errors.message && <p role="alert" className="text-red-400 text-xs mt-1">{errors.message}</p>}
</div>
<button type="submit" disabled={status === "loading"}
className="bg-sky-500 hover:bg-sky-400 text-white px-4 py-2 rounded font-medium disabled:opacity-50"
>
{status === "loading" ? "Enviando..." : "Enviar"}
</button>
{status === "error" && <p role="alert" className="text-red-400 text-sm">Erro ao enviar. Tente novamente.</p>}
</form>
);
}Listas & Keys
⚠️ Atenção
key quando a lista pode ser reordenada ou filtrada. Use um ID único e estável.// ❌ Índice como key : problemático com listas dinâmicas
items.map((item, index) => <Row key={index} item={item} />);
// ✅ ID estável como key
items.map((item) => <Row key={item.id} item={item} />);
// Componente de lista com renderização otimizada
type Todo = { id: string; text: string; done: boolean };
function TodoList({ todos }: { todos: Todo[] }) {
if (todos.length === 0) {
return <p className="text-slate-500">Nenhuma tarefa.</p>;
}
return (
<ul role="list" className="space-y-2">
{todos.map((todo) => (
<li
key={todo.id}
className={`flex items-center gap-3 p-3 rounded border ${
todo.done ? "border-slate-800 opacity-50" : "border-slate-700"
}`}
>
<span className={`flex-1 ${todo.done ? "line-through" : ""}`}>
{todo.text}
</span>
</li>
))}
</ul>
);
}Renderização condicional
// Formas de renderização condicional em JSX
function StatusBadge({ status }: { status: "active" | "inactive" | "pending" }) {
// 1. Objeto de variantes (preferível para muitos casos)
const config = {
active: { label: "Ativo", className: "bg-green-900 text-green-400" },
inactive: { label: "Inativo", className: "bg-slate-800 text-slate-400" },
pending: { label: "Pendente", className: "bg-yellow-900 text-yellow-400" },
};
const { label, className } = config[status];
return <span className={`px-2 py-0.5 rounded text-xs ${className}`}>{label}</span>;
}
// 2. && - cuidado com valores falsy (0 renderiza!)
function Notification({ count }: { count: number }) {
return (
<div>
{count > 0 && <span className="badge">{count}</span>}
{/* ✅ count > 0 evita renderizar "0" */}
</div>
);
}
// 3. Ternário, bom para alternativas binárias
function LoginButton({ isLoggedIn }: { isLoggedIn: boolean }) {
return isLoggedIn
? <button>Sair</button>
: <button>Entrar</button>;
}
// 4. Early return, bom para estados de loading/error
function UserCard({ userId }: { userId: string }) {
const { data: user, loading, error } = useFetch<User>(`/api/users/${userId}`);
if (loading) return <Skeleton />;
if (error) return <ErrorMessage error={error} />;
if (!user) return null;
return <div>{user.name}</div>;
}Error Boundaries no Next.js
No App Router, error.tsx é o mecanismo nativo de Error Boundary por segmento de rota.
"use client"; // Error components devem ser Client Components
import { useEffect } from "react";
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Log para serviço de monitoramento (Sentry, etc.)
console.error("Dashboard error:", error);
}, [error]);
return (
<div className="flex flex-col items-center gap-4 py-16 text-center">
<h2 className="text-xl font-semibold text-red-400">
Algo deu errado
</h2>
<p className="text-slate-400 text-sm max-w-sm">
{error.message || "Erro inesperado. Tente novamente."}
</p>
<button
onClick={reset}
className="bg-sky-500 text-white px-4 py-2 rounded text-sm"
>
Tentar novamente
</button>
</div>
);
}Suspense & Loading
// loading.tsx fallback automático durante navegação
export default function DashboardLoading() {
return (
<div className="space-y-4 animate-pulse">
<div className="h-8 bg-slate-800 rounded w-1/3" />
<div className="grid grid-cols-3 gap-4">
{[1, 2, 3].map((i) => (
<div key={i} className="h-32 bg-slate-800 rounded" />
))}
</div>
<div className="h-64 bg-slate-800 rounded" />
</div>
);
}import { Suspense } from "react";
// Componente lento (async Server Component)
async function SlowWidget() {
await new Promise((r) => setTimeout(r, 2000)); // simula latência
const data = await fetch("/api/analytics").then((r) => r.json());
return <div>{data.value}</div>;
}
// Streaming com Suspense, o resto da página carrega imediatamente
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
<div className="grid grid-cols-2 gap-4">
<Suspense fallback={<div className="h-20 bg-slate-800 animate-pulse rounded" />}>
<SlowWidget />
</Suspense>
<Suspense fallback={<div className="h-20 bg-slate-800 animate-pulse rounded" />}>
<AnotherSlowWidget />
</Suspense>
</div>
</div>
);
}Data Fetching com Server Components
ℹ️ Info
cache: 'no-store' para dados sempre frescos ou revalidate para ISR.// Server Component, fetch direto, sem useEffect
async function ProductPage({ params }: { params: { id: string } }) {
// Cache padrão: memoizado por request (Next.js 15)
const product = await fetch(`https://api.marcocianci.com/products/${params.id}`).then(r => r.json());
// Sem cache, sempre busca dados frescos
const inventory = await fetch(`https://api.marcocianci.com/inventory/${params.id}`, {
cache: "no-store",
}).then(r => r.json());
// Revalidação periódica (ISR-like)
const reviews = await fetch(`https://api.marcocianci.com/reviews/${params.id}`, {
next: { revalidate: 3600 }, // revalida a cada 1 hora
}).then(r => r.json());
return (
<div>
<h1>{product.name}</h1>
<p>Estoque: {inventory.available}</p>
<ReviewList reviews={reviews} />
</div>
);
}
// Acesso ao banco direto (Drizzle, Prisma, etc.)
async function UserProfile({ id }: { id: string }) {
const user = await db.query.users.findFirst({
where: (users, { eq }) => eq(users.id, id),
with: { posts: true },
});
if (!user) notFound();
return <div>{user.name}</div>;
}Fetches paralelos, evitando waterfall
⚠️ Atenção
Promise.all para paralelizar.// ❌ Waterfall, total = userTime + postsTime + ordersTime
async function ProfilePage({ userId }: { userId: string }) {
const user = await fetchUser(userId); // 200ms
const posts = await fetchPosts(userId); // 150ms
const orders = await fetchOrders(userId); // 100ms
// Total: 450ms
}
// ✅ Paralelo, total = max(userTime, postsTime, ordersTime)
async function ProfilePage({ userId }: { userId: string }) {
const [user, posts, orders] = await Promise.all([
fetchUser(userId), // }
fetchPosts(userId), // } executam ao mesmo tempo
fetchOrders(userId), // }
]);
// Total: ~200ms
}
// ✅ Alternativa com Suspense streaming, melhor para UX
// Inicia os fetches sem await para que corram em paralelo
async function ProfilePage({ userId }: { userId: string }) {
const userPromise = fetchUser(userId); // inicia imediatamente
const postsPromise = fetchPosts(userId); // inicia imediatamente
const ordersPromise = fetchOrders(userId); // inicia imediatamente
const user = await userPromise; // espera apenas o user para renderizar o header
return (
<div>
<UserHeader user={user} />
<Suspense fallback={<Skeleton />}>
<Posts postsPromise={postsPromise} />
</Suspense>
<Suspense fallback={<Skeleton />}>
<Orders ordersPromise={ordersPromise} />
</Suspense>
</div>
);
}Acessibilidade básica
- Todo
<img>precisa dealtdescritivo (oualt=""se decorativo) - Use elementos semânticos:
<button>,<nav>,<main>,<article> - Labels associados a todos os inputs via
htmlFor - Focus ring visível em todos os elementos interativos
- Contraste de cor mínimo 4.5:1 para texto normal (WCAG AA)
- Keyboard navigation funcional (Tab, Enter, Space, Escape)
// ✅ Dialog acessível
function Dialog({
open,
onClose,
title,
children,
}: {
open: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}) {
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
hidden={!open}
className="fixed inset-0 z-50 flex items-center justify-center"
>
<div className="bg-slate-900 rounded-lg p-6 max-w-md w-full">
<h2 id="dialog-title" className="text-lg font-semibold">{title}</h2>
{children}
<button
onClick={onClose}
aria-label="Fechar dialog"
className="absolute top-4 right-4 focus-visible:ring-2 focus-visible:ring-sky-500"
>
✕
</button>
</div>
</div>
);
}
// ✅ Botão de ícone com aria-label
function IconButton({ icon, label, onClick }: {
icon: React.ReactNode;
label: string;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
aria-label={label}
className="p-2 rounded hover:bg-slate-800 focus-visible:ring-2 focus-visible:ring-sky-500 transition-colors"
>
{icon}
</button>
);
}
// ✅ Live region para status dinâmico
function StatusAnnouncer({ message }: { message: string }) {
return (
<div role="status" aria-live="polite" aria-atomic="true" className="sr-only">
{message}
</div>
);
}Padrões Tailwind em componentes React
// Variantes com objeto de classes (evita string concatenation condicional bagunçada)
type Variant = "primary" | "secondary" | "danger";
const buttonVariants: Record<Variant, string> = {
primary: "bg-sky-500 text-white hover:bg-sky-400 focus-visible:ring-sky-500",
secondary: "border border-slate-700 text-slate-200 hover:bg-slate-800",
danger: "bg-red-600 text-white hover:bg-red-500 focus-visible:ring-red-500",
};
// Responsividade
function ResponsiveGrid({ children }: { children: React.ReactNode }) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{children}
</div>
);
}
// Dark mode com classe (configurado no tailwind.config)
function Card({ children }: { children: React.ReactNode }) {
return (
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-lg p-4">
{children}
</div>
);
}
// Estados de foco acessíveis
const inputClass = [
"w-full rounded-lg border px-3 py-2 text-sm",
"bg-slate-900 border-slate-700 text-slate-100",
"placeholder:text-slate-500",
"focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 focus-visible:border-transparent",
"disabled:opacity-50 disabled:cursor-not-allowed",
].join(" ");
// cn helper (sem dependência de lib)
function cn(...classes: (string | false | undefined | null)[]) {
return classes.filter(Boolean).join(" ");
}
// Uso
function Input({ error, className, ...props }: React.InputHTMLAttributes<HTMLInputElement> & { error?: boolean }) {
return (
<input
className={cn(
inputClass,
error && "border-red-500 focus-visible:ring-red-500",
className
)}
{...props}
/>
);
}