Project Ref // 02

ABOUT VANTAJS

BACK

There is a particular genre of JavaScript library that earns its reputation not through raw technical complexity but through an immediately legible sense of delight. VantaJS is firmly in that category. A handful of script tags and three lines of initialisation and your page has a living, breathing animated background that responds to mouse movement, touch and gyroscope input. The hard labour of WebGL is entirely hidden away.

Below is our live gallery. Use the menu button in the lower-left corner to switch between effects. The Retroscope button in the lower-right is, as we will explain shortly, a bit of fun.

What is VantaJS?

VantaJS is an open-source JavaScript library created by Teng Bao that renders animated WebGL backgrounds directly into any HTML element on a page. It is built on top of Three.js for its 3D effects and optionally uses p5.js for effects that require its drawing primitives. The library abstracts the considerable complexity of both into a simple, declarative API.

The project lives at vantajs.com where you can preview all available effects with live sliders, and the full source code is on GitHub at tengbao/vanta.

What makes it different

Most canvas background libraries require you to manage an animation loop, handle resize events, construct geometry and write shader code yourself. VantaJS handles all of that internally. You provide a target element and an options object. The library instantiates a Three.js scene, attaches a renderer, builds the geometry for the chosen effect, registers pointer and touch listeners, and starts the animation loop. When you are finished with it you call effect.destroy() and the library tears everything down cleanly.

The interaction model is also worth noting. Many effects respond to mouse position by gently biasing the camera or particle velocities towards the cursor. On mobile, gyroscope data is optionally used instead. This gives VantaJS backgrounds a quality that static images and CSS gradients simply cannot replicate: they feel aware of the viewer.

The available effects

The gallery covers twelve of the library’s primary effects. Three.js powers most of them; Trunk and Topology additionally rely on p5.js for their organic drawing behaviour.

  • Clouds — a rolling sky rendered as a WebGL shader with controllable sun position, cloud density and atmospheric colour grading
  • Waves — a displaced plane mesh that simulates ocean surface movement with adjustable wave height and speed
  • Fog — a layered volumetric fog shader with highlight, midtone, lowlight and base colour channels
  • Cells — a Voronoi-like cellular texture that shifts and recolours over time
  • Rings — a set of concentric torus geometries that orbit and pulse
  • Halo — a glowing ring of light with configurable amplitude and scale
  • Globe — a rotating wireframe sphere with a network of arcing connections
  • Net — a proximity-based edge graph where nodes draw lines to neighbours within a set distance
  • Dots — an instanced particle field that reacts to the pointer
  • Birds — a flocking simulation using Reynolds steering behaviour across a configurable population
  • Trunk — a branching tree structure drawn with p5.js, regenerating its form on palette changes
  • Topology — an undulating contour map rendered in p5.js

Code Breakdown

Loading dependencies from CDN

The file loads all its dependencies before the closing </head> tag using <script> tags pointing to jsDelivr and Cloudflare.

<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r134/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vanta@0.5.24/dist/vanta.clouds.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vanta@0.5.24/dist/vanta.waves.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vanta@0.5.24/dist/vanta.rings.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vanta@0.5.24/dist/vanta.birds.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vanta@0.5.24/dist/vanta.fog.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vanta@0.5.24/dist/vanta.globe.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vanta@0.5.24/dist/vanta.net.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vanta@0.5.24/dist/vanta.cells.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vanta@0.5.24/dist/vanta.dots.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vanta@0.5.24/dist/vanta.halo.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.1.9/p5.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vanta@0.5.24/dist/vanta.trunk.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vanta@0.5.24/dist/vanta.topology.min.js"></script>

Three.js must be loaded first because every VantaJS effect script expects THREE to already exist on window when it evaluates. Each VantaJS effect is a separate bundle — you only load what you actually use. p5.js is loaded before trunk and topology because those two effects reach for window.p5 at initialisation time. The version pins (@0.5.24 and r134) are intentional: newer Three.js releases have introduced breaking changes to the buffer attribute API that certain Vanta effects have not yet caught up with.

