Lattice Grid Buy a licence

chart gallery

Timeline Charts: Serpentine, Spiral, Race, Motion

Four ways to show change over time: two timelines that fold or wind a long history, and two charts that play through the years.

Theme All chart types

Serpentine timeline chart

A serpentine timeline folds a long history into rows joined by half-circle turns, so a year of milestones fits a page and still reads in order. Time runs along the whole path, turns included, so an event sits as far round as its date says and a span bends with the curve. Use it for a project plan, a product history or a roadmap review where a straight line would run off the screen. Events can carry a label and a colour from a column, calendar ticks run along the path, and a click selects the event’s row.

Try it. Click an event to select its row in the grid.

Loading a live chart…

The code

createChart({
  grid,
  container,
  type: 'serpentine',
  x: 'at',
  label: 'name',
  serpentine: { rows: 3 },
  selection: true,
  title: 'Fibre build milestones',
});

Options used: type: 'serpentine', serpentine, label

Sample data
({
  columns: [
    { field: 'at', title: 'Date', type: 'date' },
    { field: 'name', title: 'Milestone' },
  ],
  rows: [
    ['2026-01-12', 'Kick-off'], ['2026-02-02', 'Survey done'], ['2026-02-23', 'Design signed off'],
    ['2026-03-16', 'Permits granted'], ['2026-04-06', 'Ducting starts'], ['2026-04-27', 'Cabinets installed'],
    ['2026-05-18', 'Fibre pulled'], ['2026-06-08', 'Splicing'], ['2026-06-29', 'Testing'],
    ['2026-07-20', 'First customers'], ['2026-08-10', 'Handover'], ['2026-08-31', 'Review'],
  ].map(([at, name], i) => ({ id: i, at, name })),
})

Spiral timeline chart

A spiral timeline winds a series onto a spiral with one turn per period, so the same point of every day, week or year lands on the same angle. A daily or weekly rhythm then shows as a streak running outward. It is the picture to reach for when you want to see a pattern repeat: orders that peak at lunch and in the evening, load that climbs every Monday, or seasonal sales. Show points sized by a value, bars, or spans, with one tinted band per period and labels for hours, weekdays or months.

Loading a live chart…

The code

createChart({
  grid,
  container,
  type: 'spiral',
  x: 'at',
  y: 'orders',
  spiral: { period: 'day' },
  title: 'Orders by hour, one turn per day',
});

Options used: type: 'spiral', spiral

Sample data
({
  columns: [
    { field: 'at', title: 'Hour', type: 'datetime' },
    { field: 'orders', title: 'Orders', type: 'number' },
  ],
  rows: Array.from({ length: 24 * 10 }, (_, i) => {
    const hour = i % 24;
    const lunch = Math.exp(-Math.pow(hour - 13, 2) / 6);
    const evening = Math.exp(-Math.pow(hour - 19, 2) / 8);
    return {
      id: i,
      at: new Date(Date.UTC(2026, 2, 2 + Math.floor(i / 24), hour)).toISOString(),
      orders: Math.round(6 + 40 * lunch + 55 * evening + (i % 7)),
    };
  }),
})

Bar chart race

A bar chart race ranks the top categories at each point in a time column and lets them grow, shrink and trade places as the years pass. It makes a story of change over time: which market overtook which, and when. It comes with a large time label, play and pause, a speed button and a scrubber, all real controls that work from the keyboard, and anyone who prefers reduced motion gets whole frames instead of tweening. Clicking a bar selects that category’s rows at the frame, so you can go from the picture to the data behind it.

Try it. Press play, change the speed, or drag the scrubber to jump to any year.

Loading a live chart…

The code

createChart({
  grid,
  container,
  type: 'barRace',
  x: 'country',
  time: 'year',
  y: 'customers',
  topN: 5,
  title: 'Customers by country (k)',
});

Options used: type: 'barRace', time, topN

