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

@kinnet-studio/split-flap

v0.3.0

Published

Renderer-agnostic split-flap display engine with Canvas 2D and Pixi renderers

Readme

@kinnet-studio/split-flap

A split-flap display engine for the web. The core handles the drums, the timing, idle spinning and message playlists, and works with any renderer. Canvas 2D and Pixi v8 renderers are included.

Up to 0.1.1 the package was published as @kinnet-studio/split-flaps, which is now deprecated. To upgrade, replace that name in your imports with @kinnet-studio/split-flap.

Install

bun add @kinnet-studio/split-flap
# only for the Pixi renderer:
bun add pixi.js@^8

Concepts

The API is layered; use whichever level you need.

| Layer | What it is | | -------------- | ----------------------------------------------------------------------------------------- | | FlapSequence | The ordered flaps on one drum: characters, whole words, colours, anything with a key. | | FlapUnit | One drum. setTarget(), spin(), stop(), snapTo(), update(dt). | | FlapField | Units sharing a drum, set with one value ('TOKYO' across five units, or one word flap). | | FlapBoard | Rows of named fields. show(), row(i).set(), play(). |

The core never reads the clock. Call update(dt) (milliseconds) yourself, or let a renderer's loop do it. If you drive update(dt) yourself, cap dt (e.g. Math.min(dt, MAX_FRAME_DT), exported by the renderer entry points) so a stalled tab does not jump the display.

Quick start (Canvas 2D)

import {
    CHARSETS,
    defineField,
    FlapBoard,
    FlapSequence,
    textField,
} from '@kinnet-studio/split-flap';
import { CanvasFlapRenderer, textFace } from '@kinnet-studio/split-flap/canvas';

const chars = FlapSequence.chars(`${CHARSETS.alphanumeric}:`);
const cities = new FlapSequence(['', 'TOKYO', 'OSAKA', 'KYOTO']);

const board = new FlapBoard({
    rows: 3,
    schema: {
        time: textField({ sequence: chars, length: 5 }),
        dest: defineField({ sequence: cities, length: 1, cells: 5 }),
        plat: textField({ sequence: chars, length: 2, align: 'right' }),
    },
    stagger: { order: 'column', step: 25 },
});

const renderer = new CanvasFlapRenderer({
    canvas: document.querySelector('canvas')!,
    target: board,
    face: textFace({
        font: '600 26px ui-monospace, monospace',
        color: '#f4f1e8',
        background: '#232326',
    }),
    cell: { w: 28, h: 44 },
    gap: { unit: 3, field: 16, row: 8 },
});
renderer.start();

board.show([{ time: '09:15', dest: 'TOKYO', plat: '3' }]);

Pixi v8

import { PixiFlapView } from '@kinnet-studio/split-flap/pixi';

const view = new PixiFlapView({ target: board, face, cell, gap });
app.stage.addChild(view);
view.attach(app.ticker); // or call view.update(dt) yourself

/pixi imports pixi.js, so install pixi.js@^8 before importing it; the core and /canvas entry points never load Pixi.

If one target is drawn by two renderers, only one of them should advance time. Attach the other with view.attach(app.ticker, { update: false }), which only syncs each tick (or call view.sync() yourself). view.destroy() detaches it, even after the ticker itself was destroyed.

Face painters are shared: the same textFace(...) or custom (ctx, flap, w, h) => void works in both renderers. For ready-made textures use textureFace(flap => texture).

Unit options

| Option | Default | Meaning | | -------------- | ----------- | ----------------------------------------------------------- | | flipDuration | 80 | Milliseconds per flip. | | cycle | 'all' | 'all' shows every flap in between; 'direct' flips once. | | direction | 'forward' | 'shortest' may flip backward when that is shorter. | | unknownFlap | 'pad' | Targets missing from the drum show the pad flap, or throw. |

A flip in progress always completes before a new target takes effect.

Fields

A FieldSpec (defineField, textField, or a board schema entry) takes:

| Option | Meaning | | ---------- | ------------------------------------------------------------- | | length | Number of units in the field. | | toFlaps | Converts a value into one flap per unit. | | align | 'left' (default), 'right' or 'center' for short values. | | overflow | 'truncate' (default) or 'throw' for long values. | | pad | Flap shown by unused units. | | cells | Layout cells each unit occupies (board stagger columns). | | stagger | Field-level start delays between units. | | unit | Per-unit options (see below). |

Field stagger orders: sequential | reverse | random | none. Board stagger orders: column | row | diagonal | random | none.

Playlists, spinning and events

board.play([messageA, { rows: messageB, hold: 8000 }], {
    hold: 5000,
    loop: true,
});
board.spin(); // idle/attract spin until show(), play() or stop()
board.on('flipend', ({ row, field, unit }) => playClick());
board.on('settled', () => console.log('done'));

The hold timer starts once the board has settled on a message.

Finish and colours

style.finish sets the surface: 'gloss' (default, the original look), 'satin' or 'matte'. Matte lowers the moving-flap shade and cast shadow and bakes a soft top-to-bottom light falloff and a paper-like grain into each painted face, so flaps read as printed card. Explicit shade, shadow, grain and light override the preset.

