Bump Chart

How a set of things ranked against each other over time.

'use client';

import { useState } from 'react';
import { BumpChart, bumpRanks, type BumpChartDatum } from '@/components/panelui/bump-chart';
import { Frame } from '@/components/panelui/frame';

const CLUBS = [
  { key: 'harbour', label: 'Harbour', color: 'chart-2' },
  { key: 'northside', label: 'Northside', color: 'chart-1' },
  { key: 'kestrel', label: 'Kestrel', color: 'chart-3' },
  { key: 'oldMill', label: 'Old Mill', color: 'chart-4' },
  { key: 'riverside', label: 'Riverside', color: 'chart-5' },
] as const;

const LEAGUE = [
  { week: 'w32', harbour: 3, northside: 2, kestrel: 1, oldMill: 4, riverside: 5 },
  { week: 'w33', harbour: 3, northside: 2, kestrel: 1, oldMill: 5, riverside: 4 },
  { week: 'w34', harbour: 2, northside: 3, kestrel: 1, oldMill: 4, riverside: 5 },
  { week: 'w35', harbour: 2, northside: 1, kestrel: 3, oldMill: 4, riverside: 5 },
  { week: 'w36', harbour: 1, northside: 3, kestrel: 2, oldMill: 4, riverside: 5 },
  { week: 'w37', harbour: 1, northside: 2, kestrel: 3, oldMill: 5, riverside: 4 },
  { week: 'Now', harbour: 1, northside: 2, kestrel: 3, oldMill: 4, riverside: 5 },
];

const RANKS = bumpRanks(LEAGUE, CLUBS.map(({ key }) => key));

export default function BumpChartOverview() {
  const [picked, setPicked] = useState<string | null>('harbour');
  const [active, setActive] = useState<{ index: number; datum: BumpChartDatum } | null>(null);

  const club = CLUBS.find(({ key }) => key === picked);
  const column = active ? active.index : LEAGUE.length - 1;
  const rank = club ? RANKS[club.key]?.[column] : null;

  return (
    <Frame variant="band" className="w-full max-w-lg">
      <Frame.Header>
        <Frame.Title>League table</Frame.Title>
        <Frame.Action>{picked ? 'Press again to clear' : 'Press a name'}</Frame.Action>
      </Frame.Header>
      <Frame.Panel className="p-5">
        <BumpChart
          data={LEAGUE}
          xDataKey="week"
          highlight={picked}
          onHighlightChange={setPicked}
          onActiveIndexChange={(index, datum) => setActive(datum ? { index, datum } : null)}
        >
          <BumpChart.Header
            title={club ? club.label : 'No club picked'}
            value={rank ? `#${rank}` : 'None'}
            caption={LEAGUE[column]?.week}
          />
          <BumpChart.Grid />
          {CLUBS.map(({ key, label, color }) => (
            <BumpChart.Line key={key} dataKey={key} label={label} color={color} />
          ))}
          <BumpChart.YAxis />
          <BumpChart.XAxis />
          <BumpChart.Labels />
          <BumpChart.Tooltip />
        </BumpChart>
      </Frame.Panel>
    </Frame>
  );
}

Each series is drawn at its rank in every column, with an S-curve wherever it changes places. Use it for league tables, leaderboards, search positions or any ranking tracked over time.

The rows are evenly spaced whatever the gap between the scores behind them, so the chart shows order and not distance. When how far apart the series are matters, use a line chart of the scores instead.

More than three or four lines in their own colours are hard to follow through the crossings. Pick one out with highlight: it is drawn in its colour and on top, and the rest are drawn in a muted grey.

Installation

npx shadcn@latest add https://panelui.dev/r/web/bump-chart.json

The source is copied into components/panelui/, where you can edit it.

Usage

import { BumpChart } from "@/components/panelui/bump-chart"
<BumpChart data={league} xDataKey="week" defaultHighlight="harbour">
  <BumpChart.Grid />
  <BumpChart.Line dataKey="harbour" label="Harbour" color="chart-2" />
  <BumpChart.Line dataKey="northside" label="Northside" />
  <BumpChart.YAxis />
  <BumpChart.XAxis />
  <BumpChart.Labels />
  <BumpChart.Tooltip />
</BumpChart>

Composition

