Visualização de dados com recharts: padrões de painel
Um ótimo painel não consiste apenas em números em uma tabela – são padrões visuais que revelam tendências, anomalias e oportunidades rapidamente. Recharts é a biblioteca de gráficos mais amplamente usada no ecossistema React: construída sobre os fundamentos do D3.js, totalmente digitada em TypeScript, combinável e responsiva pronta para uso. Ele lida com a complexidade do SVG para que você possa se concentrar na história dos dados.
Este guia cobre padrões Recharts para painéis de produção – tendências de receita, funis de conversão, métricas de série temporal, distribuição geográfica e dados de streaming em tempo real – com suporte ao modo escuro, paletas de cores acessíveis e layouts responsivos.
Principais conclusões
- Sempre envolva gráficos em
ResponsiveContainercomwidth="100%"e umheightfixo - nunca codifique larguras de pixel- Use componentes
CustomTooltipem vez do padrão — controle total sobre formatação e estilo- Recharts
formattercallback parameters should NEVER be explicitly typed — useNumber(value)to convert- O modo escuro requer valores de cores dinâmicos - lidos a partir de variáveis CSS, não de cores hexadecimais codificadas
- Para dados de série temporal, use um
tickFormatterno XAxis para formatar carimbos de data/hora de forma consistente- Acessibilidade: adicione
aria-labelao contêiner do gráfico e usetabIndexnos itens da legenda- Desempenho: memorize os dados do gráfico com
useMemo— Recharts re-renderiza em cada renderização pai- Para dados em tempo real, atualize a referência da matriz de dados — Recharts detecta alterações por referência
Configuração
pnpm add recharts
pnpm add -D @types/recharts # Usually not needed — recharts ships its own types
Recharts 2.x fornece tipos TypeScript. Nenhum pacote @types separado é necessário.
Gráfico de Área de Receita
// src/components/charts/revenue-area-chart.tsx
'use client';
import { useMemo } from 'react';
import {
AreaChart, Area, XAxis, YAxis, CartesianGrid,
Tooltip, Legend, ResponsiveContainer,
} from 'recharts';
import { format, parseISO } from 'date-fns';
interface RevenueDataPoint {
date: string; // ISO date string
revenue: number;
refunds: number;
net: number;
}
interface CustomTooltipProps {
active?: boolean;
payload?: Array<{ name: string; value: number; color: string }>;
label?: string;
}
function CustomTooltip({ active, payload, label }: CustomTooltipProps) {
if (!active || !payload || !label) return null;
return (
<div className="rounded-lg border bg-background p-3 shadow-md">
<p className="mb-2 text-sm font-medium text-muted-foreground">
{format(parseISO(label), 'MMM d, yyyy')}
</p>
{payload.map((entry) => (
<div key={entry.name} className="flex items-center gap-2 text-sm">
<span
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: entry.color }}
/>
<span className="text-muted-foreground">{entry.name}:</span>
<span className="font-medium">
{new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0,
}).format(entry.value)}
</span>
</div>
))}
</div>
);
}
interface RevenueAreaChartProps {
data: RevenueDataPoint[];
loading?: boolean;
}
export function RevenueAreaChart({ data, loading }: RevenueAreaChartProps) {
// CRITICAL: memoize chart data — Recharts re-renders on every reference change
const chartData = useMemo(() => data, [data]);
if (loading) {
return <div className="h-80 animate-pulse rounded-lg bg-muted" />;
}
return (
<div role="img" aria-label="Monthly revenue area chart">
<ResponsiveContainer width="100%" height={320}>
<AreaChart data={chartData} margin={{ top: 10, right: 30, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="colorRevenue" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#f59e0b" stopOpacity={0.3} />
<stop offset="95%" stopColor="#f59e0b" stopOpacity={0} />
</linearGradient>
<linearGradient id="colorNet" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#22c55e" stopOpacity={0.3} />
<stop offset="95%" stopColor="#22c55e" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="3 3"
stroke="hsl(var(--border))"
vertical={false}
/>
<XAxis
dataKey="date"
tickFormatter={(value: string) => format(parseISO(value), 'MMM d')}
tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
axisLine={false}
tickLine={false}
/>
<YAxis
tickFormatter={(value) => `$${(Number(value) / 1000).toFixed(0)}k`}
tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
axisLine={false}
tickLine={false}
width={60}
/>
<Tooltip content={<CustomTooltip />} />
<Legend
wrapperStyle={{ fontSize: '14px', paddingTop: '16px' }}
formatter={(value: string) =>
<span className="text-foreground">{value}</span>
}
/>
<Area
type="monotone"
dataKey="revenue"
name="Gross Revenue"
stroke="#f59e0b"
strokeWidth={2}
fill="url(#colorRevenue)"
/>
<Area
type="monotone"
dataKey="net"
name="Net Revenue"
stroke="#22c55e"
strokeWidth={2}
fill="url(#colorNet)"
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
}
Gráfico de barras: comparação mensal
// src/components/charts/monthly-bar-chart.tsx
'use client';
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid,
Tooltip, Legend, ResponsiveContainer, Cell,
} from 'recharts';
interface MonthlyData {
month: string;
current: number;
previous: number;
target: number;
}
function CustomBarTooltip({ active, payload, label }: any) {
if (!active || !payload) return null;
return (
<div className="rounded-lg border bg-background p-3 shadow-md">
<p className="mb-2 font-medium">{label}</p>
{payload.map((entry: { name: string; value: number; fill: string }) => (
<div key={entry.name} className="flex items-center gap-2 text-sm">
<span
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: entry.fill }}
/>
<span className="text-muted-foreground">{entry.name}:</span>
<span className="font-medium">
{new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0,
}).format(entry.value)}
</span>
</div>
))}
</div>
);
}
export function MonthlyBarChart({ data }: { data: MonthlyData[] }) {
return (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
<XAxis
dataKey="month"
tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
axisLine={false}
tickLine={false}
/>
<YAxis
tickFormatter={(value) => `$${(Number(value) / 1000).toFixed(0)}k`}
tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
axisLine={false}
tickLine={false}
/>
<Tooltip content={<CustomBarTooltip />} />
<Legend />
<Bar dataKey="previous" name="Previous Year" fill="hsl(var(--muted))" radius={[4, 4, 0, 0]} />
<Bar dataKey="current" name="Current Year" fill="#f59e0b" radius={[4, 4, 0, 0]} />
<Bar dataKey="target" name="Target" fill="#22c55e" opacity={0.4} radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
);
}
Gráfico de rosca/torta
// src/components/charts/platform-pie-chart.tsx
'use client';
import { useState } from 'react';
import {
PieChart, Pie, Cell, Sector,
ResponsiveContainer, Legend, Tooltip,
} from 'recharts';
interface PieDataPoint {
name: string;
value: number;
color: string;
}
const PLATFORM_COLORS: Record<string, string> = {
'Odoo': '#714b67',
'Shopify': '#96bf48',
'GoHighLevel': '#f59e0b',
'Power BI': '#f2c811',
'OpenClaw': '#0ea5e9',
'Other': '#94a3b8',
};
function renderActiveShape(props: any) {
const {
cx, cy, innerRadius, outerRadius, startAngle, endAngle,
fill, payload, percent, value,
} = props;
return (
<g>
<text x={cx} y={cy - 12} textAnchor="middle" className="text-base font-bold fill-foreground">
{payload.name}
</text>
<text x={cx} y={cy + 12} textAnchor="middle" className="text-sm fill-muted-foreground">
{`${(percent * 100).toFixed(1)}%`}
</text>
<text x={cx} y={cy + 32} textAnchor="middle" className="text-sm fill-muted-foreground">
{`$${(Number(value) / 1000).toFixed(0)}k`}
</text>
<Sector
cx={cx} cy={cy}
innerRadius={innerRadius}
outerRadius={outerRadius + 8}
startAngle={startAngle}
endAngle={endAngle}
fill={fill}
/>
<Sector
cx={cx} cy={cy}
innerRadius={outerRadius + 10}
outerRadius={outerRadius + 12}
startAngle={startAngle}
endAngle={endAngle}
fill={fill}
/>
</g>
);
}
export function PlatformPieChart({ data }: { data: PieDataPoint[] }) {
const [activeIndex, setActiveIndex] = useState(0);
const chartData = data.map((item) => ({
...item,
color: PLATFORM_COLORS[item.name] ?? '#94a3b8',
}));
return (
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
activeIndex={activeIndex}
activeShape={renderActiveShape}
data={chartData}
cx="50%"
cy="50%"
innerRadius={70}
outerRadius={100}
dataKey="value"
onMouseEnter={(_, index) => setActiveIndex(index)}
>
{chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Legend
formatter={(value: string) =>
<span className="text-sm text-foreground">{value}</span>
}
/>
</PieChart>
</ResponsiveContainer>
);
}
Gráfico de linhas em tempo real
// src/components/charts/realtime-chart.tsx
'use client';
import { useState, useEffect, useRef } from 'react';
import {
LineChart, Line, XAxis, YAxis, CartesianGrid,
Tooltip, ResponsiveContainer, ReferenceLine,
} from 'recharts';
interface MetricPoint {
time: string;
requests: number;
errors: number;
p95: number;
}
const MAX_DATA_POINTS = 60; // 60 seconds of data
export function RealtimeChart({ endpoint }: { endpoint: string }) {
const [data, setData] = useState<MetricPoint[]>([]);
const intervalRef = useRef<ReturnType<typeof setInterval>>();
useEffect(() => {
const fetchMetrics = async () => {
try {
const response = await fetch(endpoint);
const point: MetricPoint = await response.json();
setData((prev) => {
const updated = [
...prev,
{ ...point, time: new Date().toLocaleTimeString() },
];
// Keep only the last MAX_DATA_POINTS — sliding window
return updated.slice(-MAX_DATA_POINTS);
});
} catch {
// Metrics fetch failure is non-critical — log but do not crash
}
};
fetchMetrics();
intervalRef.current = setInterval(fetchMetrics, 1000);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [endpoint]);
return (
<ResponsiveContainer width="100%" height={200}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
<XAxis
dataKey="time"
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
interval="preserveStartEnd"
/>
<YAxis
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
axisLine={false}
/>
<Tooltip />
{/* Reference line for SLO threshold */}
<ReferenceLine
y={200}
label={{ value: 'SLO 200ms', position: 'right', fontSize: 10 }}
stroke="#ef4444"
strokeDasharray="4 4"
/>
<Line
type="monotone"
dataKey="p95"
name="P95 Latency (ms)"
stroke="#f59e0b"
strokeWidth={2}
dot={false}
isAnimationActive={false} // Disable animation for real-time data
/>
<Line
type="monotone"
dataKey="errors"
name="Errors/s"
stroke="#ef4444"
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
);
}
Layout do painel com cartões de estatísticas
// src/components/dashboard/metrics-dashboard.tsx
'use client';
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api';
import { RevenueAreaChart } from './revenue-area-chart';
import { MonthlyBarChart } from './monthly-bar-chart';
import { PlatformPieChart } from './platform-pie-chart';
export function MetricsDashboard() {
const { data: metrics, isLoading } = useQuery({
queryKey: ['dashboard-metrics'],
queryFn: () => apiFetch<DashboardMetrics>('/analytics/dashboard'),
refetchInterval: 5 * 60 * 1000, // Refetch every 5 minutes
});
return (
<div className="space-y-6">
{/* KPI Cards */}
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
{[
{ label: 'MRR', value: metrics?.mrr, format: 'currency', trend: '+12%' },
{ label: 'ARR', value: metrics?.arr, format: 'currency', trend: '+8%' },
{ label: 'Customers', value: metrics?.customers, format: 'number', trend: '+23' },
{ label: 'Churn', value: metrics?.churnRate, format: 'percent', trend: '-0.3%' },
].map(({ label, value, format, trend }) => (
<div key={label} className="rounded-lg border bg-card p-4">
<p className="text-sm text-muted-foreground">{label}</p>
<p className="mt-1 text-2xl font-bold">
{isLoading ? (
<span className="inline-block h-8 w-24 animate-pulse rounded bg-muted" />
) : (
formatMetric(value, format)
)}
</p>
<p className="mt-1 text-sm text-green-500">{trend} this month</p>
</div>
))}
</div>
{/* Charts */}
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2 rounded-lg border bg-card p-6">
<h3 className="mb-4 text-lg font-semibold">Revenue Trend</h3>
<RevenueAreaChart data={metrics?.revenueByDay ?? []} loading={isLoading} />
</div>
<div className="rounded-lg border bg-card p-6">
<h3 className="mb-4 text-lg font-semibold">Revenue by Platform</h3>
<PlatformPieChart data={metrics?.revenueByPlatform ?? []} />
</div>
</div>
<div className="rounded-lg border bg-card p-6">
<h3 className="mb-4 text-lg font-semibold">Monthly Comparison</h3>
<MonthlyBarChart data={metrics?.monthlyComparison ?? []} />
</div>
</div>
);
}
function formatMetric(value: number | undefined, format: string): string {
if (value === undefined) return '—';
const fmt = new Intl.NumberFormat('en-US');
if (format === 'currency') return `$${fmt.format(value)}`;
if (format === 'percent') return `${value.toFixed(1)}%`;
return fmt.format(value);
}
Perguntas frequentes
Por que os retornos de chamada do formatador Recharts Tooltip nunca devem digitar explicitamente seus parâmetros?
Os tipos TypeScript de Recharts para a propriedade formatter são sobrecarregados de maneiras que causam conflitos de tipo quando você anota os parâmetros. O padrão correto é usar Number(value) para converter o valor com segurança sem anotar o tipo de value. Se você anotar como (value: number), o TypeScript poderá apresentar erro devido ao tipo de união na assinatura interna do Recharts. Deixe o TypeScript inferir o tipo e converter com Number(value).
Como faço para que os gráficos do Recharts funcionem no modo escuro?
Use propriedades personalizadas CSS (variáveis) para todas as cores em vez de valores hexadecimais codificados. Referencie-os com hsl(var(--border)) para linhas de grade, hsl(var(--muted-foreground)) para texto de eixo e hsl(var(--background)) para fundos de dicas de ferramentas. Essas variáveis são definidas em seu tema Tailwind e alternam automaticamente os valores entre os modos claro e escuro por meio da classe dark em <html>. Nunca codifique #374151 – ele não será atualizado com o tema.
Como lidar com estados vazios ou de carregamento em gráficos?
Para carregar: substitua o gráfico por um esqueleto (animate-pulse bg-muted rounded-lg h-80). Para dados vazios: renderize uma mensagem de estado vazio centralizada dentro de um ResponsiveContainer. Para dados parciais: Recharts lida normalmente com dados esparsos - basta passar o que você tem e ele conecta os pontos (ou mostra lacunas para valores nulos com connectNulls={false}).
Devo usar Recharts ou Chart.js ou D3?
Painéis nativos do Recharts for React — é declarativo, combinável e se integra naturalmente ao modelo de renderização do React. Chart.js para casos de uso mais simples onde você deseja um renderizador baseado em Canvas (melhor desempenho para mais de 10.000 pontos de dados). D3 quando você precisar de tipos de gráficos personalizados e não padrão que nenhuma biblioteca cobre – espere significativamente mais código. Evite misturar várias bibliotecas de gráficos em um aplicativo.
Como posso exportar gráficos como imagens para relatórios?
Use a biblioteca html2canvas para capturar o contêiner do gráfico como uma imagem: const canvas = await html2canvas(chartRef.current); const url = canvas.toDataURL('image/png');. Como alternativa, use a saída SVG integrada do Recharts - como o Recharts renderiza SVG, você pode serializar o elemento SVG diretamente para exportação de vetor. Para relatórios em PDF, renderize gráficos no lado do servidor usando o Puppeteer ou um navegador headless.
Próximas etapas
A visualização de dados transforma métricas brutas em inteligência de negócios. Os padrões neste guia (contêineres responsivos, dicas de ferramentas personalizadas, streaming em tempo real e cores com reconhecimento de tema) fornecem a base para UIs de painel que se comunicam claramente em qualquer dispositivo e em qualquer esquema de cores.
ECOSIRE cria painéis analíticos com Recharts em 8 módulos de painel para métricas financeiras, operacionais e de desempenho - com dados ao vivo de APIs NestJS e PostgreSQL baseado em Drizzle. Para implementações de business intelligence e Power BI, explore nossos serviços do Power BI ou veja todos os nossos serviços.
Escrito por
ECOSIRE Research and Development Team
Construindo produtos digitais de nível empresarial na ECOSIRE. Compartilhando insights sobre integrações Odoo, automação de e-commerce e soluções de negócios com IA.
Artigos Relacionados
Building Financial Dashboards with Power BI
Step-by-step guide to building financial dashboards in Power BI covering data connections to accounting systems, DAX measures for KPIs, P&L visualisations, and best practices.
GoHighLevel Reporting and Analytics: Measuring What Matters
Master GoHighLevel reporting and analytics. Learn to build custom dashboards, track ROI across channels, measure funnel conversion, and make data-driven marketing decisions.
Next.js 16 App Router: Production Patterns and Pitfalls
Production-ready Next.js 16 App Router patterns: server components, caching strategies, metadata API, error boundaries, and performance pitfalls to avoid.