Mapa de Calor
Grade de linhas × colunas (como dias da semana × horas) com a cor de cada célula pela intensidade do valor, balão com o valor ao passar o mouse ou focar, legenda do menor ao maior e células sem dado. Navegável pelas setas do teclado.
Playground
Props
Geradas dos tipos TypeScript do componente; a descrição vem dos comentários /** */.
<MapaDeCalor>
Mapa de calor em grade (linhas × colunas, como dias da semana × horas): a cor de cada célula cresce com o valor. Mostra o valor num balão ao passar o mouse ou focar, tem legenda do menor ao maior e trata células sem dado. Pelo teclado, a grade é um só ponto de Tab: as setas andam entre as células (Home/End vão ao começo/fim da linha) e o leitor de tela lê linha, coluna e valor.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| linhasobrigatória | string[] | — | Rótulos das linhas, de cima para baixo ("Seg", "Ter"...). |
| colunasobrigatória | string[] | — | Rótulos das colunas, da esquerda para a direita ("0h", "1h"...). |
| valoresobrigatória | (number | null)[][] | — | Uma lista por linha, um valor por coluna. `null` é célula sem dado (diferente de zero). |
| rotuloAcessivelobrigatória | string | — | Nome da grade para leitores de tela ("Acessos por dia da semana e hora"). |
| formatarValor | (valor: number) => string | formatarNumero | Formatação do valor no balão e na legenda ("12 acessos", "R$ 1,2 mil"). |
| cor | string | "var(--accent)" | Cor da intensidade máxima. |
| minimo | number | — | Início da escala de cor. Padrão: o menor valor ou zero, o que for menor. |
| maximo | number | — | Fim da escala de cor. Padrão: o maior valor. |
| rotuloDeColunaACada | number | 1 | Escreve o rótulo de uma coluna a cada N (ex.: 3 para horas: 0h, 3h, 6h...). Todas continuam no balão e no leitor de tela. |
| textoSemValor | string | "Sem dados" | |
| legenda | boolean | true | Mostra a legenda de cores embaixo da grade. |
| instrucao | string | "Use as setas para percorrer as células." | Dica de uso do teclado lida por leitores de tela ao entrar na grade. |
Código
stories.tsx tem os exemplos usados no preview e no playground.
"use client";
import { useId, useRef, useState, type CSSProperties, type KeyboardEvent } from "react";
const formatarNumero = (v: number) => v.toLocaleString("pt-BR", { maximumFractionDigits: 1 });
/** Fundo de uma célula: mistura a cor com o trilho conforme a intensidade (0 a 1). */
function corDaCelula(cor: string, intensidade: number) {
return `color-mix(in oklab, ${cor} ${Math.round(intensidade * 100)}%, var(--track))`;
}
type Ativa = { linha: number; coluna: number; x: number; y: number; alinhamento: "inicio" | "meio" | "fim" };
/**
* Mapa de calor em grade (linhas × colunas, como dias da semana × horas): a cor
* de cada célula cresce com o valor. Mostra o valor num balão ao passar o mouse
* ou focar, tem legenda do menor ao maior e trata células sem dado.
*
* Pelo teclado, a grade é um só ponto de Tab: as setas andam entre as células
* (Home/End vão ao começo/fim da linha) e o leitor de tela lê linha, coluna e valor.
*/
export function MapaDeCalor({
linhas,
colunas,
valores,
rotuloAcessivel,
formatarValor = formatarNumero,
cor = "var(--accent)",
minimo,
maximo,
rotuloDeColunaACada = 1,
textoSemValor = "Sem dados",
legenda = true,
instrucao = "Use as setas para percorrer as células.",
}: {
/** Rótulos das linhas, de cima para baixo ("Seg", "Ter"...). */
linhas: string[];
/** Rótulos das colunas, da esquerda para a direita ("0h", "1h"...). */
colunas: string[];
/** Uma lista por linha, um valor por coluna. `null` é célula sem dado (diferente de zero). */
valores: (number | null)[][];
/** Nome da grade para leitores de tela ("Acessos por dia da semana e hora"). */
rotuloAcessivel: string;
/** Formatação do valor no balão e na legenda ("12 acessos", "R$ 1,2 mil"). */
formatarValor?: (valor: number) => string;
/** Cor da intensidade máxima. */
cor?: string;
/** Início da escala de cor. Padrão: o menor valor ou zero, o que for menor. */
minimo?: number;
/** Fim da escala de cor. Padrão: o maior valor. */
maximo?: number;
/** Escreve o rótulo de uma coluna a cada N (ex.: 3 para horas: 0h, 3h, 6h...). Todas continuam no balão e no leitor de tela. */
rotuloDeColunaACada?: number;
textoSemValor?: string;
/** Mostra a legenda de cores embaixo da grade. */
legenda?: boolean;
/** Dica de uso do teclado lida por leitores de tela ao entrar na grade. */
instrucao?: string;
}) {
const idInstrucao = useId();
const raiz = useRef<HTMLDivElement>(null);
const [focoGuardado, setFoco] = useState({ linha: 0, coluna: 0 });
// Se a grade encolher, a célula com Tab volta para dentro dela.
const foco = {
linha: Math.max(0, Math.min(focoGuardado.linha, linhas.length - 1)),
coluna: Math.max(0, Math.min(focoGuardado.coluna, colunas.length - 1)),
};
const [ativa, setAtiva] = useState<Ativa | null>(null);
const numeros = valores.flat().filter((v): v is number => v !== null && Number.isFinite(v));
const temValor = numeros.length > 0;
const inicio = minimo ?? Math.min(0, ...numeros);
const fim = maximo ?? (temValor ? Math.max(...numeros) : 1);
const intensidade = (v: number) => (fim === inicio ? 1 : Math.min(1, Math.max(0, (v - inicio) / (fim - inicio))));
const temVazio = linhas.some((_, l) => colunas.some((_, c) => (valores[l]?.[c] ?? null) === null));
const valorEm = (l: number, c: number) => valores[l]?.[c] ?? null;
const textoDoValor = (v: number | null) => (v === null ? textoSemValor : formatarValor(v));
function mostrar(linha: number, coluna: number, celula: HTMLElement) {
const caixa = raiz.current?.getBoundingClientRect();
if (!caixa) return;
const r = celula.getBoundingClientRect();
const x = r.left + r.width / 2 - caixa.left;
// Perto das bordas o balão se alinha à célula em vez de centralizar, para não sair da área.
const alinhamento = x < caixa.width * 0.2 ? "inicio" : x > caixa.width * 0.8 ? "fim" : "meio";
setAtiva({
linha,
coluna,
x: alinhamento === "inicio" ? r.left - caixa.left : alinhamento === "fim" ? r.right - caixa.left : x,
y: r.top - caixa.top,
alinhamento,
});
}
function aoTeclar(e: KeyboardEvent<HTMLTableElement>) {
const passos: Record<string, [number, number]> = {
ArrowUp: [-1, 0],
ArrowDown: [1, 0],
ArrowLeft: [0, -1],
ArrowRight: [0, 1],
};
let { linha, coluna } = foco;
if (passos[e.key]) {
linha = Math.min(linhas.length - 1, Math.max(0, linha + passos[e.key][0]));
coluna = Math.min(colunas.length - 1, Math.max(0, coluna + passos[e.key][1]));
} else if (e.key === "Home") {
coluna = 0;
if (e.ctrlKey) linha = 0;
} else if (e.key === "End") {
coluna = colunas.length - 1;
if (e.ctrlKey) linha = linhas.length - 1;
} else if (e.key === "Escape") {
setAtiva(null);
return;
} else {
return;
}
e.preventDefault();
setFoco({ linha, coluna });
const celula = e.currentTarget.querySelector<HTMLElement>(`[data-celula="${linha}-${coluna}"]`);
celula?.focus();
}
const posicaoDoBalao: CSSProperties | undefined = ativa
? {
left: ativa.x,
top: ativa.y - 6,
transform: `translate(${ativa.alinhamento === "inicio" ? "0" : ativa.alinhamento === "fim" ? "-100%" : "-50%"}, -100%)`,
}
: undefined;
return (
<div ref={raiz} className="relative flex flex-col gap-3">
<div className="overflow-x-auto">
<table
role="grid"
aria-label={rotuloAcessivel}
aria-describedby={idInstrucao}
onKeyDown={aoTeclar}
onPointerLeave={() => setAtiva(null)}
style={{ minWidth: `${colunas.length * 1.25 + 3}rem` }}
className="w-full border-separate border-spacing-0.5"
>
<thead>
<tr>
<td />
{colunas.map((c, i) => (
<th key={`${c}-${i}`} scope="col" className="p-0 pb-1 text-center text-xs font-normal text-muted">
{i % rotuloDeColunaACada === 0 ? (
<span className="whitespace-nowrap">{c}</span>
) : (
<span className="sr-only">{c}</span>
)}
</th>
))}
</tr>
</thead>
<tbody>
{linhas.map((rotuloLinha, l) => (
<tr key={`${rotuloLinha}-${l}`}>
<th scope="row" className="w-px p-0 pr-2 text-right text-xs font-normal whitespace-nowrap text-muted">
{rotuloLinha}
</th>
{colunas.map((rotuloColuna, c) => {
const v = valorEm(l, c);
const focavel = foco.linha === l && foco.coluna === c;
return (
<td
key={`${rotuloColuna}-${c}`}
data-celula={`${l}-${c}`}
tabIndex={focavel ? 0 : -1}
onPointerEnter={(e) => mostrar(l, c, e.currentTarget)}
onFocus={(e) => {
setFoco({ linha: l, coluna: c });
mostrar(l, c, e.currentTarget);
}}
onBlur={() => setAtiva(null)}
className="h-6 rounded-sm p-0 outline-accent focus-visible:outline-2 focus-visible:outline-offset-1"
style={
v === null
? { boxShadow: "inset 0 0 0 1px var(--rule)" }
: { background: corDaCelula(cor, intensidade(v)) }
}
>
<span className="sr-only">{textoDoValor(v)}</span>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
<p id={idInstrucao} className="sr-only">
{instrucao}
</p>
{legenda && temValor && (
<div className="flex flex-wrap items-center justify-end gap-x-4 gap-y-2 text-xs text-muted">
<span className="flex items-center gap-1.5">
<span className="tabular-nums">{formatarValor(inicio)}</span>
<span aria-hidden="true" className="flex gap-0.5">
{[0, 0.25, 0.5, 0.75, 1].map((p) => (
<span key={p} className="size-3.5 rounded-sm" style={{ background: corDaCelula(cor, p) }} />
))}
</span>
<span className="tabular-nums">{formatarValor(fim)}</span>
</span>
{temVazio && (
<span className="flex items-center gap-1.5">
<span aria-hidden="true" className="size-3.5 rounded-sm" style={{ boxShadow: "inset 0 0 0 1px var(--rule)" }} />
{textoSemValor}
</span>
)}
</div>
)}
{ativa && (
<div
aria-hidden="true"
style={posicaoDoBalao}
className="pointer-events-none absolute z-10 rounded-lg border border-rule bg-page px-3 py-2 text-sm whitespace-nowrap shadow-flutuante"
>
<div className="text-xs text-muted">
{linhas[ativa.linha]} · {colunas[ativa.coluna]}
</div>
<div className="font-semibold tabular-nums">{textoDoValor(valorEm(ativa.linha, ativa.coluna))}</div>
</div>
)}
</div>
);
}
import { defineStories } from "@/registry/stories";
import { MapaDeCalor } from "./MapaDeCalor";
const DIAS = ["Seg", "Ter", "Qua", "Qui", "Sex", "Sáb", "Dom"];
const HORAS = Array.from({ length: 24 }, (_, h) => `${h}h`);
/** Acessos de exemplo: pico no horário comercial, pouco no fim de semana. Determinístico. */
function acessos(comVazios: boolean) {
return DIAS.map((_, d) =>
HORAS.map((_, h) => {
if (comVazios && d === 2 && h >= 9 && h <= 11) return null; // sistema fora do ar
const comercial = h >= 8 && h <= 18 ? 1 : h >= 6 && h <= 22 ? 0.35 : 0.05;
const almoco = h === 12 || h === 13 ? 0.6 : 1;
const fimDeSemana = d >= 5 ? 0.25 : 1;
const ruido = ((d * 31 + h * 17) % 11) / 11;
return Math.round(60 * comercial * almoco * fimDeSemana * (0.75 + ruido * 0.5));
}),
);
}
const MESES = ["Jan", "Fev", "Mar", "Abr", "Mai", "Jun", "Jul", "Ago", "Set", "Out", "Nov", "Dez"];
const LOJAS = ["Centro", "Shopping Norte", "Aeroporto", "Online"];
const VENDAS = LOJAS.map((_, l) => MESES.map((_, m) => 40 + ((l * 13 + m * 7) % 23) * 3 + (m >= 10 ? 30 : 0) + (l === 3 ? 25 : 0)));
const formatoAcessos = (v: number) => `${v.toLocaleString("pt-BR")} ${v === 1 ? "acesso" : "acessos"}`;
export default defineStories({
args: { rotuloACada: 3, comVazios: false, legenda: true, cor: "accent" },
controls: {
rotuloACada: { type: "number", label: "rotuloDeColunaACada", min: 1, max: 6 },
comVazios: { type: "boolean", label: "células sem dado (null)" },
legenda: { type: "boolean" },
cor: { type: "select", options: ["accent", "series-2", "series-3", "series-4"] },
},
render: (a) => (
<div className="w-full max-w-4xl">
<MapaDeCalor
linhas={DIAS}
colunas={HORAS}
valores={acessos(a.comVazios)}
rotuloAcessivel="Acessos por dia da semana e hora"
formatarValor={formatoAcessos}
rotuloDeColunaACada={a.rotuloACada}
legenda={a.legenda}
cor={`var(--${a.cor})`}
/>
</div>
),
stories: [
{ name: "Dia × hora" },
{ name: "Com células sem dado", args: { comVazios: true } },
{
name: "Lojas × meses",
render: () => (
<div className="w-full max-w-3xl">
<MapaDeCalor
linhas={LOJAS}
colunas={MESES}
valores={VENDAS}
rotuloAcessivel="Vendas por loja e mês, em milhares de reais"
formatarValor={(v) => `R$ ${v} mil`}
cor="var(--series-2)"
minimo={30}
/>
</div>
),
},
],
});
Para usar em outro projeto, copie também
Os componentes de que este depende e o tema. O CSS do tema é um arquivo só: importe depois do Tailwind.
Tema escolhido no cabeçalho (com a ponte para o Tailwind)
Ver o CSS do tema
/* Tema "Editorial", gerado no Component Vault.
Importe depois de @import "tailwindcss". Carregue as fontes "Archivo" e "Inter" no projeto. */
@theme inline {
--color-page: var(--page);
--color-surface: var(--surface);
--color-track: var(--track);
--color-grid: var(--grid);
--color-ink: var(--ink);
--color-ink-2: var(--ink-2);
--color-muted: var(--muted);
--color-rule: var(--rule);
--color-rule-strong: var(--rule-strong);
--color-axis: var(--axis);
--color-accent: var(--accent);
--color-success: var(--success);
--color-warning: var(--warning);
--color-critical: var(--critical);
--font-display: var(--fonte-titulo);
--shadow-flutuante: var(--sombra-flutuante);
}
:root {
color-scheme: light dark;
--page: light-dark(#ffffff, #0f0f0e);
--surface: light-dark(#f5f5f5, #1a1a19);
--track: light-dark(#f0f0f0, #262624);
--grid: light-dark(#ededed, #232321);
--ink: light-dark(#111111, #f0efec);
--ink-2: light-dark(#4a4a4a, #c3c2b7);
--muted: light-dark(#666666, #a3a29a);
--rule: light-dark(#e3e3e3, #2c2c2a);
--rule-strong: light-dark(#111111, #f0efec);
--axis: light-dark(#9a9a9a, #6b6a64);
--accent: light-dark(#1a56db, #7aa2f7);
--success: light-dark(#13865c, #3fc48f);
--warning: #fab219;
--critical: #d03b3b;
--series-1: light-dark(#2a78d6, #3987e5);
--series-2: light-dark(#eb6834, #d95926);
--series-3: light-dark(#1baf7a, #199e70);
--series-4: light-dark(#eda100, #c98500);
--series-5: light-dark(#e87ba4, #d55181);
--series-6: #008300;
--series-7: light-dark(#6250d6, #9085e9);
--series-8: light-dark(#e34948, #e66767);
--fonte-titulo: "Archivo", system-ui, -apple-system, "Segoe UI", sans-serif;
--fonte-texto: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
--spacing: 0.25rem;
--text-xs: 0.8rem;
--text-sm: 0.8944rem;
--text-base: 1rem;
--text-lg: 1.118rem;
--text-xl: 1.25rem;
--text-2xl: 1.5625rem;
--text-3xl: 1.9531rem;
--text-4xl: 2.4414rem;
--text-5xl: 3.0518rem;
--text-6xl: 3.8147rem;
--text-7xl: 4.7684rem;
--radius-xs: 2px;
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-2xl: 16px;
--radius-3xl: 24px;
--radius-4xl: 32px;
--sombra-flutuante: 0 8px 24px -6px rgb(0 0 0 / 0.14), 0 2px 6px -2px rgb(0 0 0 / 0.08);
color: var(--ink);
font-family: var(--fonte-texto);
}
Na vitrine, a ponte com o Tailwind fica em src/themes/tokens.css:
/* Ponte entre os tokens do tema e o Tailwind.
Os componentes da biblioteca usam classes comuns do Tailwind (p-5, text-sm,
rounded-xl...) e as cores semânticas abaixo (bg-page, text-ink...). Dentro de
[data-theme="vault"], o tema redefine as variáveis que essas classes leem:
--spacing, --text-*, --radius-* e as cores. Os valores vêm do tema escolhido
na vitrine (src/themes/presets.ts) e são gerados por themeCss(). */
@theme inline {
--color-page: var(--page);
--color-surface: var(--surface);
--color-ink: var(--ink);
--color-ink-2: var(--ink-2);
--color-muted: var(--muted);
--color-rule: var(--rule);
--color-rule-strong: var(--rule-strong);
--color-track: var(--track);
--color-grid: var(--grid);
--color-axis: var(--axis);
--color-accent: var(--accent);
--color-success: var(--success);
--color-warning: var(--warning);
--color-critical: var(--critical);
--font-display: var(--fonte-titulo);
--shadow-flutuante: var(--sombra-flutuante);
}