DEV Community

Andrew Courtice
Andrew Courtice

Posted on

I built a chart for the web, then rendered it in my terminal by changing one line 🤯

Here is something that has annoyed me for years; more than a decade at this point...

When you start a graphics project on the web, one of the first decisions you make is also one of the most difficult to change: Canvas or SVG? Canvas is fast and pixel-pushy but gives you nothing. No objects, no events, no hierarchy, just a bag of imperative draw calls you get to babysit forever. SVG is lovely and declarative and inspectable, right up until you put ten thousand nodes in the DOM and your framerate falls off a cliff.

So you pick one. And because the two APIs look nothing alike, that pick calcifies. Six months later "can we also render this server-side?" turns into a rewrite instead of a config change. Of course, since the introduction of AI tooling a rewrite is far less daunting, but Ripl makes this a non-issue altogether.

Ripl (pronounced ripple) is a small library that refuses to make you choose. You write your drawing code once, against a single API, and point it at whatever surface you want. Canvas. SVG. A terminal. Switching between them is a one-line change, and I promise that isn't headline exaggeration.

I'm going to prove it by building an animated line chart, and then rendering that exact same chart in a terminal using braille characters. No rewrite. The drawing code doesn't move a muscle.

Let's get into it.

The whole idea in three lines

Ripl's mental model is refreshingly boring, which is exactly what you want when it comes to rendering on the web. There's a context (the surface you draw on), there are elements (the things you draw), and you render the elements to the context.

import {
    createCircle,
    createContext
} from '@ripl/web';

const context = createContext('.chart');

createCircle({
    fill: '#6366f1',
    cx: context.width / 2,
    cy: context.height / 2,
    radius: 40,
}).render(context);
Enter fullscreen mode Exit fullscreen mode

@ripl/web gives you a Canvas context by default. Now watch what it takes to make that identical circle an SVG element instead:

import { createContext } from '@ripl/svg'; // 👈 the only change
import { createCircle } from '@ripl/web';
Enter fullscreen mode Exit fullscreen mode

That's it. That's the whole trick. The createCircle call, the properties, the .render(): none of it knows or cares what it's drawing to. The context is an abstraction over "how do I actually put a shape on a surface," and everything above it is blissfully ignorant of the answer.

If you've ever used Two.js, this idea will feel familiar. Ripl borrows its context-agnostic rendering from there, cribs its scales and interpolation from D3, and models its hierarchy, style inheritance, events, and querying on the DOM and CSSOM. If you know CSS, you already know how a fill set on a group cascades down to its children. That's not an accident. It's the whole design philosophy.

Right, enough philosophy. Let's build something real.

Setting the stage: scene + renderer

For a single static circle, .render() is fine. For a chart with a dozen moving parts and an entry animation, you want two helpers doing the bookkeeping.

A Scene is a special group bound to a context. It owns the render lifecycle: clearing the surface, drawing children in z-order, and (critically for us later) re-rendering when the container resizes. A Renderer wraps the scene in a requestAnimationFrame loop and gives you a transition() method for animation. It's smart enough to stop ticking when nothing's happening, so it's not burning CPU while your chart sits still.

import {
    createContext,
    createScene,
    createRenderer
} from '@ripl/web';

const context = createContext('#root', { buffer: false });
const scene = createScene(context);
const renderer = createRenderer(scene);
Enter fullscreen mode Exit fullscreen mode

Three lines of setup, and we never have to think about the render loop again.

Mapping data to pixels with scales

Every chart is, deep down, a function that turns data into coordinates. A revenue of £82k needs to become "212 pixels from the top." D3 people call these functions scales, and Ripl ships eleven of them.

We only need the workhorse: scaleContinuous, a linear map from an input domain to an output range.

import { scaleContinuous } from '@ripl/web';

const VALUE_SCALE = scaleContinuous([0, 1], [40, 120]);
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const VALUES = Array.from({ length: 12 }, () => VALUE_SCALE(Math.random()));
const COLOR = '#6366f1';
const DURATION = 1600;
Enter fullscreen mode Exit fullscreen mode

Now here's the single most important idea in this whole article, and it's the reason the terminal trick works at the end:

Every coordinate in the chart is derived from context.width and context.height. Nothing is hard-coded.

A chart built from fixed pixel values assumes it lives in a fixed-size box. A chart built from proportions of its context fits any surface you give it: an 800px canvas, a fluid SVG, or an 80-column terminal. So all our geometry flows from the context's current size:

function getArea() {
    const marginX = context.width * 0.1;
    const marginY = context.height * 0.12;

    return {
        left: marginX,
        right: context.width - marginX,
        top: marginY,
        bottom: context.height - marginY,
    };
}

function getScales(area) {
    return {
        x: scaleContinuous([0, VALUES.length - 1], [area.left, area.right]),
        y: scaleContinuous([0, Math.max(...VALUES) * 1.1], [area.bottom, area.top]),
    };
}

