UIPackage
Menu

Sankey Chart

sankey-chart ui
Edit on GitHub

React mirror of @uipkge/sankey-chart — see the Vue registry item for the canonical description.

Also available for Vue ->

Installation

$ npx shadcn@latest add https://uipkge.dev/r/react/sankey-chart.json
Named registry: npx shadcn@latest add @uipkge-react/sankey-chart Installs to: components/ui/charts/sankey-chart/

Examples

Props

Name Type / Values Default Required
nodes

Node names. If omitted, derived from the union of link sources + targets.

string[] optional
links

`{ source, target, value }` edges between nodes.

SankeyLink[] required
height number | string 360 optional
curveness

Curvature of the link ribbons. 0 = straight, 1 = max curve.

number 0 optional
option any optional
className string optional

Schema

Type aliases from this item's source — use them to shape the data you pass in.

SankeyLink
interface SankeyLink {
  source: string
  target: string
  value: number
}
ChartTheme
interface ChartTheme {
  colors: string[]
  textColor: string
  axisColor: string
  splitLineColor: string
  tooltipBg: string
  tooltipBorder: string
  tooltipText: string
}

npm dependencies

Used by

Files installed (4)

  • components/ui/charts/sankey-chart/SankeyChart.tsx 2.4 kB
    'use client'
    
    import * as React from 'react'
    import ReactECharts from 'echarts-for-react/lib/core'
    import { cn } from '@/lib/utils'
    import { ChartFrame, EChart, echartsCoreModule, heightToStyle } from '../shared'
    import { useChartTheme, mergeOptionBlock, toRgba, gaugeThresholds } from '../useChartTheme'
    
    // SankeyChart
    // ─────────────────────────────────────────────────────────────────────────
    
    interface SankeyLink {
      source: string
      target: string
      value: number
    }
    
    export interface SankeyChartProps {
      /** Node names. If omitted, derived from the union of link sources + targets. */
      nodes?: string[]
      /** `{ source, target, value }` edges between nodes. */
      links: SankeyLink[]
      height?: number | string
      /** Curvature of the link ribbons. 0 = straight, 1 = max curve. */
      curveness?: number
      option?: any
      className?: string
    }
    
    export const SankeyChart = React.forwardRef<HTMLDivElement, SankeyChartProps>(
      ({ nodes, links, height = 360, curveness = 0.5, option, className }, ref) => {
        const theme = useChartTheme()
    
        const mergedOption = React.useMemo(() => {
          const nodeNames = nodes ?? Array.from(new Set(links.flatMap((l) => [l.source, l.target])))
    
          const series = [
            {
              type: 'sankey',
              left: 16,
              right: 72,
              top: 12,
              bottom: 12,
              data: nodeNames.map((name) => ({ name })),
              links,
              lineStyle: { color: 'gradient', curveness },
              label: { fontSize: 11, color: theme.tooltipText },
              emphasis: { focus: 'adjacency' },
              itemStyle: { borderWidth: 0 },
            },
          ]
    
          const userOption: any = option ?? {}
          const { series: userSeries, ...userRest } = userOption
          const mergedSeries = Array.isArray(userSeries) ? series.map((s, i) => ({ ...s, ...(userSeries[i] ?? {}) })) : series
    
          return {
            color: theme.colors,
            tooltip: {
              trigger: 'item',
              triggerOn: 'mousemove',
              backgroundColor: theme.tooltipBg,
              borderColor: theme.tooltipBorder,
              textStyle: { color: theme.tooltipText, fontSize: 12 },
            },
            series: mergedSeries,
            ...userRest,
          }
        }, [nodes, links, curveness, option, theme])
    
        return (
          <ChartFrame ref={ref} height={height} className={className} focusable={false}>
            <EChart option={mergedOption} />
          </ChartFrame>
        )
      },
    )
    SankeyChart.displayName = 'SankeyChart'
  • components/ui/charts/sankey-chart/index.ts 0.1 kB
    export { SankeyChart, type SankeyChartProps } from './SankeyChart'
  • components/ui/charts/useChartTheme.ts 7 kB
    'use client'
    
    import { useEffect, useState } from 'react'
    
    // Chart palette is driven by Tailwind v4 CSS variables (`--chart-1`..`--chart-5`,
    // `--muted-foreground`, `--border`, `--popover`, etc.) so dark/light flips
    // happen automatically when the consumer toggles their theme class. The
    // values resolve at runtime via `getComputedStyle`, so they pick up whatever
    // the consumer set in their own `tailwind.css` -- no fork required.
    //
    // We bump a module-level `themeKey` whenever `<html>` class/style changes (the
    // typical shadcn dark-mode pivot) and notify subscribed `useChartTheme()`
    // consumers so every chart re-resolves its colors and ECharts re-paints.
    
    let themeKey = 0
    const listeners = new Set<() => void>()
    
    function bump() {
      themeKey++
      listeners.forEach((l) => l())
    }
    
    if (typeof window !== 'undefined') {
      // Bump once on the first paint so post-hydration getComputedStyle reads
      // the *resolved* CSS values (during SSR-built bundles the very first
      // read returns the fallbacks below).
      requestAnimationFrame(bump)
      new MutationObserver(bump).observe(document.documentElement, {
        attributes: true,
        attributeFilter: ['class', 'style', 'data-theme'],
      })
    }
    
    // Lazy canvas context used to normalize any CSS color string (including
    // `oklch(...)`, `oklab(...)`, `color(display-p3 ...)`) into a hex / rgba
    // string ECharts' canvas renderer can consume. Without this, code that
    // does `color + '40'` (8-digit hex alpha trick) produces invalid color
    // strings like `oklch(...)40` and the canvas API throws.
    let _hexCanvas: CanvasRenderingContext2D | null = null
    function toHex(cssColor: string): string {
      if (typeof document === 'undefined') return cssColor
      if (!_hexCanvas) {
        _hexCanvas = document.createElement('canvas').getContext('2d')
      }
      if (!_hexCanvas) return cssColor
      // Reset, then assign; the browser normalizes whatever it accepted into
      // the canonical hex/rgba form when read back.
      _hexCanvas.fillStyle = '#000'
      _hexCanvas.fillStyle = cssColor
      return _hexCanvas.fillStyle as string
    }
    
    // Convert any CSS color (hex, rgb, oklch, color()) + alpha 0..1 to a
    // canvas-safe rgba(r,g,b,a). `colorString + '40'` (8-digit hex alpha)
    // only works when `colorString` is `#rrggbb`; once tokens resolve to
    // oklch() post-hydration the gradient stops break and the canvas paint
    // throws every frame. Stay defensive and always return rgba.
    export function toRgba(cssColor: string, alpha: number): string {
      if (typeof document === 'undefined') return cssColor
      if (!_hexCanvas) {
        _hexCanvas = document.createElement('canvas').getContext('2d')
      }
      if (!_hexCanvas) return cssColor
      _hexCanvas.fillStyle = '#000'
      _hexCanvas.fillStyle = cssColor
      const normalized = _hexCanvas.fillStyle as string
      if (normalized.startsWith('#') && normalized.length === 7) {
        const r = parseInt(normalized.slice(1, 3), 16)
        const g = parseInt(normalized.slice(3, 5), 16)
        const b = parseInt(normalized.slice(5, 7), 16)
        return `rgba(${r},${g},${b},${alpha})`
      }
      if (normalized.startsWith('rgba(')) {
        return normalized.replace(/,\s*[\d.]+\s*\)$/, `,${alpha})`)
      }
      if (normalized.startsWith('rgb(')) {
        return normalized.replace(/^rgb\(/, 'rgba(').replace(/\)$/, `,${alpha})`)
      }
      // Canvas refused to parse this color -- ship the original string and
      // let ECharts complain (better than crashing the paint loop).
      return cssColor
    }
    
    function resolveVar(name: string, fallback: string): string {
      if (typeof window === 'undefined') return fallback
      const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
      if (!v) return fallback
      return toHex(v)
    }
    
    // SSR / pre-hydration fallback palette. Hex values picked to roughly
    // match the shadcn Neutral defaults in `tailwind.css` so the first paint
    // doesn't flicker.
    const CHART_FALLBACK = ['#f59e0b', '#14b8a6', '#3b82f6', '#f97316', '#eab308']
    
    /** The resolved chart theme tokens. All values are canvas-safe hex/rgba. */
    export interface ChartTheme {
      colors: string[]
      textColor: string
      axisColor: string
      splitLineColor: string
      tooltipBg: string
      tooltipBorder: string
      tooltipText: string
    }
    
    function resolveTheme(): ChartTheme {
      return {
        colors: Array.from({ length: 5 }, (_, i) => resolveVar(`--chart-${i + 1}`, CHART_FALLBACK[i]!)),
        textColor: resolveVar('--muted-foreground', '#888888'),
        axisColor: resolveVar('--border', '#e5e5e5'),
        splitLineColor: resolveVar('--border', '#f0f0f0'),
        tooltipBg: resolveVar('--popover', 'rgba(255,255,255,0.96)'),
        tooltipBorder: resolveVar('--border', '#e5e5e5'),
        tooltipText: resolveVar('--popover-foreground', '#333333'),
      }
    }
    
    /**
     * Subscribe to the theme-token palette. Re-resolves (and re-renders the
     * consuming chart) whenever the consumer flips their dark-mode class on
     * `<html>`. The first client render resolves the real CSS values; SSR /
     * pre-hydration returns the fallback palette above.
     */
    export function useChartTheme(): ChartTheme {
      const [, setTick] = useState(themeKey)
      const [theme, setTheme] = useState<ChartTheme>(() => resolveTheme())
    
      useEffect(() => {
        const update = () => {
          setTick(themeKey)
          setTheme(resolveTheme())
        }
        listeners.add(update)
        // Resolve once on mount so the first client paint reads the real CSS
        // values instead of the SSR fallbacks.
        update()
        return () => {
          listeners.delete(update)
        }
      }, [])
    
      return theme
    }
    
    // Two-level deep merge for ECharts option blocks (xAxis, yAxis, grid,
    // tooltip, legend, singleAxis, parallel, etc.). The top-level keys merge
    // shallowly, but one nested level (axisLabel, axisLine, splitLine, etc.)
    // merges shallowly too so a consumer passing `xAxis: { axisLabel: { fontSize: 9 } }`
    // doesn't blow away the wrapper's `color` + base font defaults on the same
    // axisLabel block. Arrays + primitives replace outright.
    //
    // This is the merge strategy the chart wrappers use to fold `option`
    // onto their computed base option without forcing consumers to spell out
    // every default they want to preserve.
    export function mergeOptionBlock<T extends Record<string, any>>(base: T, user: Partial<T> | undefined): T {
      if (!user) return base
      const out: any = { ...base }
      for (const k of Object.keys(user)) {
        const bv = (base as any)[k]
        const uv = (user as any)[k]
        if (
          bv != null &&
          uv != null &&
          typeof bv === 'object' &&
          typeof uv === 'object' &&
          !Array.isArray(bv) &&
          !Array.isArray(uv)
        ) {
          out[k] = { ...bv, ...uv }
        } else {
          out[k] = uv
        }
      }
      return out
    }
    
    // Default gauge stoplight: teal (safe) -> amber (warning) -> red (danger).
    // Pulled off saturated green and onto teal so the gauge ties back to the
    // dashboard palette; red is kept as the universal "limit reached" cue.
    // GaugeChart consumes this via its `thresholds` prop default; consumers
    // pass their own array to override. Static because gauges have semantic
    // meaning (green safe / red danger) that we deliberately don't theme-flip.
    export const gaugeThresholds: [number, string][] = [
      [0.6, '#14b8a6'],
      [0.85, '#f59e0b'],
      [1, '#dc2626'],
    ]
  • components/ui/charts/shared.tsx 3.1 kB
    'use client'
    
    import * as React from 'react'
    import * as echartsCore from 'echarts/core'
    import { use } from 'echarts/core'
    import { CanvasRenderer } from 'echarts/renderers'
    import {
      LineChart as EChartsLineChart,
      BarChart as EChartsBarChart,
      PieChart as EChartsPieChart,
      ScatterChart as EChartsScatterChart,
      RadarChart as EChartsRadarChart,
      GaugeChart as EChartsGaugeChart,
      HeatmapChart as EChartsHeatmap,
      TreemapChart as EChartsTreemapChart,
      FunnelChart as EChartsFunnelChart,
      BoxplotChart as EChartsBoxplotChart,
      CandlestickChart as EChartsCandlestickChart,
      GraphChart as EChartsGraphChart,
      ParallelChart as EChartsParallelChart,
      SankeyChart as EChartsSankeyChart,
      SunburstChart as EChartsSunburstChart,
      ThemeRiverChart,
      TreeChart as EChartsTreeChart,
    } from 'echarts/charts'
    import {
      GridComponent,
      TooltipComponent,
      LegendComponent,
      RadarComponent,
      VisualMapComponent,
      CalendarComponent,
      DataZoomComponent,
      ParallelComponent,
      SingleAxisComponent,
    } from 'echarts/components'
    import ReactECharts from 'echarts-for-react/lib/core'
    import { cn } from '@/lib/utils'
    
    export function heightToStyle(height: number | string): string {
      return /^\d+$/.test(String(height)) ? `${height}px` : String(height)
    }
    
    interface ChartFrameProps {
      height: number | string
      className?: string
      /** Apply the accessible role/tabindex/focus-ring chrome. Some chart
       *  wrappers ship a bare `w-full` frame -- pass `false` for those. */
      focusable?: boolean
    }
    
    /** Shared `<div>` wrapper around the ECharts canvas. */
    export const ChartFrame = React.forwardRef<HTMLDivElement, ChartFrameProps & { children: React.ReactNode }>(
      ({ height, className, focusable = true, children }, ref) => (
        <div
          ref={ref}
          role={focusable ? 'img' : undefined}
          tabIndex={focusable ? 0 : undefined}
          style={{ height: heightToStyle(height) }}
          className={
            focusable
              ? cn('focus-visible:ring-ring w-full focus-visible:ring-2 focus-visible:outline-none', className)
              : cn('w-full', className)
          }
        >
          {children}
        </div>
      ),
    )
    ChartFrame.displayName = 'ChartFrame'
    
    /** ECharts canvas filling its parent frame. */
    export function EChart({ option }: { option: any }) {
      return (
        <ReactECharts
          echarts={echartsCore as any}
          option={option}
          notMerge
          lazyUpdate
          style={{ width: '100%', height: '100%' }}
        />
      )
    }
    
    export const echartsCoreModule = echartsCore
    
    // Register every chart type the wrappers need once at module load.
    use([
      CanvasRenderer,
      EChartsLineChart,
      EChartsBarChart,
      EChartsPieChart,
      EChartsScatterChart,
      EChartsRadarChart,
      EChartsGaugeChart,
      EChartsHeatmap,
      EChartsTreemapChart,
      EChartsFunnelChart,
      EChartsBoxplotChart,
      EChartsCandlestickChart,
      EChartsGraphChart,
      EChartsParallelChart,
      EChartsSankeyChart,
      EChartsSunburstChart,
      ThemeRiverChart,
      EChartsTreeChart,
      GridComponent,
      TooltipComponent,
      LegendComponent,
      RadarComponent,
      VisualMapComponent,
      CalendarComponent,
      DataZoomComponent,
      ParallelComponent,
      SingleAxisComponent,
    ])

Raw manifest: https://uipkge.dev/r/react/sankey-chart.json