<BumpChart data={…}>
  <BumpChart.Header />
  <BumpChart.Grid />
  <BumpChart.Skeleton />
  <BumpChart.Line dataKey="…" />
  <BumpChart.YAxis />
  <BumpChart.XAxis />
  <BumpChart.Labels />
  <BumpChart.Standings />
  <BumpChart.Tooltip />
  <BumpChart.Legend />
</BumpChart>
  • BumpChart.Header — The strip above the plot: title, value and caption, with the series key or your own control on the trailing side. Pass the value yourself, from onHighlightChange or onActiveIndexChange if it should follow the chart.
  • BumpChart.Grid — Dashed guides down every column, and across every rank with horizontal.
  • BumpChart.Skeleton — A thin bar on every rank row with a highlight moving across them, drawn only while status is loading.
  • BumpChart.Line — One series. Declare one per key. With values="score", their order breaks ties.
  • BumpChart.YAxis — The ranks down the side, one on every row, with room made for them. format changes #1 to anything else.
  • BumpChart.XAxis — Column labels along the bottom. ticks sets how many are shown, spread across the run.
  • BumpChart.Labels — Each series' name level with its last point, in a column kept on the right. The names move with their lines when the data changes. Pressing one picks that line out, and pressing it again clears it. pressable={false} turns that off.
  • BumpChart.Standings — A table of the column being read, or of the last column when none is, with the places gained or lost since the column before. Reading the chart reorders it. It sits beside the plot from 448 pixels wide and under it below that.
  • BumpChart.Tooltip — The readout, and what drives it. It lists every series in the order it stood at that column, and it covers the plot, so the pointer does not have to land on a line.
  • BumpChart.Legend — A swatch and a name per series, over the top corner of the plot. On a chart with a header, use the header's legend prop instead.

Examples

A live table

Add Standings for a table that follows the column being read. It drives the reading on its own, so a chart can use it in place of Tooltip, whose readout covers part of the lines. It takes the same room as Labels, so a chart usually has one or the other.

'use client';

import { BumpChart } from '@/components/panelui/bump-chart';
import { Frame } from '@/components/panelui/frame';

const LEAGUE = [
  { week: 'w32', harbour: 3, northside: 2, kestrel: 1, oldMill: 4, riverside: 5 },
  { week: 'w33', harbour: 3, northside: 2, kestrel: 1, oldMill: 5, riverside: 4 },
  { week: 'w34', harbour: 2, northside: 3, kestrel: 1, oldMill: 4, riverside: 5 },
  { week: 'w35', harbour: 2, northside: 1, kestrel: 3, oldMill: 4, riverside: 5 },
  { week: 'w36', harbour: 1, northside: 3, kestrel: 2, oldMill: 4, riverside: 5 },
  { week: 'w37', harbour: 1, northside: 2, kestrel: 3, oldMill: 5, riverside: 4 },
  { week: 'Now', harbour: 1, northside: 2, kestrel: 3, oldMill: 4, riverside: 5 },
];

export default function BumpChartStandings() {
  return (
    <Frame variant="band" className="w-full max-w-xl">
      <Frame.Header>
        <Frame.Title>League table</Frame.Title>
        <Frame.Action>Hover to read</Frame.Action>
      </Frame.Header>
      <Frame.Panel className="p-5">
        <BumpChart data={LEAGUE} xDataKey="week" aspectRatio={1.45}>
          <BumpChart.Grid />
          <BumpChart.Line dataKey="harbour" label="Harbour" color="chart-2" />
          <BumpChart.Line dataKey="northside" label="Northside" color="chart-1" />
          <BumpChart.Line dataKey="kestrel" label="Kestrel" color="chart-3" />
          <BumpChart.Line dataKey="oldMill" label="Old Mill" color="chart-4" />
          <BumpChart.Line dataKey="riverside" label="Riverside" color="chart-5" />
          <BumpChart.YAxis />
          <BumpChart.XAxis />
          <BumpChart.Standings />
        </BumpChart>
      </Frame.Panel>
    </Frame>
  );
}

Ranking from scores

Pass the scores themselves with values="score" and the chart ranks every column, highest first. Two equal scores keep the order the lines are declared in. bumpRanks(data, keys, { values: 'score' }) runs the same ranking outside the chart, for a header or a table that has to agree with it.

