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
- Paste the snippet just before
</head>. - Add
data-candlestick-chartto an element with a height. On its own it makes up a market that never ends; adddata-valuesto build your own candles instead.
Options
Add any of these attributes to the same element.
data-values | Your candles, oldest first: open,high,low,close for each, separated by spaces. |
data-count | Candles across the chart. Default 30. |
data-speed | Time for each candle to form. Default 900 ms. |
data-up / data-down | Candle colours. Default green and red. |
data-prefix | Text before prices, e.g. $. |
data-decimals | Decimal places on prices. Default 2. |
data-volume | false hides the volume bars along the bottom. |
data-loop | With 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.
<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><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>