function getPoints(scales) {
    return VALUES.map((value, index) => [scales.x(index), scales.y(value)]);
}
Enter fullscreen mode Exit fullscreen mode

Note the y scale runs from area.bottom to area.top, which is how you flip the browser's "y grows downward" into "up means more," for free, just by ordering the range.

Drawing the furniture

Now the elements. Ripl has all the primitives you'd expect: arc, circle, rect, line, polyline, polygon, ellipse, path, text, image. We'll use a handful.

I'm creating every element with its geometry zeroed out and its opacity at 0. That's deliberate: we'll position everything in a single layout() pass, and animate the opacity up as an entrance. Elements in Ripl are just stateful objects, so you can new them up now and mutate their properties whenever you like.

import {
    createLine,
    createText,
    createPolyline,
    createCircle
} from '@ripl/web';

const gridLines = [0, 1, 2, 3].map(() => createLine({
    x1: 0,
    y1: 0,
    x2: 0,
    y2: 0,
    stroke: '#a1a1aa',
    lineWidth: 1,
    opacity: 0,
}));

const tickLabels = gridLines.map(() => createText({
    x: 0,
    y: 0,
    content: '',
    fill: '#9ca3af',
    font: '11px sans-serif',
    textAlign: 'right',
    textBaseline: 'middle',
    opacity: 0,
}));

// Label every OTHER month, so a narrow viewport (like a terminal!) stays legible.
const monthLabels = MONTHS
    .filter((_, index) => index % 2 === 0)
    .map(month => createText({
        x: 0,
        y: 0,
        content: month,
        fill: '#9ca3af',
        font: '11px sans-serif',
        textAlign: 'center',
        textBaseline: 'top',
        opacity: 0,
    }));

const line = createPolyline({
    points: [],
    stroke: COLOR,
    lineWidth: 3
});

const markers = VALUES.map(() => createCircle({
    cx: 0,
    cy: 0,
    radius: 0,
    fill: '#ffffff',
    stroke: COLOR,
    lineWidth: 2,
}));
Enter fullscreen mode Exit fullscreen mode

That "label every other month" comment isn't decoration. It's foreshadowing. When this chart is 80 characters wide instead of 800 pixels, twelve labels won't fit but six will. Building for the narrowest target keeps you honest.

Laying it all out

layout() is the one function that turns the current context size into concrete positions. Call it once now; we'll call it again on every resize.

function layout() {
    const area = getArea();
    const scales = getScales(area);
    const points = getPoints(scales);

    const maxDomain = Math.max(...VALUES) * 1.1;
    const ticks = gridLines.map((_, i) => (maxDomain * (i + 1)) / gridLines.length);

    gridLines.forEach((gridLine, i) => {
        const y = scales.y(ticks[i]);

        gridLine.x1 = area.left;
        gridLine.x2 = area.right;
        gridLine.y1 = y;
        gridLine.y2 = y;
    });

    tickLabels.forEach((label, i) => {
        label.content = Math.round(ticks[i]);
        label.x = area.left - 8;
        label.y = scales.y(ticks[i]);
    });

    monthLabels.forEach((label, i) => {
        label.x = scales.x(i * 2);
        label.y = area.bottom + 8;
    });

    line.points = points;

    markers.forEach((marker, i) => {
        marker.cx = points[i][0];
        marker.cy = points[i][1];
    });

    return points;
}

const points = layout();

scene.add([
    ...gridLines,
    ...tickLabels,
    ...monthLabels,
    line,
    ...markers
]);
Enter fullscreen mode Exit fullscreen mode

scene.add() takes the whole flat list of elements. Under the hood the scene hoists them into a single buffer so rendering is O(n) rather than a recursive tree-walk, but you don't have to think about that. You just hand it your elements.

Making it move

Here's where Ripl stops being "nice" and starts being fun. renderer.transition() animates an element (or an array of them) from its current state to a target state, over a duration, with an easing function. It returns a promise, so animations compose.

First, the line drawing itself on, left to right. interpolatePath is the magic ingredient. It interpolates the polyline's points so the stroke grows along its length instead of just cross-fading into existence:

import {
    easeOutCubic,
    interpolatePath
} from '@ripl/web';

renderer.transition(line, {
    duration: DURATION,
    ease: easeOutCubic,
    state: {
        points: interpolatePath(points)
    },
});
Enter fullscreen mode Exit fullscreen mode

Now the markers. I want them to pop in one after another, timed so each one appears just as the drawing line reaches it. transition() accepts a callback that runs per element and receives its index, so staggering is a one-liner:

renderer.transition(markers, (marker, index, length) => ({
    duration: 350,
    delay: (index / length) * DURATION,
    ease: easeOutCubic,
    state: {
        radius: 4
    },
}));
Enter fullscreen mode Exit fullscreen mode

