← Charts

Candlestick Chart

A price chart that builds itself like a live market. The newest candle flickers up and down as the price ticks, its wicks stretching to each new high and low, then it closes and the next one opens. Once the chart is full it scrolls along and the price scale eases to fit.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-candlestick-chart to an element with a height. On its own it makes up a market that never ends; add data-values to build your own candles instead.

Options

Add any of these attributes to the same element.

data-valuesYour candles, oldest first: open,high,low,close for each, separated by spaces.
data-countCandles across the chart. Default 30.
data-speedTime for each candle to form. Default 900 ms.
data-up / data-downCandle colours. Default green and red.
data-prefixText before prices, e.g. $.
data-decimalsDecimal places on prices. Default 2.
data-volumefalse hides the volume bars along the bottom.
data-loopWith data-values: start again after the last candle.

It’s drawn on a canvas, sharp on high-density screens, and stops while it’s off screen. With reduced motion it shows a finished chart.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Candlestick Chart: a price chart that builds itself candle by candle like
    // a live market. The newest candle flickers up and down as the price ticks,
    // its wicks stretching to each new high and low, then closes and the next
    // one opens. Once the chart is full it scrolls along and the price scale
    // eases to fit what's on screen.
    // Usage: <div data-candlestick-chart style="height:260px"></div>
    // Optional: data-values="o,h,l,c o,h,l,c …" (real candles, open, high,
    // low, close, built one by one; default a made-up market that never ends),
    // data-count (candles across the chart, default 30), data-speed (ms per
    // candle, default 900), data-up / data-down (colours, default green and
    // red), data-prefix (before prices, e.g. "$"), data-decimals (default 2),
    // data-volume="false" (no volume bars), data-loop (with data-values: start
    // again after the last candle).
    var DEFAULTS = { count: 30, speed: 900, up: '#10b981', down: '#f43f5e', decimals: 2, hold: 2400 };

    var css =
      '.cdl{position:relative}' +
      '.cdl canvas{position:absolute;inset:0;width:100%;height:100%;display:block}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function setup(el) {
      var d = el.dataset;
      var c = {
        el: el,
        count: Math.max(5, +d.count || DEFAULTS.count),
        speed: +d.speed || DEFAULTS.speed,
        up: d.up || DEFAULTS.up,
        down: d.down || DEFAULTS.down,
        pre: d.prefix || '',
        dec: d.decimals !== undefined ? +d.decimals : DEFAULTS.decimals,
        volume: d.volume !== 'false',
        loop: d.loop !== undefined && d.loop !== 'false',
        data: d.values ? d.values.trim().split(/\s+/).map(function (s) { return s.split(',').map(Number); }) : null,
        visible: false, raf: 0
      };
      el.classList.add('cdl');
      el.setAttribute('role', 'img');
      el.setAttribute('aria-label', c.data ? 'Candlestick chart of ' + c.data.length + ' candles, closing at ' + c.pre + c.data[c.data.length - 1][3] : 'Candlestick chart of a live, made-up price');
      c.canvas = document.createElement('canvas');
      c.canvas.setAttribute('aria-hidden', 'true');
      el.appendChild(c.canvas);
      c.ctx = c.canvas.getContext('2d');
      reset(c);
      el._cdl = c;
      resize(c);
      if (window.ResizeObserver) new ResizeObserver(function () { resize(c); if (!c.raf) draw(c); }).observe(el);
      return c;
    }

    // Start over: some history for a made-up market, nothing for real data.
    function reset(c) {
      c.candles = []; c.shift = 0; c.next = 0; c.done = false;
      c.price = 100;
      if (!c.data) {
        var n = Math.round(c.count * 0.55);
        for (var i = 0; i < n; i++) {
          var o = c.price, h = o, l = o;
          for (var t = 0; t < 10; t++) { tick(c); h = Math.max(h, c.price); l = Math.min(l, c.price); }
          c.candles.push({ o: o, h: h, l: l, c: c.price, v: vol(o, c.price) });
        }
        c.shift = Math.max(0, n + 1 - c.count);
      }
      c.lo = null;
      open(c);
    }

    function vol(o, cl) { return 0.25 + Math.random() * 0.5 + Math.min(1, Math.abs(cl - o) / o * 60); }

    // One made-up price tick: a random walk that drifts back towards 100.
    function tick(c) {
      c.price += (Math.random() - 0.5) * c.price * 0.009 + (100 - c.price) * 0.004;
    }

    function open(c) {
      if (c.data) {
        if (c.next >= c.data.length) { c.forming = null; c.done = true; return; }
        var q = c.data[c.next++];
        c.forming = { o: q[0], h: q[0], l: q[0], c: q[0], v: 0, q: q, age: 0 };
      } else {
        var o = c.candles.length ? c.candles[c.candles.length - 1].c : c.price;
        c.price = o;
        c.forming = { o: o, h: o, l: o, c: o, v: 0, age: 0, tick: 0 };
      }
    }

    // Move the forming candle on by dt ms.
    function advance(c, dt) {
      var f = c.forming;
      if (!f) return;
      f.age += dt;
      var p = Math.min(1, f.age / c.speed);
      if (c.data) {
        // Real candle: open, to one extreme, the other, then close.
        var q = f.q, path = q[3] >= q[0] ? [q[0], q[2], q[1], q[3]] : [q[0], q[1], q[2], q[3]];
        var s = p * 3, i = Math.min(2, Math.floor(s)), t = s - i;
        f.c = path[i] + (path[i + 1] - path[i]) * t;
        f.v = vol(q[0], q[3]) * p;
      } else {
        f.tick += dt;
        while (f.tick > 80) { f.tick -= 80; tick(c); }
        f.c = c.price;
        f.v = vol(f.o, f.c) * p;
      }
      f.h = Math.max(f.h, f.c); f.l = Math.min(f.l, f.c);
      if (p >= 1) {
        c.candles.push({ o: f.o, h: f.h, l: f.l, c: f.c, v: f.v });
        if (c.candles.length > c.count + 2) { c.candles.shift(); c.shift--; }
        open(c);
      }
    }

    function resize(c) {
      var dpr = Math.min(2, window.devicePixelRatio || 1);
      c.w = c.el.clientWidth; c.h = c.el.clientHeight;
      c.canvas.width = Math.round(c.w * dpr); c.canvas.height = Math.round(c.h * dpr);
      c.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    }

    function fmt(c, v) { return c.pre + v.toFixed(c.dec); }

    function draw(c, dt) {
      var ctx = c.ctx, W = c.w, H = c.h;
      if (!W || !H) return;
      var items = c.forming ? c.candles.concat([c.forming]) : c.candles;
      var n = items.length;
      // Scroll: ease the shift towards keeping the newest candle at the right.
      var target = Math.max(0, n - c.count);
      c.shift += (target - c.shift) * (dt ? 1 - Math.exp(-dt / 140) : 1);

      var axis = 46, top = 8, bottom = 6;
      var plotW = W - axis, volH = c.volume ? Math.round((H - top - bottom) * 0.16) : 0;
      var plotH = H - top - bottom - volH - (volH ? 6 : 0);
      var slot = plotW / c.count;

      // Price range of what's on screen, eased.
      var lo = Infinity, hi = -Infinity, vmax = 0.0001;
      items.forEach(function (k, j) {
        if ((j - c.shift + 1) * slot < 0) return;
        lo = Math.min(lo, k.l); hi = Math.max(hi, k.h); vmax = Math.max(vmax, k.v);
      });
      if (!isFinite(lo)) { lo = 99; hi = 101; }
      var pad = (hi - lo) * 0.12 || 1;
      lo -= pad; hi += pad;
      if (c.lo === null || !dt) { c.lo = lo; c.hi = hi; c.vmax = vmax; }
      else {
        var e = 1 - Math.exp(-dt / 260);
        c.lo += (lo - c.lo) * e; c.hi += (hi - c.hi) * e; c.vmax += (vmax - c.vmax) * e;
      }
      var y = function (v) { return top + (1 - (v - c.lo) / (c.hi - c.lo)) * plotH; };

      var ink = getComputedStyle(c.el).color;
      ctx.clearRect(0, 0, W, H);
      ctx.font = '600 10px system-ui, sans-serif';
      ctx.textBaseline = 'middle';
      ctx.textAlign = 'left';
      // Grid and price labels.
      for (var g = 0; g <= 4; g++) {
        var gy = Math.round(top + (plotH * g) / 4) + 0.5;
        ctx.globalAlpha = 0.08; ctx.fillStyle = ink;
        ctx.fillRect(0, gy, plotW, 1);
        ctx.globalAlpha = 0.5;
        ctx.fillText(fmt(c, c.hi - ((c.hi - c.lo) * g) / 4), plotW + 6, Math.min(H - 6, Math.max(6, gy)));
      }
      ctx.globalAlpha = 1;

      ctx.save();
      ctx.beginPath(); ctx.rect(0, 0, plotW, H); ctx.clip();
      var body = Math.max(2, slot * 0.62);
      items.forEach(function (k, j) {
        var x = (j - c.shift + 0.5) * slot;
        if (x < -slot || x > plotW + slot) return;
        var col = k.c >= k.o ? c.up : c.down;
        ctx.fillStyle = col;
        var px = Math.round(x) + 0.5;
        ctx.fillRect(px - 0.5, y(k.h), 1, Math.max(1, y(k.l) - y(k.h)));
        var y1 = y(Math.max(k.o, k.c)), y2 = y(Math.min(k.o, k.c));
        ctx.fillRect(Math.round(x - body / 2), y1, Math.round(body), Math.max(1.5, y2 - y1));
        if (volH) {
          var vh = (k.v / c.vmax) * volH;
          ctx.globalAlpha = 0.35;
          ctx.fillRect(Math.round(x - body / 2), H - bottom - vh, Math.round(body), vh);
          ctx.globalAlpha = 1;
        }
      });
      ctx.restore();

      // The latest price: a dashed line across and a tag on the axis.
      var last = items[n - 1];
      if (last) {
        var ly = Math.round(y(last.c)) + 0.5, lc = last.c >= last.o ? c.up : c.down;
        ctx.strokeStyle = lc; ctx.globalAlpha = 0.7; ctx.lineWidth = 1;
        ctx.setLineDash([3, 3]);
        ctx.beginPath(); ctx.moveTo(0, ly); ctx.lineTo(plotW, ly); ctx.stroke();
        ctx.setLineDash([]); ctx.globalAlpha = 1;
        var ty = Math.min(H - 8, Math.max(8, ly));
        ctx.fillStyle = lc;
        ctx.beginPath();
        if (ctx.roundRect) ctx.roundRect(plotW + 2, ty - 8, axis - 2, 16, 3); else ctx.rect(plotW + 2, ty - 8, axis - 2, 16);
        ctx.fill();
        ctx.fillStyle = '#fff';
        ctx.fillText(fmt(c, last.c), plotW + 6, ty + 0.5);
      }
    }

    function loop(c) {
      var prev = 0;
      function frame(now) {
        if (!c.visible) { c.raf = 0; return; }
        var dt = prev ? Math.min(60, now - prev) : 16;
        prev = now;
        advance(c, dt);
        draw(c, dt);
        if (c.done) {
          c.raf = 0;
          if (c.loop) setTimeout(function () { reset(c); if (c.visible && !c.raf) loop(c); }, DEFAULTS.hold);
          return;
        }
        c.raf = requestAnimationFrame(frame);
      }
      c.raf = requestAnimationFrame(frame);
    }

    // A finished chart, for reduced motion.
    function still(c) {
      if (c.data) {
        c.candles = c.data.map(function (q) { return { o: q[0], h: q[1], l: q[2], c: q[3], v: vol(q[0], q[3]) }; }).slice(-c.count);
      } else {
        while (c.candles.length < c.count) {
          var o = c.price, h = o, l = o;
          for (var t = 0; t < 10; t++) { tick(c); h = Math.max(h, c.price); l = Math.min(l, c.price); }
          c.candles.push({ o: o, h: h, l: l, c: c.price, v: vol(o, c.price) });
        }
      }
      c.forming = null; c.shift = Math.max(0, c.candles.length - c.count); c.lo = null;
      draw(c);
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var c = e.target._cdl;
          c.visible = e.isIntersecting;
          if (c.visible && !c.raf && !c.done) loop(c);
        });
      });
      document.querySelectorAll('[data-candlestick-chart]').forEach(function (el) {
        if (el._cdl) return;
        var c = setup(el);
        if (reduced()) { still(c); return; }
        draw(c);
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-candlestick-chart data-prefix="$" style="height: 260px"></div>

<!-- or, your own candles: -->
<div data-candlestick-chart data-loop style="height: 260px"
     data-values="100,104,99,103 103,106,101,102 102,103,97,98"></div>