CSS custom properties and palette switching

The stylesheet uses CSS custom properties to describe the entire visual language of the UI. Two sets of values are defined: a default white-on-dark theme and an overriding set that activates when body.retro is present.

:root {
    --ui-fg: #ffffff;
    --ui-line: rgba(255, 255, 255, 0.32);
    --ui-panel: rgba(8, 12, 18, 0.58);
    --ui-hot: rgba(255, 255, 255, 0.16);
    --ui-glow: rgba(20, 136, 204, 0.45);
    --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Courier New", monospace;
}

body.retro {
    --ui-fg: #8affa0;
    --ui-line: rgba(138, 255, 160, 0.55);
    --ui-panel: rgba(2, 14, 6, 0.72);
    --ui-hot: rgba(138, 255, 160, 0.16);
    --ui-glow: rgba(138, 255, 160, 0.4);
}

Every button, panel, border and glow in the UI reads from these variables rather than from hard-coded colour values. This means that toggling a single class on <body> reprograms the entire interface from white to phosphor green in a single paint. The cascade does the work; no JavaScript loops through DOM elements touching individual style attributes.

The Vanta element and its transition

The #vanta div is the render target. Three.js will append a <canvas> element inside it and drive all GPU output to that canvas.

#vanta {
    position: fixed;
    inset: 0;
    touch-action: none;
    background: #000;
    filter: none;
    opacity: 1;
    transition: filter 0.6s ease, opacity 0.28s ease, background-color 0.6s ease;
}

body.retro #vanta {
    filter: grayscale(1) sepia(1) hue-rotate(75deg) saturate(6) brightness(0.9) contrast(1.25);
}

#vanta.swapping {
    opacity: 0;
}

Two distinct transitions operate here. When switching between effects, the JavaScript adds the swapping class which drops opacity to zero in 280ms, mounts the new effect, then removes the class so the canvas fades back in. This prevents the viewer from seeing the geometry construction flash on screen.

The Retroscope palette shift is handled entirely in CSS using a stacked filter chain on body.retro #vanta. grayscale(1) strips all colour from the WebGL output, sepia(1) adds warm tones, hue-rotate(75deg) swings those tones into green, and saturate(6) brightness(0.9) contrast(1.25) push the result towards that acid-green phosphor character. The 0.6s transition on filter means the palette change sweeps in gradually rather than cutting.

The CRT overlay

The Retroscope effect goes beyond a colour filter. A dedicated .crt layer sits above the Vanta canvas at z-index: 5, invisible by default and fading to opacity: 1 when body.retro is set.

<div class="crt" aria-hidden="true">
    <div class="grain"></div>
    <div class="scanlines"></div>
    <div class="beam"></div>
    <div class="vignette"></div>
    <div class="flicker"></div>
</div>

Each child div contributes a distinct layer of the CRT illusion.

Grain

.grain {
    position: fixed;
    top: -50%;
    left: -50%;
    width: 200%;
    height: 200%;
    background-image: url("data:image/svg+xml,...");
    opacity: 0.05;
    mix-blend-mode: overlay;
    animation: grainMove 0.5s steps(3) infinite;
}

The grain texture is a data URI containing an inline SVG that uses the feTurbulence filter to generate fractal noise. The element is sized at 200% of the viewport in each dimension so that the grainMove animation can shift it by small random percentages without revealing any edges. steps(3) gives the animation a jittery, filmstrip quality rather than smooth motion. mix-blend-mode: overlay composites the noise onto whatever is below it, making it visible on mid-tones while preserving shadows and highlights.

Scanlines

.scanlines {
    position: fixed;
    inset: 0;
    background: repeating-linear-gradient(
        to bottom,
        rgba(0, 0, 0, 0)    0px,
        rgba(0, 0, 0, 0)    1px,
        rgba(0, 0, 0, 0.35) 2px,
        rgba(0, 0, 0, 0.35) 3px
    );
    mix-blend-mode: multiply;
}