That delay: (index / length) * DURATION is the entire stagger. First marker fires immediately, last one fires as the line finishes. Chef's kiss.

Finally, fade the furniture up so the axes and labels arrive gently rather than blinking into place:

renderer.transition(gridLines, {
    duration: 600,
    ease: easeOutCubic,
    state: {
        opacity: 0.35
    },
});

renderer.transition([...tickLabels, ...monthLabels], {
    duration: 600,
    ease: easeOutCubic,
    state: {
        opacity: 1
    },
});
Enter fullscreen mode Exit fullscreen mode

One line for responsiveness

Remember the scene emits a resize event, and remember layout() derives everything from the current context size? Wiring up full responsiveness is therefore this:

scene.on('resize', layout);
Enter fullscreen mode Exit fullscreen mode

Resize the window, the container changes size, the scene fires resize, layout() re-runs against the new dimensions, and every element repositions itself. The renderer's already looping, so it just paints the new frame. No ResizeObserver boilerplate, no debounce dance.

The payoff: same chart, different universe

We now have a complete, animated, responsive line chart. Here's the moment I promised you.

To render it as SVG instead of Canvas, we change the context's import, and nothing else:

import { createContext } from '@ripl/svg';
Enter fullscreen mode Exit fullscreen mode

Every createLine, every scaleContinuous, every renderer.transition above is byte-for-byte identical. The polyline that was a set of canvas draw commands is now a real <polyline> node you can inspect in devtools. Same chart, different DOM.

And now the party trick: the terminal.

@ripl/terminal rasterizes your shapes onto a grid of Unicode braille characters (U+2800 to U+28FF). Each character cell packs a 2x4 grid of dots, so you get 8x the resolution of plain text, and it maps your colours to ANSI truecolor escape sequences.

In Node, the drawing code is still the same. Only the context creation changes, because a terminal needs an output to write to and a logical coordinate space to draw in:

import '@ripl/node';

import {
    createNodeOutput,
    createTerminalContext
} from '@ripl/node';

const output = createNodeOutput();
const context = createTerminalContext(output, {
    // Author in an 800x600 space; Ripl scales + letterboxes it into the character grid.
    logicalWidth: 800,
    logicalHeight: 600,
});
Enter fullscreen mode Exit fullscreen mode

That logicalWidth / logicalHeight pair is the unsung hero. By default a terminal context's coordinate space is the braille grid. An 80x24 terminal is a mere 160x96 pixels, and a chart authored in the hundreds would overflow it instantly. Give it a logical size and it uniformly scales and centres your scene into whatever grid it has, exactly the way a canvas maps CSS pixels onto its device backing store. Circles stay circular. Your 800x600 chart just fits.

This is also the moment that "label every other month" pays off. On a canvas, cramming all twelve labels was fine. In 80 columns, six is all you get, and because we built for the narrow case from the start, the terminal version doesn't clip. It just works.

So... the terminal setup is a little more than one physical line, because a terminal isn't a DOM element, so you have to hand it an output and a logical size. But the part that actually matters does not change at all: your drawing code, your scales, your animations, your layout logic. That's the promise. The surface is a detail you configure at the edge, and everything inside stays put.

Where this actually earns its keep

The terminal thing is a fantastic demo, but it's not just a party trick:

  • CI/CD and monitoring: render a trend chart straight into pipeline logs or a status dashboard, no browser, no screenshot service.
  • CLI tools: ship real visualisations in a dev tool without asking anyone to open a web page.
  • Server-side and headless: generate the same chart on the server that you show in the browser, from one codebase.

And if you don't fancy hand-rolling every axis and marker, @ripl/charts gives you 25 pre-built, animated chart types (bar, area, pie, scatter, stock/candlestick, sankey, treemap, and more) with tooltips, legends, crosshairs and grids included. The from-scratch version we built here is the fun way to understand what's happening underneath.

One honest caveat about the terminal target: it's the most constrained of the three. No pointer events, gradients, or transforms there yet, since braille dots can only do so much. On Canvas and SVG, the events, hit-testing, and interactivity are all there.

Go break it yourself

The best part is that you don't have to take my word for any of this. The Ripl playground has this exact chart loaded and ready. There's a dropdown to flip the render target between Canvas, SVG, and Terminal, no import editing required. Just pick one and watch the same code repaint itself onto a completely different surface.

Go change the target. Watch the braille dots assemble themselves into your line chart. Then tell me you're not a little bit delighted.

I was. That's why I wrote this. And if after all that you're still not convinced, why not have a peek at how this extends into 3D space.

I'd love to hear your thoughts in the comments, or if you create something cool in the Ripl playground, post the link in the comments also.

Happy coding!

Top comments (0)