Candlestick / OHLC

Candlestick and OHLC price marks with an ordinal date axis. ChartSeries.ohlc carries the four prices while values keeps the closes, so the CSV export, the screen-reader table and a 10-day moving average all work with no candle-specific code. Toggle the axis: a real time axis opens a gap over every weekend, an ordinal one spaces sessions evenly and still labels them by date. The strip under the plot is the chart brush: drag its window to pan, drag an edge to resize.

A live, editable Svelte 5 data grid example from the SvGrid gallery (Charts). See the SvGrid documentation for the full API.

What this example shows

A price chart needs two things a line chart does not.

1. Four numbers per point. `ChartSeries.ohlc` carries them, while `values` keeps the CLOSES - which is what lets everything generic keep working: the tooltip, the CSV export, the screen-reader table and `overlay: 'sma:10'` for the moving average all read `values`.

2. An axis that skips the days the market was shut. `xType: 'time'` spaces points by real elapsed time, so every weekend opens a hole three trading days wide. `xType: 'ordinal-time'` spaces sessions evenly and still labels them by date. Toggle it below.

The strip under the plot is the chart's own `brush`: drag the window to pan, drag an edge to resize. Volume stays in the grid rather than in a second chart there, for two reasons. A short strip under a plot reads as a range selector whatever you draw in it, and two charts cannot share a zoom, so the volume fell out of register the moment you zoomed the price.

Free, in @svgrid/grid.

Imports, features and API used

Imports: @svgrid/grid

Table features registered: rowSortingFeature

Columns: date (Session), c (Close), volume (Vol)

Source code (432-chart-candlestick.svelte)

<script lang="ts">
  /**
   * 432. Candlestick / OHLC with an ordinal date axis
   * -------------------------------------------------
   * A price chart needs two things a line chart does not.
   *
   * 1. Four numbers per point. `ChartSeries.ohlc` carries them, while
   *    `values` keeps the CLOSES - which is what lets everything generic
   *    keep working: the tooltip, the CSV export, the screen-reader table
   *    and `overlay: 'sma:10'` for the moving average all read `values`.
   *
   * 2. An axis that skips the days the market was shut. `xType: 'time'`
   *    spaces points by real elapsed time, so every weekend opens a hole
   *    three trading days wide. `xType: 'ordinal-time'` spaces sessions
   *    evenly and still labels them by date. Toggle it below.
   *
   * The strip under the plot is the chart's own `brush`: drag the window to
   * pan, drag an edge to resize. Volume stays in the grid rather than in a
   * second chart there, for two reasons. A short strip under a plot reads as
   * a range selector whatever you draw in it, and two charts cannot share a
   * zoom, so the volume fell out of register the moment you zoomed the price.
   *
   * Free, in @svgrid/grid.
   */
  import {
    SvGrid,
    SvChart,
    tableFeatures,
    rowSortingFeature,
    type GridColumns,
    type ChartSpec,
    type OhlcBar,
  } from '@svgrid/grid'

  type Session = { date: string; o: number; h: number; l: number; c: number; volume: number }

  /** 45 trading sessions, weekends skipped. Seeded, so it reads the same every load. */
  function sessions(): Session[] {
    const out: Session[] = []
    const d = new Date(Date.UTC(2026, 0, 5)) // a Monday
    let price = 182
    let seed = 7
    const next = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648 - 0.5)
    while (out.length < 45) {
      const day = d.getUTCDay()
      if (day !== 0 && day !== 6) {
        const o = price
        const c = Math.max(20, o + next() * 5)
        out.push({
          date: d.toISOString().slice(0, 10),
          o: +o.toFixed(2),
          h: +(Math.max(o, c) + Math.abs(next()) * 2.2).toFixed(2),
          l: +(Math.min(o, c) - Math.abs(next()) * 2.2).toFixed(2),
          c: +c.toFixed(2),
          volume: Math.round(400 + Math.abs(c - o) * 220 + Math.abs(next()) * 300),
        })
        price = c
      }
      d.setUTCDate(d.getUTCDate() + 1)
    }
    return out
  }

  const rows = sessions()
  const features = tableFeatures({ rowSortingFeature })

  // Three columns, not six: the grid sits beside a 640px chart pane, and a
  // a wide table there is just truncated headers.
  const columns: GridColumns<Session> = [
    { field: 'date', header: 'Session', width: 96 },
    { field: 'c', header: 'Close', width: 78, align: 'right' },
    { field: 'volume', header: 'Vol', width: 68, align: 'right' },
  ]

  let mark = $state<'candlestick' | 'ohlc'>('candlestick')
  let axis = $state<'ordinal-time' | 'time'>('ordinal-time')
  let showMa = $state(true)

  const ohlc: OhlcBar[] = rows.map((r) => ({ o: r.o, h: r.h, l: r.l, c: r.c }))
  const dates = rows.map((r) => r.date)

  const price = $derived<ChartSpec>({
    type: mark,
    categories: dates,
    xType: axis,
    height: 188,
    series: [
      {
        label: 'ACME',
        values: rows.map((r) => r.c),
        ohlc,
        ...(showMa ? { overlay: 'sma:10' as const, overlayColor: '#8b5cf6' } : {}),
      },
    ],
  })

