npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@glowbox/vfd

v1.11.0

Published

Vacuum-fluorescent display panel component — the stereo faceplate: a zoo of unlike anodes in one envelope (segment fields, lit word legends, spectrum bars, icons, tuning scale), sharing phosphor persistence, a dimmer, filament haze and filter glass, in th

Readme

@glowbox/vfd

A vacuum-fluorescent display panel component — a sibling rendering core to @glowbox/nixie, @glowbox/led-grid, @glowbox/seven-segment, @glowbox/flip-dot, @glowbox/split-flap and @glowbox/neon.

This is the front of a 90s mini-system: a 14-segment character field, screen-printed word annunciators (DOLBY NR · ST · MONO · REC), a spectrum analyser with peak-hold caps, transport icons, a tuning dial, and the silkscreen labels that were never wired to anything — all in one vacuum envelope, sharing its physics. Zero runtime deps.

yarn add @glowbox/vfd
import { createVfdPanel } from '@glowbox/vfd';

const panel = createVfdPanel(canvas, {
	frame: [320, 64], // the design frame every element box is placed in
	layout: [
		{ kind: 'digits', name: 'main', chars: 8, glyphs: '14seg', x: 8, y: 8, w: 150, h: 30 },
		{ kind: 'legend', name: 'st', text: 'ST', x: 170, y: 6, w: 12, h: 8 },
		{ kind: 'bars', name: 'spec', bands: 12, rows: 8, peakHold: true, x: 196, y: 8, w: 116, h: 40 }
	]
});

panel?.set('main', 'FM 98.50');
panel?.light('st', true);
panel?.setBars('spec', levels); // 0..1 per band; the caps fall on their own

Give it a canvas; it owns the 2D render, the physics, resize, and the render loop — which runs only while something is in flight. A settled panel costs nothing.

What a div grid and a green webfont can't do

Laying these parts out is the easy half, and it isn't why this package exists. The envelope is:

  • Phosphor persistence. An anode keeps glowing after its drive stops, so a falling spectrum bar leaves a tail. persistence sets the release; the attack stays fast, and that asymmetry is the character of the thing. It needs a per-anode brightness integrator across frames — no font, filter or CSS transition reproduces it. It is a stylized control and defaults low (0.05 ≈ 37 ms, two frames): real ZnO:Zn decays in microseconds, so no receiver smeared much — what you remember is the multiplex refresh plus your own eye. Past ~0.45 a character field ghosts into its previous value.
  • One shared multiplex. The filament wires cross the glass in front of everything, the control-grid mesh is continuous across the whole panel rather than per digit, and brightness is the DIMMER — that 2- or 3-position button on the front of the receiver — pulling the entire face down at once, non-linearly.
  • The power-on self-test. selfTest() lights every anode for about a second, then settles. It exists only because the panel knows its own complete anode inventory.
  • Failure, two ways. age runs the franchise wear arc at anode granularity (dim → sparse flicker → dead), plus the one that belongs to vacuum fluorescence: past ~0.6 a multiplex grid goes weak and a vertical band of the panel reads dimmer than the rest, cutting across whichever elements sit in it.
  • The filter glass. filter is the tinted window in front of the tube: it pushes the light toward its own hue and crushes the undriven phosphor ghosts, which is why the things were fitted (filter: 'none' shows a filterless panel — every unlit anode floating there in outline). zones adds extra windows over regions of it: the amber band across a level meter, or the red rectangle a RECORD block needs to be visible at all, since green plastic cannot pass red. A zone belongs to the panel, not to an element, because that is what it is — plastic over a rectangle, tinting whatever sits behind it.

Elements

Every element is placed at x, y, w, h in the panel's frame units and addressed by name. Names are the wiring, so they are required and unique; a duplicate throws.

| kind | drives with | what it is | | -------- | ----------------------- | ------------------------------------------------------------------ | | digits | set(name, str) | character cells: '7seg', '14seg', '16seg' or 'matrix' 5×7 | | legend | light(name, on) | a screen-printed word — one anode, one wire, lights as a unit | | bars | setBars(name, levels) | bands × rows blocks, optional peakHold caps and wedge ramp | | icon | light(name, on) | arbitrary SVG fill data | | scale | set(name, 0..1) | a tuning dial: printed ticks plus discrete cursor blocks | | dots | setDots(name, bitmap) | a raw cols × rows dot grid — the graphic half of a panel | | rule | — (ink) | silkscreen hairlines and boxes that group the face into zones |

Anything drivable also answers clear(name), which is not the same as writing zeros — see below.

Icons are fills, not centrelines. A VFD anode is a patch of phosphor screen-printed onto the plate, so path data straight out of any vector editor works as-is, and a shape with a hole (a cassette's reels, a disc's ring) fills correctly as one anode. Wind glyph outlines the other way inside a rectangle and you have a knocked-out REC.

An icon can share a design frame with its neighbours instead of being fitted to a box: give several the same frame and their path coordinates are read as frame coordinates, so a mechanism cut into thirty anodes stays in register. A framed icon needs no x/y/w/h at all. Every other kind does need a box; omitting one throws.

printed: true turns a legend into silkscreen — ink on the glass, never wired, never lit, unaffected by the dimmer. The dB scale numbers and the source names under the annunciators were these on the real thing.