style: {
    finish: 'matte';
} // or { finish: 'matte', shade: 0.25 }

textFace takes a theme (classic, solari, airport, cream, or { color, background }) plus per-flap and per-row colours:

import { FLAP_THEMES, textFace } from '@kinnet-studio/split-flap/canvas';

textFace({
    font: '600 26px ui-monospace, monospace',
    theme: 'solari',
    colors: flap => (flap === 'DELAYED' ? { color: '#ff5a4f' } : undefined),
    rows: row => (row % 2 ? { background: '#343438' } : undefined),
});
style: {
    hingeColor: FLAP_THEMES.solari.hinge;
} // each theme suggests a hinge colour

To change the look of a running board, call setStyle / setFace on the renderer (CanvasFlapRenderer) or view (PixiFlapView). setStyle replaces the whole style, so spread the current one to change a single value:

renderer.setStyle({ ...renderer.style, finish: 'matte' }); // Pixi: view.flapStyle
renderer.setFace(textFace({ font, theme: 'airport' }));

Precedence: per-flap, then per-row, then explicit color / background, then the theme. Custom painters receive a fifth argument { row, field }; set perRow: true on a painter whose output depends on the row so renderers cache its faces per row (textFace does this when rows is given).

Covered-flap stack

Show the flaps under the bottom half, like the edges of a book:

new CanvasFlapRenderer({ /* … */ style: { stack: { count: 3, step: 2 } } });
new PixiFlapView({
    /* … */ style: { stack: { count: 3, step: 2, shade: 0.15 } },
});

count covered flaps each peek out step px below the one in front, darkened shade more per layer (default 0.15). The stack fits inside the cell: the face shrinks by count × step, and layout and canvas size stay the same. The edges are the real earlier flaps on the drum, so colour faces show the previous colours.

React

import { SplitFlapCanvas, useFlapBoard } from '@kinnet-studio/split-flap/react';

function Departures({ rows }: { rows: RowValues<typeof schema>[] }) {
    // One stable board; `value` is shown whenever its content changes.
    const board = useFlapBoard({ rows: 4, schema, value: rows });
    return (
        <SplitFlapCanvas
            target={board}
            face={face} // keep painters stable: module scope or useMemo
            cell={{ w: 28, h: 44 }}
            flapStyle={{ finish: 'matte' }}
            fit="width"
        />
    );
}

How props are handled:

  • cell, gap and flapStyle are compared by content, and face by identity. Changes go through setLayout, setStyle and setFace.
  • target, fit and drive recreate the renderer, so keep target stable (useFlapBoard does).
  • flipCurve, dpr, createCanvas and scheduler are read once, when the renderer is created, so inline functions are fine. To change one later, remount with a new key.
  • drive={false} only draws a board that something else advances.
  • className and style style the wrapper <div>.

value and flapStyle are compared with JSON.stringify. That means:

  • Values that serialize the same count as equal. For example, class instances whose data lives in getters all serialize as {}, so use plain data.
  • Values that can't be serialized (a BigInt, a cycle) are compared by identity instead, so memoize them.

For Pixi, usePixiFlapView(app, options) from /react-pixi adds a view to an Application you manage and returns it (null until app is set). app, target and drive recreate the view. resolution, flipCurve and createCanvas are read once. In the render where one of those changes, the hook still returns the old view, and the same commit destroys it. An effect that uses the view should check for that:

const view = usePixiFlapView(app, { target: board, face, cell });
useEffect(() => {
    if (!view || view.destroyed) return;
    view.position.set(16, 16);
}, [view]);

The view is safe to clean up after the app is destroyed: unmounting after app.destroy() doesn't throw.

Vue

import { SplitFlapCanvas, useFlapBoard } from '@kinnet-studio/split-flap/vue';

const board = useFlapBoard({ rows: 4, schema, value: () => departures.value });
// <SplitFlapCanvas :target="board" :face="face" :cell="{ w: 28, h: 44 }"
//                  :flap-style="{ finish: 'matte' }" fit="width" class="board" />
  • value may be an array, a ref or a getter, and nested changes are tracked. It is compared by content, with the same caveats as in React.
  • The component takes the same props as the React one, with the same rules for what recreates the renderer. class and style fall through to the wrapper.
  • The board returned by useFlapBoard is kept out of Vue's reactivity (markRaw). Do the same for other core objects you put in reactive state.

For Pixi, usePixiFlapView(app, options) from /vue-pixi:

const app = shallowRef<Application | null>(null); // set once it's initialised
const view = usePixiFlapView(app, () => ({
    target: board,
    face, // created once, outside the getter
    cell: { w: 28, h: 44 },
    flapStyle: settings.flapStyle,
}));
  • Hold the app in a shallowRef. A deep ref would proxy the whole Application.
  • Pass the options as a getter over your reactive state.
  • face and target are compared by identity, so create them outside the getter. A painter created inside it is new on every run, so every change would repaint every face.
  • The view is created once the app is set, recreated when app, target or drive changes, and destroyed when the scope is disposed.

