smooth trading charts with pixijs & react
Note: This post was written by AI (Claude) after I fed it the code I built and my ramblings about the approach. The ideas, implementation, and code are mine.
Every crypto/finance app needs a price chart. I tried the usual suspects — Chart.js, Recharts, Lightweight Charts — but none of them gave me what I wanted:
- Buttery 60 fps animations when the data updates
- Touch-friendly crosshairs with haptic feedback on mobile
- Total visual control — custom gradients, markers, tooltips, all pixel-perfect
- Tiny footprint — no massive charting library, just what I need
So I built my own on top of PixiJS, a 2D rendering engine that uses WebGL (your GPU) under the hood. The result is a React component that renders thousands of data points without breaking a sweat.
The Big Picture
Our chart has layers, like transparent sheets stacked on top of each other:
┌──────────────────────────────┐
│ 7. Tooltip (price info box) │ ← topmost
│ 6. Overlay (crosshair lines)│
│ 5. Labels (Y-axis prices) │
│ 4. Chart line │
│ 3. Gradient fill under line │
│ 2. Gradient mask │
│ 1. Grid lines │ ← bottommost
└──────────────────────────────┘PixiJS gives us a stage — think of it as the root "scene". We add each layer to the stage in order, so things drawn later appear on top.
Step 1: Setting Up PixiJS Inside React
PixiJS is an imperative library — it doesn't use JSX. We bridge the gap with a useEffect that creates the PixiJS Application and attaches it to a div.
const containerRef = useRef<HTMLDivElement>(null);
const appRef = useRef<Application | null>(null);
useEffect(() => {
const app = new Application();
await app.init({
width: 800,
height: 400,
background: "#ffffff",
antialias: true,
resolution: window.devicePixelRatio || 1,
autoDensity: true,
});
containerRef.current.appendChild(app.canvas);
appRef.current = app;
return () => {
app.destroy(true, { children: true });
};
}, []);resolution: window.devicePixelRatio makes the chart crisp on Retina/HiDPI screens. On unmount, we destroy everything to prevent memory leaks.
Step 2: Mapping Data to Pixels
Every chart needs two functions: one to convert a price to a Y pixel, and one to convert a data index to an X pixel.
const pointSpacing = data.length > 1
? chartWidth / (data.length - 1)
: 0;
const yScale = chartHeight / (yMax - yMin);
const priceToY = (price) =>
padding.top + chartHeight - (price - yMin) * yScale;
const indexToX = (index) =>
padding.left + index * pointSpacing;Why chartHeight - ...? In screen coordinates, Y=0 is the top. But in a price chart, higher prices should be higher on screen (lower Y). So we flip it.
Step 3: Drawing the Price Line
The star of the show — a smooth line connecting all price points:
chartLayer.setStrokeStyle({
width: 2,
color: 0x09090b,
cap: "round",
join: "round",
});
chartLayer.moveTo(indexToX(0), priceToY(data[0].price));
for (let i = 1; i < data.length; i++) {
chartLayer.lineTo(indexToX(i), priceToY(data[i].price));
}
chartLayer.stroke();Step 4: The Gradient Fill
That soft gradient under the line? It's a two-part trick:
Part A: Create a gradient texture on an offscreen canvas, then turn it into a PixiJS texture.
Part B: Mask it to the area under the price line — trace along the price line, then close the path at the bottom. The gradient only appears between the line and the bottom of the chart.
Step 5: Smooth Y-Axis Animations
When new data arrives, the price range might change. Instead of jumping instantly, we lerp toward the target:
function lerp(a, b, t) {
return a + (b - a) * t;
}
yAxis.min = lerp(yAxis.min, yAxis.targetMin, 0.15);
yAxis.max = lerp(yAxis.max, yAxis.targetMax, 0.15);The chart gently "breathes" as it adjusts to new data — instead of snapping around jarringly.
Step 6: Touch-Friendly Crosshair
When a user touches or hovers on the chart, we show crosshair lines and a tooltip. PixiJS doesn't have a built-in "dashed line" API, so we draw lots of small segments:
const DASH = 4;
const GAP = 4;
for (let py = top; py < bottom; py += DASH + GAP) {
overlayLayer.moveTo(x, py);
overlayLayer.lineTo(x, Math.min(py + DASH, bottom));
}
overlayLayer.stroke();On mobile, after 300ms of holding, a pulsing dot appears and we trigger a haptic vibration:
if ("vibrate" in navigator) {
navigator.vibrate(20);
}Step 7: Markers (Buy/Sell Indicators)
Green triangles for buys, red for sells. They sit right on the price line at the exact point the trade happened:
if (marker.type === "buy") {
chartLayer.moveTo(x, y - 12);
chartLayer.lineTo(x - 8, y + 4);
chartLayer.lineTo(x + 8, y + 4);
} else {
chartLayer.moveTo(x, y + 12);
chartLayer.lineTo(x - 8, y - 4);
chartLayer.lineTo(x + 8, y - 4);
}
chartLayer.closePath();
chartLayer.fill(marker.type === "buy" ? 0x22c55e : 0xef4444);Performance Tricks
A few things that keep this chart fast:
- No React re-renders for drawing. All drawing happens in imperative PixiJS code triggered by
requestAnimationFrame. - Texture caching. The gradient texture is cached and only regenerated when the size or color changes.
- TextStyle reuse. Created once, reused across frames.
- Animation only when needed.
requestAnimationFrameonly runs while the Y-axis is transitioning. Once it settles, we stop. - Proper cleanup. On unmount, we destroy the PixiJS app, textures, and all children.
Wrapping Up
Building a chart from scratch sounds intimidating, but PixiJS makes the drawing part surprisingly straightforward. The hardest parts were getting the Y-axis math right (flipped coordinates always trip me up), making the gradient mask line up perfectly, and handling the imperative PixiJS world inside React's declarative world.
The payoff? A chart that renders at 60fps, looks exactly how I want, weighs almost nothing, and works great on both desktop and mobile.
If you're building something where stock charting libraries feel too rigid, give PixiJS a shot. You might be surprised how far moveTo and lineTo can take you.