A repeating-linear-gradient draws alternating 1px transparent and 1px semi-opaque bands across the full screen. This replicates the discrete horizontal lines of a cathode ray tube display. mix-blend-mode: multiply means the dark bands only darken what is behind them; they do not add any colour of their own.

The beam sweep

.beam {
    position: fixed;
    left: 0;
    right: 0;
    top: -3px;
    height: 3px;
    background: linear-gradient(to right, transparent, rgba(140, 255, 170, 0.55), transparent);
    filter: blur(1px);
    mix-blend-mode: screen;
    animation: sweep 9s linear infinite;
}

@keyframes sweep {
    0%   { top: -3px; }
    100% { top: 100%; }
}

This is the detail that sells the CRT illusion. A 3px high strip of green-tinted light travels from the top of the screen to the bottom over nine seconds on a perfectly linear timing curve, looping immediately. mix-blend-mode: screen makes it additive: it brightens whatever it passes over without obscuring it. The slight blur(1px) softens the hard edge into something that reads as an electron beam rather than a CSS rectangle.

Vignette and flicker

.vignette {
    position: fixed;
    inset: 0;
    background: radial-gradient(ellipse at center, rgba(0,0,0,0) 50%, rgba(0,0,0,0.7) 100%);
}

@keyframes flicker {
    0%, 92%, 96%, 100% { background: rgba(0, 0, 0, 0); }
    93%  { background: rgba(120, 255, 160, 0.05); }
    94%  { background: rgba(0, 0, 0, 0.08); }
    95%  { background: rgba(120, 255, 160, 0.03); }
}

The vignette is a static radial gradient that darkens the corners of the screen, giving the illusion of the curved glass edges of an old monitor. The .flicker element is transparent for most of its 6-second keyframe cycle but fires a rapid three-frame brightness fluctuation between 92% and 96% of the cycle. This irregularity is what makes the Retroscope feel inhabited rather than merely themed.

The EFFECTS configuration array

Inside DOMContentLoaded, all twelve effects are described as plain objects in an array. Each entry carries enough information to mount and style the effect in both palette modes without any switch statements or conditionals scattered through the logic.

var EFFECTS = [
    {
        id: 'clouds', label: 'Clouds', ctor: 'CLOUDS',
        base: { speed: 1 },
        normal: {
            skyColor: 0x1488cc, cloudColor: 0x6dd5fa, cloudShadowColor: 0x183550,
            sunColor: 0xff9919, sunGlareColor: 0xff6633, sunlightColor: 0xff9933
        },
        retro: {
            skyColor: 0x020805, cloudColor: 0x2fae55, cloudShadowColor: 0x000000,
            sunColor: 0x8affa0, sunGlareColor: 0xffffff, sunlightColor: 0x8affa0
        },
        bg: { normal: '#1488cc', retro: '#04120a' }
    },
    // ...and eleven more
];

Each object has:

  • id — a stable string identifier used to select the effect
  • label — the display name rendered in the menu
  • ctor — the key on the global VANTA object, e.g. VANTA['CLOUDS']
  • base — options passed to the constructor that apply regardless of palette
  • normal — options representing the effect’s default colour scheme. For effects without an explicit normal key, the code reads live values back from the instantiated effect immediately after mounting
  • retro — the replacement colours for Retroscope mode, always in the phosphor-green palette
  • bg — the background-color to set on #vanta for each mode, visible in the 280ms swap window before Three.js fills the canvas

The effects that use p5.js carry two additional flags:

{
    id: 'trunk', label: 'Trunk', ctor: 'TRUNK',
    needsP5: true,
    restartOnPalette: true,
    // ...
}

needsP5: true tells the available() guard function to check that window.p5 is defined before allowing the effect to be used. restartOnPalette: true marks that setOptions alone is not enough to repaint p5.js-based effects — the effect must be fully restarted to pick up new colours because p5.js draws onto its own canvas imperatively rather than reading from a reactive state.

Checking availability and building the menu

function available(e) {
    return !!VANTA[e.ctor] && (!e.needsP5 || typeof window.p5 !== 'undefined');
}

