Alluvial chart
How members move between categories across ordered steps. Each step stacks its categories in the same order, and ribbons carry the members from one step's category to the next's, coloured by where they came from. Hover a category to light its ribbons.
Plan moves
Accounts that signed up in Q1, by plan at each quarter's end. Most churn leaves from Free in the first quarter.
| Steps | From | To | Members |
|---|---|---|---|
| Q1 2025 → Q2 2025 | Free | Free | 1240 |
| Q1 2025 → Q2 2025 | Free | Starter | 220 |
| Q1 2025 → Q2 2025 | Free | Churned | 310 |
| Q1 2025 → Q2 2025 | Starter | Free | 40 |
| Q1 2025 → Q2 2025 | Starter | Starter | 330 |
| Q1 2025 → Q2 2025 | Starter | Pro | 150 |
| Q1 2025 → Q2 2025 | Pro | Starter | 35 |
| Q1 2025 → Q2 2025 | Pro | Pro | 235 |
| Q2 2025 → Q3 2025 | Free | Free | 860 |
| Q2 2025 → Q3 2025 | Free | Starter | 240 |
| Q2 2025 → Q3 2025 | Free | Churned | 180 |
| Q2 2025 → Q3 2025 | Starter | Starter | 425 |
| Q2 2025 → Q3 2025 | Starter | Pro | 90 |
| Q2 2025 → Q3 2025 | Starter | Churned | 70 |
| Q2 2025 → Q3 2025 | Pro | Pro | 360 |
| Q2 2025 → Q3 2025 | Pro | Churned | 25 |
| Q2 2025 → Q3 2025 | Churned | Churned | 310 |
| Q3 2025 → Q4 2025 | Free | Free | 820 |
| Q3 2025 → Q4 2025 | Free | Churned | 40 |
| Q3 2025 → Q4 2025 | Starter | Starter | 535 |
| Q3 2025 → Q4 2025 | Starter | Pro | 130 |
| Q3 2025 → Q4 2025 | Pro | Pro | 450 |
| Q3 2025 → Q4 2025 | Churned | Churned | 585 |
Usage
use hydroxy_leptos::alluvial_chart::*;
use hydroxy_leptos::chart_frame::ChartFrame;
use leptos::prelude::*;
pub fn demo() -> AnyView {
let quarters: Vec<String> = ["Q1", "Q2", "Q3", "Q4"].iter().map(|q| format!("{q} 2025")).collect();
// Accounts that signed up in Q1, by plan at the end of each quarter.
let accounts = vec![
Cohort::new(["Free", "Free", "Free", "Free"], 820.0),
Cohort::new(["Free", "Free", "Starter", "Starter"], 240.0),
Cohort::new(["Free", "Starter", "Starter", "Pro"], 130.0),
Cohort::new(["Free", "Starter", "Pro", "Pro"], 90.0),
Cohort::new(["Free", "Churned", "Churned", "Churned"], 310.0),
Cohort::new(["Free", "Free", "Churned", "Churned"], 180.0),
Cohort::new(["Starter", "Starter", "Starter", "Starter"], 260.0),
Cohort::new(["Starter", "Pro", "Pro", "Pro"], 150.0),
Cohort::new(["Starter", "Starter", "Churned", "Churned"], 70.0),
Cohort::new(["Starter", "Free", "Free", "Churned"], 40.0),
Cohort::new(["Pro", "Pro", "Pro", "Pro"], 210.0),
Cohort::new(["Pro", "Starter", "Starter", "Starter"], 35.0),
Cohort::new(["Pro", "Pro", "Churned", "Churned"], 25.0),
];
view! {
<ChartFrame title="Plan moves" description="Accounts that signed up in Q1, by plan at each quarter's end. Most churn leaves from Free in the first quarter.">
<AlluvialChart data=accounts steps=quarters label="Q1 2025 sign-ups by plan, quarter by quarter" />
</ChartFrame>
}
.into_any()
}use dioxus::prelude::*;
use hydroxy_dioxus::alluvial_chart::{AlluvialChart, Cohort};
use hydroxy_dioxus::chart_frame::ChartFrame;
#[component]
pub fn AlluvialChartDemo() -> Element {
let quarters: Vec<String> = ["Q1", "Q2", "Q3", "Q4"].iter().map(|q| format!("{q} 2025")).collect();
// Accounts that signed up in Q1, by plan at the end of each quarter.
let accounts = vec![
Cohort::new(["Free", "Free", "Free", "Free"], 820.0),
Cohort::new(["Free", "Free", "Starter", "Starter"], 240.0),
Cohort::new(["Free", "Starter", "Starter", "Pro"], 130.0),
Cohort::new(["Free", "Starter", "Pro", "Pro"], 90.0),
Cohort::new(["Free", "Churned", "Churned", "Churned"], 310.0),
Cohort::new(["Free", "Free", "Churned", "Churned"], 180.0),
Cohort::new(["Starter", "Starter", "Starter", "Starter"], 260.0),
Cohort::new(["Starter", "Pro", "Pro", "Pro"], 150.0),
Cohort::new(["Starter", "Starter", "Churned", "Churned"], 70.0),
Cohort::new(["Starter", "Free", "Free", "Churned"], 40.0),
Cohort::new(["Pro", "Pro", "Pro", "Pro"], 210.0),
Cohort::new(["Pro", "Starter", "Starter", "Starter"], 35.0),
Cohort::new(["Pro", "Pro", "Churned", "Churned"], 25.0),
];
rsx! {
ChartFrame {
title: "Plan moves",
description: "Accounts that signed up in Q1, by plan at each quarter's end. Most churn leaves from Free in the first quarter.",
AlluvialChart { data: accounts, steps: quarters, label: "Q1 2025 sign-ups by plan, quarter by quarter" }
}
}
}
Anatomy
| Component | data-part | Renders |
|---|---|---|
AlluvialChart | root | figure; `data-active` while a category or ribbon is picked |
| step | A step's name above its column |
| flow | The ribbons and nodes, revealed left to right |
| node | A category at one step; `data-highlighted` when lit, `data-selected` when picked |
| ribbon | Members moving between two categories; `data-highlighted` when lit |
| node-label | Category name and count, beside nodes tall enough for them |
| tooltip | Members and shares for the picked category or ribbon |
| sr-table | Every ribbon with its steps, categories and count, for screen readers and CSV export |
Keyboard
| Key | Does |
|---|---|
Arrow keys | Step through the categories, then the ribbons |
Home / End | First or last mark |
Escape | Clear the picked mark |
Props
| Prop | Type | Notes |
|---|---|---|
data | Vec<Cohort> | Required. Members sharing a path, one category per step |
steps | Vec<String> | Required. Step names in order |
label | String | Required. What the chart shows; the accessible name |
width | u16 | Drawing width in px. Default 720 |
height | u16 | Drawing height in px. Default 360 |
format | Fn(f64) -> String | Formats counts in labels, the tooltip and the table |
Events
| Prop | Type | Notes |
|---|---|---|
on_hover | Callback<Option<Datum>> | The mark under the pointer or keyboard focus changes, or is left. Fires only on a change |
on_select | Callback<Datum> | Click, tap or Enter on a mark |