Diagrama de Sankey
Fluxos entre etapas em SVG puro: colunas calculadas a partir das ligações, nós proporcionais ao volume e faixas na cor da origem. Destaque do caminho e balão com valores ao passar o mouse, e tabela para leitores de tela.
Playground
Props
Geradas dos tipos TypeScript do componente; a descrição vem dos comentários /** */.
<DiagramaSankey>
Diagrama de Sankey em SVG puro: colunas calculadas pelos fluxos, nós com altura proporcional ao volume e faixas da cor da origem. Passar o mouse num nó ou numa faixa destaca o caminho e mostra os valores; uma tabela com todos os fluxos fica disponível para leitores de tela.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| nosobrigatória | NoDoSankey[] | — | |
| ligacoesobrigatória | LigacaoDoSankey[] | — | |
| rotuloobrigatória | string | — | Descrição curta do diagrama para leitores de tela ("Origem do tráfego do site"). |
| formatar | (valor: number) => string | (n) => n.toLocaleString("pt-BR") | Formata os valores dos rótulos, do balão e da tabela. |
| altura | number | 320 | |
| larguraDoNo | number | 14 | Largura das barras dos nós, em px. |
| espacoEntreNos | number | 16 | Espaço vertical entre nós da mesma coluna, em px. |
| mostrarValores | boolean | true | Mostra o valor ao lado do nome de cada nó. |
Código
stories.tsx tem os exemplos usados no preview e no playground.
"use client";
import { useLayoutEffect, useMemo, useRef, useState } from "react";
/** Um nó (etapa, origem ou destino). Sem `cor`, recebe a próxima cor de série, na ordem da lista. */
export type NoDoSankey = { id: string; rotulo?: string; cor?: string };
/** Um fluxo de `de` para `para`, com a quantidade que passa por ele. */
export type LigacaoDoSankey = { de: string; para: string; valor: number };
export type DiagramaSankeyProps = {
nos: NoDoSankey[];
ligacoes: LigacaoDoSankey[];
/** Descrição curta do diagrama para leitores de tela ("Origem do tráfego do site"). */
rotulo: string;
/** Formata os valores dos rótulos, do balão e da tabela. */
formatar?: (valor: number) => string;
altura?: number;
/** Largura das barras dos nós, em px. */
larguraDoNo?: number;
/** Espaço vertical entre nós da mesma coluna, em px. */
espacoEntreNos?: number;
/** Mostra o valor ao lado do nome de cada nó. */
mostrarValores?: boolean;
};
type NoCalculado = NoDoSankey & {
coluna: number;
valor: number;
x: number;
y: number;
h: number;
cor: string;
entrada: number;
saida: number;
};
type LigacaoCalculada = LigacaoDoSankey & { y0: number; y1: number; h: number; x0: number; x1: number; cor: string };
const MARGEM_Y = 8;
function useLargura<T extends HTMLElement>() {
const ref = useRef<T>(null);
const [largura, setLargura] = useState(640);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const observador = new ResizeObserver(([entrada]) => setLargura(entrada.contentRect.width));
observador.observe(el);
return () => observador.disconnect();
}, []);
return [ref, largura] as const;
}
/** Coluna de cada nó: o caminho mais longo desde uma origem (nós sem entrada ficam na coluna 0). */
function colunas(nos: NoDoSankey[], ligacoes: LigacaoDoSankey[]) {
const coluna = new Map(nos.map((n) => [n.id, 0]));
// Relaxa as distâncias; num grafo sem ciclos estabiliza em até n passadas
for (let i = 0; i < nos.length; i++) {
let mudou = false;
for (const l of ligacoes) {
const proxima = (coluna.get(l.de) ?? 0) + 1;
if (proxima > (coluna.get(l.para) ?? 0)) {
coluna.set(l.para, proxima);
mudou = true;
}
}
if (!mudou) break;
}
return coluna;
}
/** Faixa curva entre dois segmentos verticais, como um único caminho preenchido. */
function faixa(l: LigacaoCalculada) {
const meio = (l.x0 + l.x1) / 2;
return `M${l.x0},${l.y0} C${meio},${l.y0} ${meio},${l.y1} ${l.x1},${l.y1} L${l.x1},${l.y1 + l.h} C${meio},${l.y1 + l.h} ${meio},${l.y0 + l.h} ${l.x0},${l.y0 + l.h} Z`;
}
/**
* Diagrama de Sankey em SVG puro: colunas calculadas pelos fluxos, nós com altura proporcional ao
* volume e faixas da cor da origem. Passar o mouse num nó ou numa faixa destaca o caminho e mostra
* os valores; uma tabela com todos os fluxos fica disponível para leitores de tela.
*/
export function DiagramaSankey({
nos,
ligacoes,
rotulo,
formatar = (n) => n.toLocaleString("pt-BR"),
altura = 320,
larguraDoNo = 14,
espacoEntreNos = 16,
mostrarValores = true,
}: DiagramaSankeyProps) {
const [ref, largura] = useLargura<HTMLDivElement>();
const [foco, setFoco] = useState<{ tipo: "no"; id: string } | { tipo: "ligacao"; indice: number } | null>(null);
const [ponteiro, setPonteiro] = useState<{ x: number; y: number } | null>(null);
const desenho = useMemo(() => {
const coluna = colunas(nos, ligacoes);
const ultimaColuna = Math.max(0, ...coluna.values());
const entrada = new Map<string, number>();
const saida = new Map<string, number>();
for (const l of ligacoes) {
saida.set(l.de, (saida.get(l.de) ?? 0) + l.valor);
entrada.set(l.para, (entrada.get(l.para) ?? 0) + l.valor);
}
// Rótulos à esquerda na primeira coluna e à direita nas outras: as pontas reservam espaço para eles
// Largura estimada do texto (text-xs, ~6,5 px por caractere) do maior rótulo de cada ponta
const larguraDoRotulo = (n: NoDoSankey) =>
((n.rotulo ?? n.id).length + (mostrarValores ? formatar(Math.max(entrada.get(n.id) ?? 0, saida.get(n.id) ?? 0)).length + 1 : 0)) * 6.5 + 10;
const reservaEsquerda = Math.max(8, ...nos.filter((n) => (coluna.get(n.id) ?? 0) === 0).map(larguraDoRotulo));
const reservaDireita = Math.max(8, ...nos.filter((n) => (coluna.get(n.id) ?? 0) === ultimaColuna).map(larguraDoRotulo));
const util = Math.max(1, largura - reservaEsquerda - reservaDireita - larguraDoNo);
const xDaColuna = (c: number) => reservaEsquerda + (ultimaColuna === 0 ? 0 : (util * c) / ultimaColuna);
const porColuna = Array.from({ length: ultimaColuna + 1 }, () => [] as NoCalculado[]);
// Cores de série na ordem da lista, sem repetir: a partir da 9ª, cinza neutro
nos.forEach((n, i) => {
const c = coluna.get(n.id) ?? 0;
const valor = Math.max(entrada.get(n.id) ?? 0, saida.get(n.id) ?? 0);
porColuna[c].push({
...n,
coluna: c,
valor,
x: xDaColuna(c),
y: 0,
h: 0,
cor: n.cor ?? (i < 8 ? `var(--series-${i + 1})` : "var(--muted)"),
entrada: entrada.get(n.id) ?? 0,
saida: saida.get(n.id) ?? 0,
});
});
// Uma escala para todas as colunas: a coluna mais cheia ocupa a altura toda
const escala = Math.min(
...porColuna.map((col) => {
const total = col.reduce((s, n) => s + n.valor, 0);
return total === 0 ? Infinity : (altura - MARGEM_Y * 2 - espacoEntreNos * (col.length - 1)) / total;
}),
);
for (const col of porColuna) {
const total = col.reduce((s, n) => s + n.valor * escala, 0) + espacoEntreNos * (col.length - 1);
// Colunas mais vazias ficam centralizadas na altura
let y = (altura - total) / 2;
for (const n of col) {
n.y = y;
n.h = Math.max(1, n.valor * escala);
y += n.h + espacoEntreNos;
}
}
const porId = new Map(porColuna.flat().map((n) => [n.id, n]));
const deslocSaida = new Map<string, number>();
const deslocEntrada = new Map<string, number>();
// Saídas ordenadas pela altura do destino, entradas pela altura da origem: as faixas não se cruzam à toa
const ordenadas = ligacoes
.map((l, indice) => ({ l, indice }))
.filter(({ l }) => porId.has(l.de) && porId.has(l.para));
const calculadas: (LigacaoCalculada & { indice: number })[] = ordenadas.map(({ l, indice }) => ({
...l,
indice,
x0: porId.get(l.de)!.x + larguraDoNo,
x1: porId.get(l.para)!.x,
y0: 0,
y1: 0,
h: l.valor * escala,
cor: porId.get(l.de)!.cor,
}));
[...calculadas]
.sort((a, b) => porId.get(a.para)!.y - porId.get(b.para)!.y)
.forEach((l) => {
const d = deslocSaida.get(l.de) ?? 0;
l.y0 = porId.get(l.de)!.y + d;
deslocSaida.set(l.de, d + l.h);
});
[...calculadas]
.sort((a, b) => porId.get(a.de)!.y - porId.get(b.de)!.y)
.forEach((l) => {
const d = deslocEntrada.get(l.para) ?? 0;
l.y1 = porId.get(l.para)!.y + d;
deslocEntrada.set(l.para, d + l.h);
});
return { nos: porColuna.flat(), ligacoes: calculadas, porId, ultimaColuna };
}, [nos, ligacoes, largura, altura, larguraDoNo, espacoEntreNos, mostrarValores, formatar]);
const nomeDe = (id: string) => desenho.porId.get(id)?.rotulo ?? id;
// Destaque: a faixa sob o mouse, ou todas as que entram e saem do nó sob o mouse
const ligada = (l: LigacaoCalculada & { indice: number }) =>
foco === null || (foco.tipo === "ligacao" ? foco.indice === l.indice : l.de === foco.id || l.para === foco.id);
const noLigado = (n: NoCalculado) =>
foco === null ||
(foco.tipo === "no"
? n.id === foco.id || ligacoes.some((l) => (l.de === foco.id && l.para === n.id) || (l.para === foco.id && l.de === n.id))
: ligacoes[foco.indice].de === n.id || ligacoes[foco.indice].para === n.id);
const dica = (() => {
if (!foco) return null;
if (foco.tipo === "ligacao") {
const l = ligacoes[foco.indice];
return { titulo: `${nomeDe(l.de)} → ${nomeDe(l.para)}`, linhas: [["Fluxo", formatar(l.valor)]] };
}
const n = desenho.porId.get(foco.id)!;
const linhas: string[][] = [];
if (n.entrada) linhas.push(["Entra", formatar(n.entrada)]);
if (n.saida) linhas.push(["Sai", formatar(n.saida)]);
return { titulo: nomeDe(n.id), linhas };
})();
return (
<figure className="m-0 flex w-full flex-col gap-3">
<div ref={ref} className="relative w-full overflow-hidden">
<svg
width={largura}
height={altura}
role="img"
aria-label={`${rotulo}. A tabela a seguir lista todos os fluxos.`}
className="block select-none"
onPointerMove={(e) => {
const caixa = e.currentTarget.getBoundingClientRect();
setPonteiro({ x: e.clientX - caixa.left, y: e.clientY - caixa.top });
}}
onPointerLeave={() => {
setFoco(null);
setPonteiro(null);
}}
>
{desenho.ligacoes.map((l) => (
<path
key={l.indice}
d={faixa(l)}
fill={l.cor}
fillOpacity={foco === null ? 0.42 : ligada(l) ? 0.72 : 0.1}
className="cursor-default transition-[fill-opacity] duration-150"
onPointerEnter={() => setFoco({ tipo: "ligacao", indice: l.indice })}
/>
))}
{desenho.nos.map((n) => {
const aEsquerda = n.coluna === 0;
return (
<g
key={n.id}
opacity={noLigado(n) ? 1 : 0.35}
className="transition-opacity duration-150"
onPointerEnter={() => setFoco({ tipo: "no", id: n.id })}
>
{/* Área de toque maior que a barra */}
<rect x={n.x - 6} y={n.y} width={larguraDoNo + 12} height={n.h} fill="transparent" />
<rect x={n.x} y={n.y} width={larguraDoNo} height={n.h} rx={2} fill={n.cor} stroke="var(--page)" strokeWidth={1} />
<text
x={aEsquerda ? n.x - 6 : n.x + larguraDoNo + 6}
y={n.y + n.h / 2}
dy="0.32em"
textAnchor={aEsquerda ? "end" : "start"}
className="pointer-events-none fill-ink text-xs font-semibold"
style={{ paintOrder: "stroke", stroke: "var(--page)", strokeWidth: 3, strokeLinejoin: "round" }}
>
{n.rotulo ?? n.id}
{mostrarValores && <tspan className="fill-muted font-normal"> {formatar(n.valor)}</tspan>}
</text>
</g>
);
})}
</svg>
{dica && ponteiro && (
<div
aria-hidden="true"
className="pointer-events-none absolute z-10 min-w-36 rounded-lg border border-rule bg-page px-3 py-2 text-sm shadow-flutuante"
style={{
top: Math.min(ponteiro.y + 14, altura - 70),
...(ponteiro.x > largura * 0.6 ? { right: largura - ponteiro.x + 14 } : { left: ponteiro.x + 14 }),
}}
>
<div className="mb-1 font-semibold">{dica.titulo}</div>
{dica.linhas.map(([nome, valor]) => (
<div key={nome} className="flex justify-between gap-4">
<span className="text-ink-2">{nome}</span>
<span className="font-medium tabular-nums">{valor}</span>
</div>
))}
</div>
)}
</div>
<table className="sr-only">
<caption>{rotulo}</caption>
<thead>
<tr>
<th scope="col">De</th>
<th scope="col">Para</th>
<th scope="col">Valor</th>
</tr>
</thead>
<tbody>
{ligacoes.map((l, i) => (
<tr key={i}>
<td>{nomeDe(l.de)}</td>
<td>{nomeDe(l.para)}</td>
<td>{formatar(l.valor)}</td>
</tr>
))}
</tbody>
</table>
</figure>
);
}
import { defineStories } from "@/registry/stories";
import { DiagramaSankey } from "./DiagramaSankey";
const ORCAMENTO = {
nos: [
{ id: "receita", rotulo: "Receita" },
{ id: "pessoal", rotulo: "Pessoal" },
{ id: "infra", rotulo: "Infraestrutura" },
{ id: "marketing", rotulo: "Marketing" },
{ id: "nuvem", rotulo: "Nuvem" },
{ id: "escritorio", rotulo: "Escritório" },
],
ligacoes: [
{ de: "receita", para: "pessoal", valor: 420 },
{ de: "receita", para: "infra", valor: 260 },
{ de: "receita", para: "marketing", valor: 140 },
{ de: "infra", para: "nuvem", valor: 180 },
{ de: "infra", para: "escritorio", valor: 80 },
],
};
const TRAFEGO = {
nos: [
{ id: "busca", rotulo: "Busca" },
{ id: "social", rotulo: "Redes sociais" },
{ id: "direto", rotulo: "Direto" },
{ id: "home", rotulo: "Página inicial" },
{ id: "produto", rotulo: "Produto" },
{ id: "carrinho", rotulo: "Carrinho" },
{ id: "saiu", rotulo: "Saiu", cor: "var(--muted)" },
{ id: "compra", rotulo: "Compra" },
],
ligacoes: [
{ de: "busca", para: "home", valor: 3200 },
{ de: "busca", para: "produto", valor: 2100 },
{ de: "social", para: "home", valor: 1800 },
{ de: "direto", para: "home", valor: 1400 },
{ de: "home", para: "produto", valor: 3900 },
{ de: "home", para: "saiu", valor: 2500 },
{ de: "produto", para: "carrinho", valor: 2600 },
{ de: "produto", para: "saiu", valor: 3400 },
{ de: "carrinho", para: "compra", valor: 1700 },
{ de: "carrinho", para: "saiu", valor: 900 },
],
};
const milhares = (n: number) => n.toLocaleString("pt-BR");
export default defineStories({
args: { altura: 300, larguraDoNo: 14, espacoEntreNos: 16, mostrarValores: true },
controls: {
altura: { type: "number", min: 160, max: 600, step: 20 },
larguraDoNo: { type: "number", min: 4, max: 32 },
espacoEntreNos: { type: "number", min: 4, max: 40 },
mostrarValores: { type: "boolean" },
},
render: (a) => (
<div className="w-full max-w-2xl">
<DiagramaSankey
{...ORCAMENTO}
rotulo="Para onde vai a receita (R$ mil)"
formatar={(n) => `R$ ${n} mil`}
altura={a.altura as number}
larguraDoNo={a.larguraDoNo as number}
espacoEntreNos={a.espacoEntreNos as number}
mostrarValores={a.mostrarValores as boolean}
/>
</div>
),
stories: [
{ name: "Orçamento" },
{
name: "Funil de visitas",
render: () => (
<div className="w-full max-w-3xl">
<DiagramaSankey {...TRAFEGO} rotulo="Caminho das visitas até a compra" formatar={milhares} altura={380} />
</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);
}