EFFECTS.forEach(function (e) {
    var b = document.createElement('button');
    b.type = 'button';
    b.role = 'menuitemradio';
    b.dataset.id = e.id;
    b.setAttribute('aria-checked', 'false');
    b.innerHTML = '<span class="dot" aria-hidden="true"></span>' +
        '<span class="lbl">' + e.label + '</span>';
    if (!available(e)) {
        b.disabled = true;
        b.title = e.label + " didn't load";
    } else {
        b.addEventListener('click', function () { select(e.id); });
    }
    menuList.appendChild(b);
});

The menu is built dynamically from the EFFECTS array rather than hardcoded in HTML. Each button is given a role="menuitemradio" for screen reader semantics and aria-checked to indicate the currently active selection. If an effect’s script failed to load (a network error, for instance), the corresponding button is rendered as disabled with a title tooltip explaining why. The UI degrades gracefully — the rest of the effects remain fully functional.

The console noise suppressor

A small self-invoking function patches console.warn before any effects are mounted.

(function () {
    var NOISE = 'THREE.BufferAttribute: .length has been deprecated';
    var warn = console.warn.bind(console);
    console.warn = function (msg) {
        if (typeof msg === 'string' && msg.indexOf(NOISE) === 0) return;
        warn.apply(null, arguments);
    };
})();

Three.js r134 emits a deprecation warning about .length on BufferAttribute objects. This warning is a known, harmless artefact of using that particular version with VantaJS. Rather than pollute the browser console with dozens of repeated messages during development and in production, the patch intercepts any call to console.warn and silently drops the specific message whilst passing everything else through to the original function unchanged. The bound reference warn is stored before patching so nothing else is broken.

Mounting an effect

function mount(id) {
    var next = EFFECTS.filter(function (e) { return e.id === id; })[0];
    if (!next || !VANTA[next.ctor]) return;

    if (effect) {
        try { effect.destroy(); } catch (err) { }
        effect = null;
    }

    var opts = Object.assign({}, COMMON, next.base);
    try {
        effect = VANTA[next.ctor](opts);
    } catch (err) {
        console.error('[gallery] ' + next.label + ' failed to start:', err);
        effect = null;
        return;
    }
    def = next;

    if (!def.normalResolved) {
        def.normalResolved = def.normal || Object.keys(def.retro).reduce(function (acc, k) {
            acc[k] = effect.options[k];
            return acc;
        }, {});
    }

    Array.prototype.forEach.call(menuList.children, function (b) {
        b.setAttribute('aria-checked', String(b.dataset.id === id));
    });

    applyMode(true);
}

mount is the core function. It first destroys any currently running effect, wrapping the call in a try/catch because certain effects can throw during teardown if Three.js has already cleaned up their internal renderer. It then constructs the options object with Object.assign, merging the shared COMMON properties — the target element, pointer controls and minimum dimensions — with the effect-specific base options.

The normalResolved logic is worth unpacking. Some effects in the gallery have explicit normal colour sets defined in the configuration. Others do not — their Retroscope palette is derived entirely from the colours they use by default. For those effects, the code reads the current values directly from effect.options (the live options object VantaJS exposes) immediately after mounting and stores them as normalResolved. This means switching back from Retroscope to normal mode has something concrete to restore, regardless of whether the developer specified a normal palette for that effect.

Switching with a crossfade

function select(id) {
    openMenu(false);
    if (swapping || (def && def.id === id)) return;
    swapping = true;
    vantaEl.classList.add('swapping');
    setTimeout(function () {
        mount(id);
        vantaEl.classList.remove('swapping');
        swapping = false;
    }, 260);
}

select guards against re-selecting the current effect and against triggering a second swap whilst one is already in progress with the swapping boolean. It adds the swapping class to trigger the CSS opacity transition to zero, waits 260ms for that transition to complete, mounts the new effect, then removes the class so the canvas fades back to full opacity. The menu is closed before the swap begins so the user never sees the half-constructed state.

