How to Build a Sleek 3-Stage Sankey Flow Chart in Power BI (Using Deneb & Vega)

Tired of standard Power BI bar charts that fail to show how your data actually flows? 😴 Whether you are analyzing spend allocation, product profitability, or customer journeys, a multi-level Sankey Flow Chart is the ultimate secret weapon for executive-level storytelling.

In this quick tutorial, you’ll learn how to create a highly responsive, publication-ready Product –>Category –> Total Spend flow chart in Power BI using the free Deneb custom visual.

Best part? It automatically ranks your top items, bundles the rest into a clean „Other” category, and fits seamlessly into any modern corporate dashboard! 💎

Sankey Flow Chart

🛠️ What You Need Sankey Flow Chart

To follow along, you only need:

  • Power BI Desktop
  • The Deneb visual (Free from Microsoft AppSource)
  • 3 data fields: Product, Category, and a Value Measure (e.g., Sales, Total Spend, or COGS)

🚀 Step-by-Step Setup Guide (It Takes < 2 Minutes!)

Step 1: Import the Deneb Visual 🛍️

  1. Open your Power BI report.
  2. In the Visualizations pane, click the three dots (...) and choose Get more visuals.
  3. Search for Deneb (declarative visualization spec built on Vega/Vega-Lite) and click Add.

Step 2: Add Your Data Fields 📥

Drag the Deneb visual onto your canvas and drop three fields into the Values field bucket:

  1. Product field (e.g., Product Name, Product, or Produkt)
  2. Category field (e.g., Category, Kategoria, or Group)
  3. Value / Numeric measure (e.g., Total COGS, Value, Sales, or Amount)

💡 Smart Feature: The code includes built-in auto-detection for common English and Polish column names, so you don’t even need to rename your model fields!