Sample data
({
  columns: [
    { field: 'country', title: 'Country' },
    { field: 'region', title: 'Region' },
    { field: 'year', title: 'Year', type: 'number' },
    { field: 'customers', title: 'Customers (k)', type: 'number' },
    { field: 'spend', title: 'Spend per customer', type: 'number' },
    { field: 'retention', title: 'Retention (%)', type: 'number' },
  ],
  rows: [
    ['UK', 'Europe', [310, 340, 365, 380, 395, 410], [95, 98, 104, 110, 118, 124], [84, 85, 86, 86, 87, 88]],
    ['Germany', 'Europe', [220, 260, 300, 345, 385, 420], [88, 92, 97, 103, 108, 115], [80, 82, 83, 85, 86, 88]],
    ['Brazil', 'Americas', [90, 130, 190, 265, 350, 440], [30, 34, 41, 49, 58, 66], [68, 70, 72, 75, 77, 80]],
    ['Canada', 'Americas', [150, 165, 180, 190, 200, 210], [92, 94, 97, 100, 104, 108], [83, 83, 84, 85, 85, 86]],
    ['India', 'Asia', [60, 120, 210, 330, 470, 640], [12, 16, 21, 27, 33, 40], [62, 66, 70, 73, 76, 79]],
    ['Japan', 'Asia', [260, 270, 280, 285, 290, 292], [110, 113, 116, 118, 121, 124], [90, 90, 91, 91, 92, 92]],
  ].flatMap(([country, region, customers, spend, retention]) => customers.map((c, i) => ({
    id: country + i, country, region, year: 2020 + i, customers: c, spend: spend[i], retention: retention[i],
  }))),
})

Motion chart

A motion chart gives each entity one bubble and glides it through time: two measures place it, a third sizes it and a category colours it. It is the classic way to see several things changing together, such as customers, spend and retention for a set of markets across years. The axes are fixed for the whole run, so movement is real movement, and a log axis is available for wide ranges. Trails draw the path so far for the entities you select, hovering shows that frame’s values, and the play, speed and scrubber controls match the bar chart race.

Try it. Press play, then select a country in the grid to give it a trail.

Loading a live chart…

The code

createChart({
  grid,
  container,
  type: 'motion',
  entity: 'country',
  time: 'year',
  x: 'spend',
  y: 'retention',
  size: 'customers',
  colour: 'region',
  trails: true,
  title: 'Spend, retention and customers',
});

Options used: type: 'motion', entity, scale, trails

Sample data
({
  columns: [
    { field: 'country', title: 'Country' },
    { field: 'region', title: 'Region' },
    { field: 'year', title: 'Year', type: 'number' },
    { field: 'customers', title: 'Customers (k)', type: 'number' },
    { field: 'spend', title: 'Spend per customer', type: 'number' },
    { field: 'retention', title: 'Retention (%)', type: 'number' },
  ],
  rows: [
    ['UK', 'Europe', [310, 340, 365, 380, 395, 410], [95, 98, 104, 110, 118, 124], [84, 85, 86, 86, 87, 88]],
    ['Germany', 'Europe', [220, 260, 300, 345, 385, 420], [88, 92, 97, 103, 108, 115], [80, 82, 83, 85, 86, 88]],
    ['Brazil', 'Americas', [90, 130, 190, 265, 350, 440], [30, 34, 41, 49, 58, 66], [68, 70, 72, 75, 77, 80]],
    ['Canada', 'Americas', [150, 165, 180, 190, 200, 210], [92, 94, 97, 100, 104, 108], [83, 83, 84, 85, 85, 86]],
    ['India', 'Asia', [60, 120, 210, 330, 470, 640], [12, 16, 21, 27, 33, 40], [62, 66, 70, 73, 76, 79]],
    ['Japan', 'Asia', [260, 270, 280, 285, 290, 292], [110, 113, 116, 118, 121, 124], [90, 90, 91, 91, 92, 92]],
  ].flatMap(([country, region, customers, spend, retention]) => customers.map((c, i) => ({
    id: country + i, country, region, year: 2020 + i, customers: c, spend: spend[i], retention: retention[i],
  }))),
})

Every chart type, live → The charts API reference → How a chart binds to a grid →