Applying palette mode

function applyMode(justMounted) {
    document.body.classList.toggle('retro', isRetro);
    if (effect && def) {
        effect.setOptions(isRetro ? def.retro : def.normalResolved);
        if (def.restartOnPalette && !(justMounted && !isRetro)) {
            try { effect.restart(); } catch (err) {
                console.error('[gallery] restart failed:', err);
            }
        }
        vantaEl.style.backgroundColor = isRetro ? def.bg.retro : def.bg.normal;
    }
    retroLbl.textContent = isRetro ? 'Normal' : 'Retroscope';
    retroBtn.setAttribute('aria-pressed', String(isRetro));
}

applyMode is called both when the Retroscope button is clicked and at the end of every mount call. It toggles body.retro, which cascades the CSS variable overrides and activates the CRT overlay. It then calls effect.setOptions() with the appropriate colour set. For p5.js-based effects the restartOnPalette flag triggers effect.restart() — but only when not in the initial mount of a normal-palette state, which the justMounted && !isRetro guard prevents. Finally it updates the button’s label and aria-pressed attribute so the interface honestly reflects the current state.

Hover ripple and pointer tracking

The menu uses a throttled pointermove listener to track cursor position for a radial gradient spotlight on each button.

menuList.addEventListener('pointermove', function (ev) {
    if (ev.pointerType !== 'mouse') return;
    var b = ev.target.closest && ev.target.closest('button');
    if (!b || b.disabled) return;
    hoverPending = { el: b, x: ev.clientX, y: ev.clientY };
    if (!hoverFrame) hoverFrame = requestAnimationFrame(flushHover);
});

function flushHover() {
    hoverFrame = 0;
    if (!hoverPending) return;
    var r = hoverPending.el.getBoundingClientRect();
    hoverPending.el.style.setProperty('--mx', (hoverPending.x - r.left) + 'px');
    hoverPending.el.style.setProperty('--my', (hoverPending.y - r.top) + 'px');
    hoverPending = null;
}

Rather than updating CSS custom properties on every pointermove event (which fires many times per frame), the handler stores the pending update and schedules a single requestAnimationFrame flush. This means the DOM write happens at most once per frame, aligned with the browser’s rendering pipeline. The --mx and --my properties feed the radial gradient in the button’s ::before pseudo-element, which follows the cursor inside the button’s bounding box to create a spotlight-style hover glow. There is also a rowScan keyframe animation triggered on hover that sweeps a brighter strip horizontally across the button — a subtle echo of the CRT beam in the interface chrome itself.

Cleanup on page hide

window.addEventListener('pagehide', function () {
    if (effect) { try { effect.destroy(); } catch (err) { } effect = null; }
});

pagehide is preferred over unload for back-forward cache compatibility. When the user navigates away, the active VantaJS effect is destroyed, releasing the WebGL context and cancelling the animation frame loop. Leaving a WebGL context open on a hidden page can prevent the browser from reclaiming GPU memory, so this cleanup is important rather than cosmetic.


The Retroscope: A Bit of Fun

The Retroscope mode was entirely a bit of fun. The practical brief was to build a gallery demonstrating VantaJS effects. The addition of a CRT palette mode was not in any requirements document.

The idea was simple: what would these fluid, GPU-rendered, thoroughly modern animations look like if you viewed them through the screen of a 1985 green-phosphor terminal? The answer, it turns out, is rather good. The Waves effect in particular becomes something strange and almost threatening in monochrome green. The Birds flocking simulation looks like a radar sweep. The Fog becomes genuinely eerie.

The implementation was a discipline in not reaching for canvas or WebGL. Everything in the CRT overlay is CSS: gradients for scanlines and vignette, a data URI SVG filter for grain, a moving linear gradient for the beam and keyframes for the flicker. The colour transformation is a single stacked filter property on the Vanta canvas. The palette switching in the VantaJS effects themselves is handled via setOptions() — the API VantaJS exposes precisely for this purpose. No pixels were hurt in the production of the Retroscope.