</script>

<section class="wrap">
  <header class="chrome">
    <div class="seg" role="group" aria-label="Mark">
      <button class:on={mark === 'candlestick'} onclick={() => (mark = 'candlestick')}>Candles</button>
      <button class:on={mark === 'ohlc'} onclick={() => (mark = 'ohlc')}>OHLC</button>
    </div>
    <div class="seg" role="group" aria-label="Date axis">
      <button class:on={axis === 'ordinal-time'} onclick={() => (axis = 'ordinal-time')}>Sessions</button>
      <button class:on={axis === 'time'} onclick={() => (axis = 'time')}>Real time</button>
    </div>
    <label class="chk"><input type="checkbox" bind:checked={showMa} /> 10-day average</label>
    <span class="note">
      Switch to <strong>Real time</strong> and every weekend opens a gap. Drag to zoom.
    </span>
  </header>

  <div class="body">
    <div class="grid-host">
      <SvGrid
        responsive={true}
        data={rows}
        {columns}
        {features}
        getRowId={(r) => r.date}
        sortable
        rowHeight={26}
        containerHeight="100%"
        fitColumns
      />
    </div>

    <div class="pane">
      <!-- The strip underneath is the chart's own brush: drag its window to
           pan, drag an edge to resize, and the plot above follows. An earlier
           draft put a separate volume chart there, which read as a range
           selector, did not act like one, and fell out of register with the
           plot the moment you zoomed. Volume lives in the grid instead. -->
      <SvChart spec={price} legend={false} zoomable brush brushHeight={74} />
    </div>
  </div>
</section>

<style>
  .wrap {
    display: flex;
    flex-direction: column;
    flex: 1;
    min-height: 0;
    gap: 10px;
  }
  .chrome {
    display: flex;
    align-items: center;
    gap: 10px;
    flex-wrap: wrap;
    flex: none;
  }
  .note {
    font-size: 12px;
    color: var(--sg-muted, #64748b);
  }
  .seg {
    display: inline-flex;
    border: 1px solid var(--sg-border, #e2e8f0);
    border-radius: 8px;
    overflow: hidden;
  }
  .seg button {
    font: inherit;
    font-size: 12px;
    padding: 4px 11px;
    border: 0;
    background: var(--sg-bg, #fff);
    color: var(--sg-fg, #0f172a);
    cursor: pointer;
  }
  .seg button + button {
    border-left: 1px solid var(--sg-border, #e2e8f0);
  }
  .seg button.on {
    background: var(--sg-accent, #2563eb);
    color: var(--sg-on-accent, #fff);
  }
  .chk {
    display: inline-flex;
    align-items: center;
    gap: 5px;
    font-size: 12px;
    color: var(--sg-fg, #0f172a);
  }

  .body {
    display: flex;
    flex: 1;
    min-height: 0;
    gap: 12px;
  }
  .grid-host {
    flex: 1;
    min-width: 0;
    min-height: 0;
  }
  .pane {
    flex: none;
    width: 600px;
    border: 1px solid var(--sg-border, #e2e8f0);
    border-radius: 10px;
    background: var(--sg-bg, #fff);
    padding: 8px 10px;
  }
</style>

View this example on GitHub

Related documentation

More Charts examples

  • Chart a selection (context menu) - With integrated charting enabled, the right-click menu gains a Chart selected range item: select a block of cells, right-click, and the chart panel opens scoped to that range - the Excel chart-this gesture, built in. The item appears only when charting is on and is appended to the default context menu automatically.
  • Chart view of the grid - The `chart` prop turns the same <SvGrid> into a chart, driven by the grid’s filtered + sorted rows (search + sort flow through). A view of the grid like board and scheduler, but the renderer is free: the grid lazy-loads a built-in view wrapping the standalone SvChart via rowsToChartSpec. Flip Table <-> Chart (bar / line / area) over one source of truth.
  • Integrated charts (no deps) - Chart the grid data with no external charting library. SvGridChart renders a ChartSpec; rowsToChartSpec aggregates the grid current (filtered/sorted) rows into one. Bar, line, area, pie - plus 100% stacked, top-N + Other, an average reference line, and double-click-to-isolate a series. Filter the grid and the chart re-aggregates live.
  • Scatter / bubble chart - A scatter plot maps two numeric measures (x vs y); a bubble chart adds a third via dot radius. type: scatter with series points [{ x, y, r }]. Spend vs revenue, sized by deals, coloured by region, with an average-revenue reference line. Filter the grid and the cloud re-plots.
  • Time-series chart (date axis) - xType: time spaces points by ACTUAL time - irregular date gaps render proportionally - and shows real date ticks. A referenceLines target/SLA line spans the plot; toggle 100% stacked to read each day as a share of its total. Line, stacked area, or stacked bar.