'use client';

import { BumpChart } from '@/components/panelui/bump-chart';

const TAKINGS = [
  { month: 'Jan', quay: 41_200, market: 38_900, station: 29_400, college: 22_100 },
  { month: 'Feb', quay: 39_800, market: 40_300, station: 31_200, college: 24_600 },
  { month: 'Mar', quay: 37_500, market: 42_100, station: 33_800, college: 34_200 },
  { month: 'Apr', quay: 44_900, market: 41_600, station: 36_100, college: 31_700 },
  { month: 'May', quay: 46_300, market: 39_200, station: 41_500, college: 30_400 },
  { month: 'Jun', quay: 48_700, market: 40_800, station: 43_900, college: 29_800 },
];

export default function BumpChartScores() {
  return (
    <BumpChart data={TAKINGS} xDataKey="month" values="score" aspectRatio={1.8} className="max-w-lg">
      <BumpChart.Header title="Top shop in June" value="Quay" />
      <BumpChart.Grid />
      <BumpChart.Line dataKey="quay" label="Quay" color="chart-2" strokeWidth={2} />
      <BumpChart.Line dataKey="market" label="Market" color="chart-1" strokeWidth={2} />
      <BumpChart.Line dataKey="station" label="Station" color="chart-3" strokeWidth={2} />
      <BumpChart.Line dataKey="college" label="College" color="chart-4" strokeWidth={2} />
      <BumpChart.YAxis />
      <BumpChart.XAxis ticks={6} />
      <BumpChart.Labels pressable={false} />
      <BumpChart.Tooltip />
    </BumpChart>
  );
}

Loading, then data

Add BumpChart.Skeleton and drive status. The lines are drawn in from the left when it turns ready. It draws a bar per rank, and at least four; rows sets another count.

'use client';

import { useState } from 'react';
import { BumpChart, type BumpChartStatus } from '@/components/panelui/bump-chart';
import { Button } from '@/components/panelui/button';

const LEAGUE = [
  { week: 'w32', harbour: 3, northside: 2, kestrel: 1, oldMill: 4 },
  { week: 'w33', harbour: 2, northside: 3, kestrel: 1, oldMill: 4 },
  { week: 'w34', harbour: 2, northside: 1, kestrel: 3, oldMill: 4 },
  { week: 'w35', harbour: 1, northside: 2, kestrel: 4, oldMill: 3 },
  { week: 'Now', harbour: 1, northside: 2, kestrel: 3, oldMill: 4 },
];

export default function BumpChartLoading() {
  const [status, setStatus] = useState<BumpChartStatus>('loading');

  return (
    <div className="flex w-full max-w-lg flex-col items-start gap-4">
      <BumpChart data={LEAGUE} xDataKey="week" status={status} defaultHighlight="harbour" aspectRatio={2}>
        <BumpChart.Grid />
        <BumpChart.Skeleton />
        <BumpChart.Line dataKey="harbour" label="Harbour" color="chart-2" />
        <BumpChart.Line dataKey="northside" label="Northside" />
        <BumpChart.Line dataKey="kestrel" label="Kestrel" />
        <BumpChart.Line dataKey="oldMill" label="Old Mill" />
        <BumpChart.YAxis />
        <BumpChart.XAxis ticks={5} />
        <BumpChart.Labels />
        <BumpChart.Tooltip />
      </BumpChart>
      <Button variant="outline" onClick={() => setStatus(status === 'loading' ? 'ready' : 'loading')}>
        {status === 'loading' ? 'Finish loading' : 'Load again'}
      </Button>
    </div>
  );
}

Changing data

New data moves every line to its new places over morphDuration, and the names move with them. A ref on the chart has replay(), which draws the lines in again.

'use client';

import { useRef, useState } from 'react';
import { BumpChart, type BumpChartHandle } from '@/components/panelui/bump-chart';
import { Button } from '@/components/panelui/button';

