Diagrama Aluvial
Como uma população se redistribui entre etapas, em SVG puro: categorias empilhadas em cada etapa e faixas ligando os caminhos. Destaque em cor do caminho ao passar o mouse, balão com valores e tabela para leitores de tela.
Playground
Props
Geradas dos tipos TypeScript do componente; a descrição vem dos comentários /** */.
<DiagramaAluvial>
Diagrama aluvial em SVG puro: em cada etapa, as categorias viram barras empilhadas, e cada grupo de itens vira uma faixa que liga as categorias por onde passa. Mostra como uma população se redistribui entre etapas (migração de planos, respostas que mudam, funis com várias dimensões). Passar o mouse numa barra ou faixa colore os caminhos que passam por ali e mostra os valores.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| etapasobrigatória | string[] | — | Nome de cada coluna (etapa no tempo ou dimensão), da esquerda para a direita. |
| fluxosobrigatória | FluxoAluvial[] | — | |
| rotuloobrigatória | string | — | Descrição curta para leitores de tela ("Migração de planos entre trimestres"). |
| cores | Record<string, string> | — | Cor de cada categoria pelo nome. Sem isso, as categorias recebem as cores de série na ordem em que aparecem. |
| formatar | (valor: number) => string | (n) => n.toLocaleString("pt-BR") | |
| altura | number | 300 | |
| colorirFaixas | boolean | false | Faixas sempre coloridas pela categoria da primeira etapa (padrão: cinza, colorindo só no destaque). |
Código
stories.tsx tem os exemplos usados no preview e no playground.
"use client";
import { useLayoutEffect, useMemo, useRef, useState } from "react";
/**
* Um grupo de itens que segue o mesmo caminho: uma categoria em cada etapa, na ordem das etapas.
* Ex.: `{ caminho: ["Plano grátis", "Plano pro", "Ativo"], valor: 120 }`.
*/
export type FluxoAluvial = { caminho: string[]; valor: number };
export type DiagramaAluvialProps = {
/** Nome de cada coluna (etapa no tempo ou dimensão), da esquerda para a direita. */
etapas: string[];
fluxos: FluxoAluvial[];
/** Descrição curta para leitores de tela ("Migração de planos entre trimestres"). */
rotulo: string;
/** Cor de cada categoria pelo nome. Sem isso, as categorias recebem as cores de série na ordem em que aparecem. */
cores?: Record<string, string>;
formatar?: (valor: number) => string;
altura?: number;
/** Faixas sempre coloridas pela categoria da primeira etapa (padrão: cinza, colorindo só no destaque). */
colorirFaixas?: boolean;
};
const LARGURA_DA_BARRA = 14;
const ESPACO = 12;
const TOPO = 4;
const BASE = 28;
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;
}
type Bloco = { etapa: number; nome: string; valor: number; y: number; h: number; cor: string };
/**
* Diagrama aluvial em SVG puro: em cada etapa, as categorias viram barras empilhadas, e cada grupo
* de itens vira uma faixa que liga as categorias por onde passa. Mostra como uma população se
* redistribui entre etapas (migração de planos, respostas que mudam, funis com várias dimensões).
* Passar o mouse numa barra ou faixa colore os caminhos que passam por ali e mostra os valores.
*/
export function DiagramaAluvial({
etapas,
fluxos,
rotulo,
cores,
formatar = (n) => n.toLocaleString("pt-BR"),
altura = 300,
colorirFaixas = false,
}: DiagramaAluvialProps) {
const [ref, largura] = useLargura<HTMLDivElement>();
const [foco, setFoco] = useState<{ etapa: number; nome: string } | { fluxo: number } | null>(null);
const [ponteiro, setPonteiro] = useState<{ x: number; y: number } | null>(null);
const desenho = useMemo(() => {
// Cor por nome de categoria, na ordem em que os nomes aparecem (a 9ª em diante fica neutra)
const corDe = new Map<string, string>();
for (const f of fluxos)
for (const nome of f.caminho)
if (!corDe.has(nome)) corDe.set(nome, cores?.[nome] ?? (corDe.size < 8 ? `var(--series-${corDe.size + 1})` : "var(--muted)"));
const total = fluxos.reduce((s, f) => s + f.valor, 0);
// A etapa com mais categorias define a escala, já que cada uma tem o mesmo total
const maisCategorias = Math.max(1, ...etapas.map((_, e) => new Set(fluxos.map((f) => f.caminho[e])).size));
const escala = total === 0 ? 0 : (altura - TOPO - BASE - ESPACO * (maisCategorias - 1)) / total;
// Rótulos à direita das barras; a última etapa precisa de espaço para eles
const reservaDireita = 110;
const xDaEtapa = (e: number) => (etapas.length <= 1 ? 0 : ((largura - reservaDireita - LARGURA_DA_BARRA) * e) / (etapas.length - 1));
const blocos: Bloco[][] = etapas.map((_, e) => {
const ordem: string[] = [];
const soma = new Map<string, number>();
for (const f of fluxos) {
const nome = f.caminho[e];
if (!soma.has(nome)) ordem.push(nome);
soma.set(nome, (soma.get(nome) ?? 0) + f.valor);
}
let y = TOPO;
return ordem.map((nome) => {
const h = (soma.get(nome) ?? 0) * escala;
const bloco = { etapa: e, nome, valor: soma.get(nome) ?? 0, y, h, cor: corDe.get(nome)! };
y += h + ESPACO;
return bloco;
});
});
const blocoDe = (e: number, nome: string) => blocos[e].find((b) => b.nome === nome)!;
const posicao = (e: number, nome: string) => blocos[e].indexOf(blocoDe(e, nome));
// Entre cada par de etapas, cada fluxo ganha uma fatia da barra de saída e da de chegada.
// Saídas ordenadas pela categoria de destino, chegadas pela de origem: menos cruzamentos.
const faixas = etapas.slice(0, -1).flatMap((_, e) => {
const lista = fluxos.map((f, indice) => ({ f, indice }));
const y0 = new Map<number, number>();
const y1 = new Map<number, number>();
const usadoSaida = new Map<string, number>();
const usadoChegada = new Map<string, number>();
[...lista]
.sort((a, b) => posicao(e + 1, a.f.caminho[e + 1]) - posicao(e + 1, b.f.caminho[e + 1]) || posicao(0, a.f.caminho[0]) - posicao(0, b.f.caminho[0]))
.forEach(({ f, indice }) => {
const b = blocoDe(e, f.caminho[e]);
const u = usadoSaida.get(b.nome) ?? 0;
y0.set(indice, b.y + u);
usadoSaida.set(b.nome, u + f.valor * escala);
});
[...lista]
.sort((a, b) => posicao(e, a.f.caminho[e]) - posicao(e, b.f.caminho[e]) || posicao(0, a.f.caminho[0]) - posicao(0, b.f.caminho[0]))
.forEach(({ f, indice }) => {
const b = blocoDe(e + 1, f.caminho[e + 1]);
const u = usadoChegada.get(b.nome) ?? 0;
y1.set(indice, b.y + u);
usadoChegada.set(b.nome, u + f.valor * escala);
});
return lista.map(({ f, indice }) => {
const x0 = xDaEtapa(e) + LARGURA_DA_BARRA;
const x1 = xDaEtapa(e + 1);
const h = f.valor * escala;
const a = y0.get(indice)!;
const b = y1.get(indice)!;
const m = (x0 + x1) / 2;
return {
chave: `${e}-${indice}`,
indice,
d: `M${x0},${a} C${m},${a} ${m},${b} ${x1},${b} L${x1},${b + h} C${m},${b + h} ${m},${a + h} ${x0},${a + h} Z`,
cor: corDe.get(f.caminho[0])!,
};
});
});
return { blocos, faixas, xDaEtapa, total };
}, [etapas, fluxos, cores, largura, altura]);
const passaPor = (indice: number) =>
foco === null || ("fluxo" in foco ? foco.fluxo === indice : fluxos[indice].caminho[foco.etapa] === foco.nome);
const dica = (() => {
if (!foco) return null;
if ("fluxo" in foco) {
const f = fluxos[foco.fluxo];
return { titulo: f.caminho.join(" → "), linhas: [["Itens", formatar(f.valor)]] };
}
const bloco = desenho.blocos[foco.etapa].find((b) => b.nome === foco.nome)!;
const parte = desenho.total ? Math.round((bloco.valor / desenho.total) * 100) : 0;
return { titulo: `${etapas[foco.etapa]}: ${foco.nome}`, linhas: [["Itens", formatar(bloco.valor)], ["Do total", `${parte}%`]] };
})();
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 cada caminho.`}
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.faixas.map((fx) => {
const ativa = foco !== null && passaPor(fx.indice);
return (
<path
key={fx.chave}
d={fx.d}
fill={ativa || colorirFaixas ? fx.cor : "var(--ink)"}
fillOpacity={foco === null ? (colorirFaixas ? 0.4 : 0.12) : ativa ? 0.6 : 0.06}
stroke="var(--page)"
strokeWidth={0.5}
className="transition-[fill-opacity] duration-150"
onPointerEnter={() => setFoco({ fluxo: fx.indice })}
/>
);
})}
{desenho.blocos.flat().map((b) => {
const x = desenho.xDaEtapa(b.etapa);
const apagado = foco !== null && !fluxos.some((f, i) => f.caminho[b.etapa] === b.nome && passaPor(i));
return (
<g
key={`${b.etapa}-${b.nome}`}
opacity={apagado ? 0.35 : 1}
className="transition-opacity duration-150"
onPointerEnter={() => setFoco({ etapa: b.etapa, nome: b.nome })}
>
<rect x={x - 6} y={b.y} width={LARGURA_DA_BARRA + 12} height={b.h} fill="transparent" />
<rect x={x} y={b.y} width={LARGURA_DA_BARRA} height={Math.max(1, b.h)} rx={2} fill={b.cor} />
{b.h >= 14 && (
<text
x={x + LARGURA_DA_BARRA + 6}
y={b.y + b.h / 2}
dy="0.32em"
className="pointer-events-none fill-ink text-xs font-semibold"
style={{ paintOrder: "stroke", stroke: "var(--page)", strokeWidth: 3, strokeLinejoin: "round" }}
>
{b.nome}
</text>
)}
</g>
);
})}
{etapas.map((nome, e) => (
// A primeira etapa alinha o nome pela esquerda, para não sair da área
<text
key={nome}
x={e === 0 ? 0 : desenho.xDaEtapa(e) + LARGURA_DA_BARRA / 2}
y={altura - 8}
textAnchor={e === 0 ? "start" : "middle"}
className="fill-muted text-xs font-semibold"
>
{nome}
</text>
))}
</svg>
{dica && ponteiro && (
<div
aria-hidden="true"
className="pointer-events-none absolute z-10 min-w-40 rounded-lg border border-rule bg-page px-3 py-2 text-sm shadow-flutuante"
style={{
top: Math.min(ponteiro.y + 14, altura - 80),
...(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>
{etapas.map((e) => (
<th key={e} scope="col">
{e}
</th>
))}
<th scope="col">Itens</th>
</tr>
</thead>
<tbody>
{fluxos.map((f, i) => (
<tr key={i}>
{f.caminho.map((c, j) => (
<td key={j}>{c}</td>
))}
<td>{formatar(f.valor)}</td>
</tr>
))}
</tbody>
</table>
</figure>
);
}
import { defineStories } from "@/registry/stories";
import { DiagramaAluvial } from "./DiagramaAluvial";
const PLANOS = {
etapas: ["Janeiro", "Abril", "Julho"],
fluxos: [
{ caminho: ["Grátis", "Grátis", "Grátis"], valor: 380 },
{ caminho: ["Grátis", "Pro", "Pro"], valor: 140 },
{ caminho: ["Grátis", "Pro", "Cancelou"], valor: 40 },
{ caminho: ["Grátis", "Cancelou", "Cancelou"], valor: 120 },
{ caminho: ["Pro", "Pro", "Pro"], valor: 210 },
{ caminho: ["Pro", "Empresa", "Empresa"], valor: 60 },
{ caminho: ["Pro", "Grátis", "Grátis"], valor: 50 },
{ caminho: ["Pro", "Pro", "Cancelou"], valor: 30 },
{ caminho: ["Empresa", "Empresa", "Empresa"], valor: 90 },
],
};
const PESQUISA = {
etapas: ["Idade", "Canal", "Avaliação"],
fluxos: [
{ caminho: ["18–29", "App", "Positiva"], valor: 210 },
{ caminho: ["18–29", "App", "Negativa"], valor: 60 },
{ caminho: ["18–29", "Site", "Positiva"], valor: 70 },
{ caminho: ["30–49", "App", "Positiva"], valor: 150 },
{ caminho: ["30–49", "Site", "Positiva"], valor: 180 },
{ caminho: ["30–49", "Site", "Negativa"], valor: 90 },
{ caminho: ["50+", "Site", "Positiva"], valor: 110 },
{ caminho: ["50+", "Loja", "Positiva"], valor: 130 },
{ caminho: ["50+", "Loja", "Negativa"], valor: 40 },
],
};
export default defineStories({
args: { altura: 300, colorirFaixas: false },
controls: {
altura: { type: "number", min: 180, max: 600, step: 20 },
colorirFaixas: { type: "boolean" },
},
render: (a) => (
<div className="w-full max-w-2xl">
<DiagramaAluvial
{...PLANOS}
rotulo="Migração de clientes entre planos"
formatar={(n) => `${n} clientes`}
altura={a.altura as number}
colorirFaixas={a.colorirFaixas as boolean}
cores={{ Cancelou: "var(--muted)" }}
/>
</div>
),
stories: [
{ name: "Migração de planos" },
{
name: "Faixas coloridas",
render: () => (
<div className="w-full max-w-2xl">
<DiagramaAluvial {...PESQUISA} rotulo="Respostas da pesquisa por idade e canal" altura={320} colorirFaixas />
</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);
}