Step 3: Paste the Vega Code 🪄

  1. Hover over the visual and click the top-right menu icon –> Edit.
  2. When prompted to select a specification framework:
    • Select Vega (⚠️ Important: Choose Vega, not Vega-Lite!).
    • Pick [empty template].
  3. Copy the full JSON code below and paste it into the code editor on the left.
  4. Click Apply Changes (or press Ctrl + Enter).
{
  "$schema": "https://vega.github.io/schema/vega/v5.json",
  "width": 850,
  "height": 520,
  "padding": {"top": 20, "left": 15, "right": 20, "bottom": 20},

  "signals": [
    {"name": "topN", "value": 5},
    {"name": "leftX", "value": 240},
    {"name": "midX", "value": 480},
    {"name": "rightX", "value": 660},
    {"name": "topMargin", "value": 45},
    {"name": "pGap", "value": 8},
    {"name": "cGap", "value": 30},
    {"name": "targetY", "value": 175}
  ],

  "data": [
    {
      "name": "dataset"
    },
    {
      "name": "raw_data",
      "source": "dataset",
      "transform": [
        {
          "type": "formula",
          "expr": "datum.Product != null ? datum.Product : datum['Product Name'] != null ? datum['Product Name'] : datum.Produkt != null ? datum.Produkt : 'Product'",
          "as": "p_raw"
        },
        {
          "type": "formula",
          "expr": "datum.Category != null ? datum.Category : datum.Kategoria != null ? datum.Kategoria : 'Category'",
          "as": "c_raw"
        },
        {
          "type": "formula",
          "expr": "toNumber(datum.Value != null ? datum.Value : datum.Wartosc != null ? datum.Wartosc : datum['Total COGS'] != null ? datum['Total COGS'] : 0)",
          "as": "val"
        }
      ]
    },
    {
      "name": "prod_totals",
      "source": "raw_data",
      "transform": [
        {
          "type": "aggregate",
          "groupby": ["p_raw"],
          "ops": ["sum"],
          "fields": ["val"],
          "as": ["p_tot"]
        },
        {
          "type": "window",
          "sort": {"field": "p_tot", "order": "descending"},
          "ops": ["row_number"],
          "as": ["p_rank"]
        }
      ]
    },
    {
      "name": "ranked_data",
      "source": "raw_data",
      "transform": [
        {
          "type": "lookup",
          "from": "prod_totals",
          "key": "p_raw",
          "fields": ["p_raw"],
          "values": ["p_rank"]
        },
        {
          "type": "formula",
          "expr": "datum.p_rank <= topN ? datum.p_rank : (topN + 1)",
          "as": "p_sort"
        },
        {
          "type": "formula",
          "expr": "datum.p_rank <= topN ? datum.p_rank + '. ' + datum.p_raw : (topN + 1) + '. Other'",
          "as": "p_name"
        }
      ]
    },
    {
      "name": "grand_total",
      "source": "ranked_data",
      "transform": [
        {
          "type": "aggregate",
          "ops": ["sum"],
          "fields": ["val"],
          "as": ["tot_val"]
        }
      ]
    },
    {
      "name": "c_nodes",
      "source": "ranked_data",
      "transform": [
        {
          "type": "aggregate",
          "groupby": ["c_raw"],
          "ops": ["sum"],
          "fields": ["val"],
          "as": ["c_val"]
        },
        {
          "type": "window",
          "sort": {"field": "c_val", "order": "descending"},
          "ops": ["row_number"],
          "as": ["c_rank"]
        },
        {
          "type": "stack",
          "groupby": [],
          "field": "c_val",
          "sort": {"field": "c_val", "order": "descending"},
          "as": ["cy0", "cy1"]
        },
        {
          "type": "formula",
          "expr": "topMargin + scale('yScale', datum.cy0) + (datum.c_rank - 1) * cGap",
          "as": "c_top"
        },
        {
          "type": "formula",
          "expr": "topMargin + scale('yScale', datum.cy1) + (datum.c_rank - 1) * cGap",
          "as": "c_bottom"
        },
        {
          "type": "formula",
          "expr": "(datum.c_top + datum.c_bottom) / 2",
          "as": "c_mid"
        },
        {
          "type": "formula",
          "expr": "max(6, datum.c_bottom - datum.c_top)",
          "as": "c_height"
        }
      ]
    },
    {
      "name": "p_nodes",
      "source": "ranked_data",
      "transform": [
        {
          "type": "aggregate",
          "groupby": ["p_sort", "p_name"],
          "ops": ["sum"],
          "fields": ["val"],
          "as": ["p_val"]
        },
        {
          "type": "window",
          "sort": {"field": "p_sort", "order": "ascending"},
          "ops": ["row_number"],
          "as": ["p_seq"]
        },
        {
          "type": "stack",
          "groupby": [],
          "field": "p_val",
          "sort": {"field": "p_sort", "order": "ascending"},
          "as": ["py0", "py1"]
        },
        {
          "type": "formula",
          "expr": "topMargin + scale('yScale', datum.py0) + (datum.p_seq - 1) * pGap",
          "as": "p_top"
        },
        {
          "type": "formula",
          "expr": "topMargin + scale('yScale', datum.py1) + (datum.p_seq - 1) * pGap",
          "as": "p_bottom"
        },
        {
          "type": "formula",
          "expr": "(datum.p_top + datum.p_bottom) / 2",
          "as": "p_mid"
        },
        {
          "type": "formula",
          "expr": "max(4, datum.p_bottom - datum.p_top)",
          "as": "p_height"
        }
      ]
    },
    {
      "name": "p_c_links",
      "source": "ranked_data",
      "transform": [
        {
          "type": "aggregate",
          "groupby": ["p_sort", "p_name", "c_raw"],
          "ops": ["sum"],
          "fields": ["val"],
          "as": ["link_val"]
        },
        {
          "type": "lookup",
          "from": "c_nodes",
          "key": "c_raw",
          "fields": ["c_raw"],
          "values": ["c_rank", "cy0"]
        },
        {
          "type": "lookup",
          "from": "p_nodes",
          "key": "p_name",
          "fields": ["p_name"],
          "values": ["p_seq", "py0"]
        },
        {
          "type": "stack",
          "groupby": ["p_name"],
          "sort": {"field": "c_rank", "order": "ascending"},
          "field": "link_val",
          "as": ["p_sy0", "p_sy1"]
        },
        {
          "type": "stack",
          "groupby": ["c_raw"],
          "sort": {"field": "p_sort", "order": "ascending"},
          "field": "link_val",
          "as": ["c_sy0", "c_sy1"]
        },
        {
          "type": "formula",
          "expr": "topMargin + scale('yScale', datum.py0 + (datum.p_sy0 + datum.p_sy1)/2) + (datum.p_seq - 1) * pGap",
          "as": "link_p_y"
        },
        {
          "type": "formula",
          "expr": "topMargin + scale('yScale', datum.cy0 + (datum.c_sy0 + datum.c_sy1)/2) + (datum.c_rank - 1) * cGap",
          "as": "link_c_y"
        },
        {
          "type": "formula",
          "expr": "max(1.5, scale('yScale', datum.link_val))",
          "as": "link_width"
        }
      ]
    }
  ],

  "scales": [
    {
      "name": "yScale",
      "type": "linear",
      "domain": {"data": "grand_total", "field": "tot_val"},
      "range": [0, 260],
      "zero": true
    },
    {
      "name": "catColor",
      "type": "ordinal",
      "domain": {"data": "c_nodes", "field": "c_raw"},
      "range": [
        "#0284C7", "#00C896", "#F43F5E", "#0F172A", "#64748B", 
        "#3B82F6", "#10B981", "#E11D48", "#8B5CF6", "#475569"
      ]
    }
  ],

  "marks": [
    {
      "type": "text",
      "encode": {
        "enter": {
          "x": {"signal": "leftX - 10"},
          "y": {"value": 20},
          "align": {"value": "right"},
          "fontSize": {"value": 11},
          "fontWeight": {"value": "bold"},
          "fill": {"value": "#475569"}
        },
        "update": {
          "text": {"signal": "'PRODUCTS (TOP ' + topN + ' + OTHER)'"}
        }
      }
    },
    {
      "type": "text",
      "encode": {
        "enter": {
          "x": {"signal": "midX"},
          "y": {"value": 20},
          "align": {"value": "center"},
          "fontSize": {"value": 11},
          "fontWeight": {"value": "bold"},
          "fill": {"value": "#475569"},
          "text": {"value": "CATEGORIES"}
        }
      }
    },
    {
      "type": "text",
      "encode": {
        "enter": {
          "x": {"signal": "rightX + 22"},
          "y": {"value": 20},
          "align": {"value": "left"},
          "fontSize": {"value": 11},
          "fontWeight": {"value": "bold"},
          "fill": {"value": "#475569"},
          "text": {"value": "TOTAL SPEND"}
        }
      }
    },

    {
      "type": "path",
      "from": {"data": "p_c_links"},
      "encode": {
        "update": {
          "path": {
            "signal": "'M ' + (leftX + 6) + ' ' + datum.link_p_y + ' C ' + (leftX + 100) + ' ' + datum.link_p_y + ', ' + (midX - 100) + ' ' + datum.link_c_y + ', ' + (midX - 8) + ' ' + datum.link_c_y"
          },
          "stroke": {"scale": "catColor", "field": "c_raw"},
          "strokeWidth": {"field": "link_width"},
          "strokeOpacity": {"value": 0.40},
          "strokeCap": {"value": "round"}
        }
      }
    },

    {
      "type": "path",
      "from": {"data": "c_nodes"},
      "encode": {
        "update": {
          "path": {
            "signal": "'M ' + (midX + 8) + ' ' + datum.c_mid + ' C ' + (midX + 80) + ' ' + datum.c_mid + ', ' + (rightX - 50) + ' ' + targetY + ', ' + rightX + ' ' + targetY"
          },
          "stroke": {"scale": "catColor", "field": "c_raw"},
          "strokeWidth": {"field": "c_height"},
          "strokeOpacity": {"value": 0.35},
          "strokeCap": {"value": "round"}
        }
      }
    },

    {
      "type": "rect",
      "from": {"data": "p_nodes"},
      "encode": {
        "update": {
          "x": {"signal": "leftX - 6"},
          "y": {"field": "p_top"},
          "width": {"value": 12},
          "height": {"field": "p_height"},
          "fill": {"signal": "datum.p_sort > topN ? '#94A3B8' : '#0F172A'"},
          "cornerRadius": {"value": 3}
        }
      }
    },

    {
      "type": "text",
      "from": {"data": "p_nodes"},
      "encode": {
        "enter": {
          "align": {"value": "right"},
          "baseline": {"value": "middle"},
          "fontSize": {"value": 10},
          "fontWeight": {"value": "bold"},
          "fill": {"value": "#1E293B"}
        },
        "update": {
          "x": {"signal": "leftX - 14"},
          "y": {"field": "p_mid"},
          "text": {"signal": "datum.p_name + ' (' + format(datum.p_val, ',.0f') + ')'"}
        }
      }
    },

    {
      "type": "rect",
      "from": {"data": "c_nodes"},
      "encode": {
        "update": {
          "x": {"signal": "midX - 8"},
          "y": {"field": "c_top"},
          "width": {"value": 16},
          "height": {"field": "c_height"},
          "fill": {"scale": "catColor", "field": "c_raw"},
          "cornerRadius": {"value": 3}
        }
      }
    },

    {
      "type": "text",
      "from": {"data": "c_nodes"},
      "encode": {
        "enter": {
          "align": {"value": "center"},
          "baseline": {"value": "bottom"},
          "fontSize": {"value": 10},
          "fontWeight": {"value": "bold"},
          "fill": {"value": "#1E293B"}
        },
        "update": {
          "x": {"signal": "midX"},
          "y": {"signal": "datum.c_top - 4"},
          "text": {"signal": "datum.c_raw + ' (' + format(datum.c_val, ',.0f') + ')'"}
        }
      }
    },

    {
      "type": "rect",
      "from": {"data": "grand_total"},
      "encode": {
        "update": {
          "x": {"signal": "rightX"},
          "y": {"signal": "targetY - 30"},
          "width": {"value": 12},
          "height": {"value": 60},
          "fill": {"value": "#0F172A"},
          "cornerRadius": {"value": 4}
        }
      }
    },

    {
      "type": "text",
      "from": {"data": "grand_total"},
      "encode": {
        "enter": {
          "align": {"value": "left"},
          "baseline": {"value": "bottom"},
          "fontSize": {"value": 13},
          "fontWeight": {"value": "bold"},
          "fill": {"value": "#0F172A"}
        },
        "update": {
          "x": {"signal": "rightX + 22"},
          "y": {"signal": "targetY - 2"},
          "text": {"signal": "format(datum.tot_val, ',.0f')"}
        }
      }
    },
    {
      "type": "text",
      "from": {"data": "grand_total"},
      "encode": {
        "enter": {
          "align": {"value": "left"},
          "baseline": {"value": "top"},
          "fontSize": {"value": 10},
          "fill": {"value": "#64748B"},
          "text": {"value": "Total Budget"}
        },
        "update": {
          "x": {"signal": "rightX + 22"},
          "y": {"signal": "targetY + 2"}
        }
      }
    }
  ]
}

Step 4: Tweak & Customize Like a Pro ⚙️

Want to tune the visual specifically for your report theme? You can easily adjust the Signals at the top of the code:

  • 🔝 Change TOP N Items: Look for "topN": 5 in the signals block. Change it to 3, 8, or 10 depending on how many top products you want to highlight before grouping the rest into "Other".
  • 🎨 Color Palette: Match your exact brand design by editing the hex color codes inside the "catColor" scale array!

🎯 Why Your Readers & Stakeholders Will Love This Chart:

  • 100% Data Integrity: Never drops long-tail data—top performers shine while smaller items seamlessly condense into an „Other” node.
  • Zero Overlap: Auto-calculated vertical offsets ensure category labels and flow ribbons never collide or cut off.
  • Pixel-Perfect Alignment: All flows stream gracefully into a clean, compact Total Budget anchor node.

💬 Over to You!

Did you try this in your Power BI report? Let us know in the comments below what data flows you’re analyzing—and don’t forget to share a screenshot of your beautiful dashboard! 📊✨