const SEASONS = {
  'This season': [
    { week: 'w32', harbour: 3, northside: 2, kestrel: 1, oldMill: 4 },
    { week: 'w33', harbour: 2, northside: 3, kestrel: 1, oldMill: 4 },
    { week: 'w34', harbour: 2, northside: 1, kestrel: 3, oldMill: 4 },
    { week: 'w35', harbour: 1, northside: 2, kestrel: 4, oldMill: 3 },
    { week: 'w36', harbour: 1, northside: 2, kestrel: 3, oldMill: 4 },
  ],
  'Last season': [
    { week: 'w32', harbour: 4, northside: 1, kestrel: 2, oldMill: 3 },
    { week: 'w33', harbour: 4, northside: 1, kestrel: 3, oldMill: 2 },
    { week: 'w34', harbour: 3, northside: 2, kestrel: 4, oldMill: 1 },
    { week: 'w35', harbour: 3, northside: 1, kestrel: 4, oldMill: 2 },
    { week: 'w36', harbour: 2, northside: 1, kestrel: 4, oldMill: 3 },
  ],
};
type Season = keyof typeof SEASONS;

export default function BumpChartChangingData() {
  const chart = useRef<BumpChartHandle>(null);
  const [season, setSeason] = useState<Season>('This season');

  return (
    <div className="flex w-full max-w-lg flex-col items-start gap-4">
      <BumpChart ref={chart} data={SEASONS[season]} xDataKey="week" defaultHighlight="harbour" aspectRatio={2}>
        <BumpChart.Grid />
        <BumpChart.Line dataKey="harbour" label="Harbour" color="chart-2" />
        <BumpChart.Line dataKey="northside" label="Northside" />
        <BumpChart.Line dataKey="kestrel" label="Kestrel" />
        <BumpChart.Line dataKey="oldMill" label="Old Mill" />
        <BumpChart.YAxis />
        <BumpChart.XAxis ticks={5} />
        <BumpChart.Labels />
        <BumpChart.Tooltip />
      </BumpChart>
      <div className="flex gap-2">
        <Button variant="outline" onClick={() => setSeason(season === 'This season' ? 'Last season' : 'This season')}>
          Show {season === 'This season' ? 'last season' : 'this season'}
        </Button>
        <Button variant="ghost" onClick={() => chart.current?.replay()}>
          Replay
        </Button>
      </div>
    </div>
  );
}

API Reference

BumpChart

PropTypeDefaultDescription
datareadonly BumpChartDatum[]—The rows. Each one is a column: a week, a round, a release.
xDataKeystring'date'Key holding the column's label.
values'rank' | 'score''rank'What the series columns hold. rank takes them as places, 1 at the top. score ranks every row for you, highest first.
highlightstring | null—The series drawn in its colour and on top, with the rest muted. null picks none, and every line keeps its own colour. Controlled: pair it with onHighlightChange.
defaultHighlightstring | nullnullThe series picked out on first render, when highlight is not passed.
onHighlightChange(key: string | null) => void—Called when a name in Labels or a row of Standings is pressed.
statusBumpChartStatus'ready'loading hides the lines and shows BumpChart.Skeleton if there is one. The lines are drawn in from the left when it turns ready.
aspectRationumber1.7The plot's width ÷ height.
animationDurationnumber700Milliseconds for the lines to draw in.
morphDurationnumber500Milliseconds for the lines to move to new places when the data changes.
onActiveIndexChange(index: number, datum: BumpChartDatum | null) => void—The column being read, and -1/null when there is none. Fires when the index changes, not per frame.
compactbooleanfalseDrop the axis padding, for a chart with no axes.
labelstring—What a screen reader calls the chart.
labelForDatum(datum: BumpChartDatum, index: number) => string—What a screen reader says for one column. Defaults to its label and every series' rank.

BumpChart.Grid

PropTypeDefaultDescription
verticalboolean—A guide down every column.
horizontalboolean—A guide across every rank.
dashArraystring—The stroke's dash pattern.
opacitynumber—Opacity of the lines.

BumpChart.Line

PropTypeDefaultDescription
dataKeystring—Column in the data holding this series' rank, or its score with values="score".
labelstring—The name shown by Labels, Standings, the tooltip and the legend. Defaults to dataKey.
colorBumpChartColor—Which of the theme's roles the series is drawn in.
strokeWidthnumber—Thickness of the line. The picked-out line is drawn one pixel thicker.
showDotsboolean—Draw a dot at every column.

BumpChart.Skeleton