dots is the graphic half, and why it is not a digits field in disguise: it is addressed by DOT, so it can do the two things a character-addressed field structurally cannot — play an animation, and scroll text smoothly by column instead of jumping a whole character (which, under persistence, reads as two glyphs stacked rather than as motion). Feed it a row-major array or an (x, y) function; row 0 is the top, because what you hand it is an image. Values are 0..1 and fractional values are honest — a multiplexed anode dims by duty cycle, so greyscale needs no dithering. Every dot is an anode, so a 120 × 7 ticker is 840 of them.

Hardware is declared, driving is cheap

setLayout(layout, frame?) re-declares the hardware and re-compiles the whole anode inventory — the one expensive call on the handle. Drive state survives it, carried by element name. setOptions(patch) patches the envelope (phosphor, windows, dimmer, persistence, wear, power) and cannot re-compile, so a wrapper is free to re-send the whole bag on every slider tick; all three of ours do.

setBars and setDots copy what you hand them, so an array you reuse next frame cannot retroactively change the display. A function passed to setDots is the exception: it is kept and sampled each frame, which is the point of it.

clear(name) stops driving an element — or the whole panel, with no argument, the way flip-dot's and split-flap's clear() do. Writing zeros is not equivalent: a bars element with peakHold remembers its caps, and a cap resting on the floor row it never falls below is a lit line across the element for good. That matters as soon as one window has more than one job — an analyser field that becomes a graphic display on the DISPLAY button, as these panels really did: the driver you switched away from has to stop, memory and all. A cleared scale shows no cursor rather than one parked at zero, because a dial with nothing tuned in is an empty scale. Silkscreen is ink and stays. The anodes keep their phosphor tails on the way down.

Driving an element through the wrong call warns rather than silently doing nothing.

The decimal point takes no cell

In every segment mode, . and : ride the cell before them instead of consuming one of their own — what a real driver chip does with its point and colon anodes, and the reason 'FM 98.50' fits eight cells. A 'matrix' cell draws its own . as a glyph, so there the point takes a cell like any other character.

Nothing in a vacuum envelope moves

Anodes are discrete and fixed. That sounds like a restriction until you notice it is how the hardware worked: a tuning cursor is not a sliding block, it is a row of ~24 fixed blocks with one of them driven — so set('tune', 0.42) lights the nearest.

Geometry, not events

The panel attaches no listeners. It answers geometry and leaves the events to you — the same contract as split-flap's cellAt and neon's sectionAt. Both answer from where the element's anodes actually are, not from the box you declared, which is what keeps an icon placed through a shared design frame from claiming the whole panel:

stage.addEventListener('click', (e) => {
	const name = panel.elementAt(e.clientX, e.clientY); // element name | null
	if (name) console.log('tapped', name, panel.elementRect(name));
});

Elements made only of silkscreen are skipped, so a rule box drawn around a zone never swallows the taps meant for what's inside it.

No sound

The first core in the family without a sound module, deliberately. A VFD has no voice — the muting relay's clunk belongs to the receiver, not to the display.

Light theme

A VFD is a physical object with a bezel, so a dark panel on a pale page is a dark panel, correctly — bezel sets the faceplate around the glass, a strip hugging it so canvas past the plate stays transparent (or null for no plate at all, to compose over your own hardware). Unlike neon, this core needs no invented element to live in the light: it was always a dark rectangle screwed into a chassis.

Themes

theme bundles the chassis: 'dark' (the default — a dark grey plate), 'light' (the brushed silver of a 70s receiver, which is what a VFD panel on a pale page actually looked like), or 'auto' to follow the page's prefers-color-scheme. The glass stays dark either way — a phosphor anode needs a dark window — so the theme is the plate. A bezel you set yourself never moves again.

Cost

Dot areas are where the anode budget goes: a dots grid or a matrix field is one anode per dot, so a 120 × 7 ticker is 840 and a full faceplate lands around 1200.

The renderer is built around the one thing that actually costs: shadowBlur. Its price scales with area × radius, so one gaussian per lit anode does not scale and batching them per element does not help — the blurred area is the same. Instead every lit anode is drawn flat into a small offscreen canvas and composited back upscaled, letting the sampler's own filtering be the blur: one pass for the whole envelope. Small dots read better for it too, since a per-anode bloom smeared neighbours together.

node scripts/bench-vfd.mjs for numbers (built package, vsync uncapped, dpr 2, every element re-driven every frame — a harder case than any real panel). Two things worth knowing if you push it further: the loop runs only while something is in flight, so a settled panel is free; and caching the static ghost/silkscreen layers to an offscreen measures slower than re-filling them, which is why they aren't cached.

The layout is pure

compilePanel(frame, layout) is exported and runs headless — no canvas, no DOM. It compiles every element kind down to one flat list of anodes (each a fixed patch with an integer address), which is what lets the physics be a handful of uniform passes over a single Float32Array instead of six per-element special cases. It is also the seam for a 3D consumer: extrude the anode polygons into plates and drive them with driveElement.

Accessibility

The canvas gets role="img" and an aria-label built from label plus what the panel is actually showing (the character fields and the lit legends), so a screen reader reads the display rather than "canvas". label: '' hides it from the a11y tree. Under prefers-reduced-motion the persistence smear and the self-test animation settle instantly.

Framework wrappers

<VfdPanel> ships in @glowbox/svelte, @glowbox/react and @glowbox/vue. Declare the hardware once with frame/layout, then drive content either declaratively through values (keyed by element name; the value's own type picks the wire) or imperatively through the handle — which is what an analyser running at frame rate wants. Each wrapper syncs the hardware on its own effect/watcher, separate from the appearance patch, so hand layout a stable array rather than rebuilding it inline every render.

Licence

MIT.