Sizing and resizing

cell (unit size), gap and a field's cells (width in cells) set the board's layout size. At runtime:

renderer.setLayout({ cell: { w: 40, h: 60 }, gap: { unit: 4 } }); // re-lay out
renderer.scale = 1.5; // uniform zoom; faces repaint at dpr × scale (sharp text)
renderer.fitTo(width, height, 'contain'); // pick the scale for a box

// Or keep the board fitted to an element (ResizeObserver):
new CanvasFlapRenderer({ /* … */ fit: { element: wrapper } }); // mode 'width'

'width' fills the element's width and lets the height follow, which suits normal page flow (the element's width must not depend on the canvas). 'contain' fits both dimensions and needs an element with a fixed height. renderer.width / height report the displayed size.

Pixi apps own their canvas, so PixiFlapView offers the same pieces without the observer: view.setLayout(...), view.fitTo(width, height, mode) (scales the view and repaints faces at resolution × scale), and view.layoutWidth / layoutHeight (unscaled size):

new ResizeObserver(([entry]) =>
    view.fitTo(entry.contentRect.width, entry.contentRect.height)
).observe(app.canvas.parentElement!);

Sound

/sound plays a short click for every flap that lands, through the Web Audio API. It listens to the core, so it works with any renderer (or none):

import { FlapSound } from '@kinnet-studio/split-flap/sound';

const sound = new FlapSound({ target: board, volume: 0.5 });
// Browsers only allow audio after a user gesture:
addEventListener('pointerdown', () => sound.unlock(), { once: true });

sound.muted = true; // or sound.volume = 0.2; or sound.destroy()

Sound is opt-in: nothing plays unless you create a FlapSound and unlock() it. Pass muted: true to start muted (e.g. behind a "sound on" toggle).

The default click is synthesized (no audio files) and modelled on a recording of a real split-flap module: a crisp, unpitched tick of broadband noise (most of it above 4 kHz) and a short rattle of tiny impacts after it, which run together into an even "trrrr" when flaps fall quickly. Each landing plays one of eight takes of the click, so a run never repeats the exact same sound. The recorded module lands a flap about every 60 ms; flipDuration: 60 gives the same pace. Pass synth to tune the click. For a softer, lower clack:

const sound = new FlapSound({
    target: board,
    synth: {
        frequency: 1100,
        resonance: 0.6,
        noise: 0.91,
        brightness: 0.5,
        attack: 0.003,
        decay: 0.004,
        body: 0.15,
        bodyDecay: 0.022,
        bounceDelay: 0.022,
    },
});

Each click gets a small random pitch, volume and timing change (variation; flaps that land in the same frame are spread over up to 12 ms instead of hitting in unison). At most maxVoices (default 12) clicks overlap; a new landing fades out the oldest. Board clicks are panned left to right by column (pan, 0..1 width), and a soft clipper on the output rounds off the peaks of big bursts instead of letting them distort.

To play real recordings instead, pass them as sample: AudioBuffers, or URLs fetched and decoded on unlock(). With several, each landing plays one at random, which keeps a fast run from sounding like one sound on repeat. Short clips work best: one flap landing, trimmed to start right at the hit.

const sound = new FlapSound({
    target: board,
    sample: ['/sounds/flap-1.wav', '/sounds/flap-2.wav', '/sounds/flap-3.wav'],
});

With sample the synth is never used: landings are silent until the recordings load. If one fails to load, unlock() rejects and the others still play.

Speed

flipDuration (per unit, or per field via unit: { flipDuration }) sets how long one flip takes. To change speed while running, set timeScale on a board, field or unit. It multiplies the dt passed to update(), so flips, stagger delays and playlist holds all scale together:

board.timeScale = 2; // twice as fast
board.field(0, 'dest').timeScale = 0.5; // this field at half of that
board.timeScale = 0; // pause

Scales compound down the tree (board × field × unit). Negative or non-finite values throw.

Custom renderers

Read unit.state ({ current, next, progress, direction }) every frame. flipGeometry(), createFlipCurve() and layout() (exported from /canvas and /pixi) do the maths for you.

Development

bun install
bun run test
bun run typecheck
bun run build
bun run dev   # examples app

The examples app plays recorded flaps by default (examples/flap-sounds/, see its CREDITS.md; they are not part of the published package). Its Sound select switches to the synth. To try your own recordings, put audio files (.wav, .mp3, .ogg, .m4a) anywhere under examples/sounds/ and reload: they show up as "Sound: your recordings". That folder is git-ignored, so the files stay on your machine.

Releasing

Releases are published from GitHub Actions: run the Release workflow on main (or a version/* branch for a patch to an older line).

  • Version bump: auto reads the conventional commits since the last v* tag (bun scripts/next-version.ts); patch, minor and major force one.
  • Dry run: bumps and packs in the runner, then stops without publishing or pushing. It can run on any branch.
  • A release commits chore(release): split-flap x.y.z, tags vx.y.z, publishes to npm with provenance, opens a GitHub release, and, from main, creates a version/x.y.z branch.

npm trusts the workflow through trusted publishing, so no npm token is stored.