PropTypeDefaultDescription
rowsnumber—How many rank rows to stand in for. Defaults to the data's, and at least four.
durationnumber—Milliseconds for one pass of the sweep.

BumpChart.XAxis

PropTypeDefaultDescription
ticksnumber—How many columns to label, spread across the run.
format(datum: BumpChartDatum, index: number) => string—Turn a row into its label. Defaults to the value at xDataKey.

BumpChart.YAxis

PropTypeDefaultDescription
format(rank: number) => string—Format a rank. Defaults to #1, #2…

BumpChart.Labels

PropTypeDefaultDescription
widthnumber84Room kept to the right of the plot for the names. Longer names are cut short.
pressablebooleantrueLet pressing a name pick that line out, and pressing it again clear it.

BumpChart.Standings

PropTypeDefaultDescription
formatRank(rank: number) => string(rank: number) => '#' + rankFormat a rank. Defaults to #1, #2…
movementbooleantrueShow the places gained or lost since the column before.
pressablebooleantrueLet pressing a row pick that line out, and pressing it again clear it.

BumpChart.Tooltip

PropTypeDefaultDescription
formatRank(rank: number) => string(rank: number) => '#' + rankFormat a rank in the readout. Defaults to #1, #2…
formatX(datum: BumpChartDatum) => string—Format the readout's heading from the row. Defaults to the value at xDataKey.
classNamestring—Added to the readout's surface.

BumpChart.Header

PropTypeDefaultDescription
titleReactNode—Small line above the value: what the chart is of.
valueReactNode—The readout. The largest thing on the card, and the first thing read.
captionReactNode—One muted line under the value: a period, a comparison, a total.
legendbooleanfalseDraw a swatch and a name per series along the trailing edge.

BumpChart, BumpChart.Header, BumpChart.Labels, BumpChart.Standings and BumpChart.Legend also take the props of the element they render, and a className. BumpChart.Tooltip puts its className on the readout. The other parts draw nothing themselves and take only the props listed.

Notes

The data

One row per column. Each series is a key holding its rank in that column, with 1 at the top. A missing or non-numeric value leaves a gap in that line, and a line that stops before the last column has its name beside the point where it stopped. The number of rows is the lowest rank any series holds, so a table of five with one series missing keeps five rows.

Sizing

The chart fills its container's width and takes its height from aspectRatio, which applies to the plot alone, so a header does not shrink it. The plot draws in the browser after it has been measured, and the space is reserved until then, so it is not in server-rendered HTML.

YAxis and Labels each keep room beside the plot. Labels takes 84 pixels by default; set its width for longer or shorter names. A name that does not fit is cut short.

Bundle size

The chart adds about 60 KB of gzipped JavaScript to a page that uses it. A page that does not import it pays nothing, including one that imports other components from the package root. The drawing library is shared with the other web charts, so a second chart costs far less.

Colour

color on BumpChart.Line names a role in the theme. Every series defaults to chart-1, so give the others their own, or pick one out with highlight and leave the rest.

Reading a column

With a Tooltip or Standings, hovering a column reads it. On a touch screen a sideways drag reads the columns it crosses, and a vertical drag scrolls the page.

onActiveIndexChange fires when the column changes, not on every frame, so it can drive a readout outside the chart, as the first example does. Inside the chart, useBumpChart gives the same column and the picked line.

Keyboard

With a Tooltip or Standings, the plot takes focus. The arrow keys move between columns, Home and End go to the first and last, and Escape clears the reading. The names and the rows of the table are buttons.

Screen readers

Each column is given as text beside the drawing, listing every series' rank. label names the chart and labelForDatum writes a column. The names and the rows of the table say whether they are pressed.

Reduced motion

With reduced motion set, the lines appear without drawing in, and the skeleton's highlight is not drawn.

Server Components

BumpChart is a client component. In a file that starts with "use client", write its parts with a dot, as on this page. A Server Component cannot read BumpChart.Header off the import, so each part is also exported by name: BumpChartHeader is BumpChart.Header, and so on for the others.

import { BumpChart, BumpChartHeader, BumpChartGrid, BumpChartLine, BumpChartSkeleton, BumpChartXAxis, BumpChartYAxis, BumpChartLabels, BumpChartStandings, BumpChartTooltip, BumpChartLegend } from "@/components/panelui/bump-chart"

On this page