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

@sn0wfr/split-flap

v1.3.0

Published

Zero-dependency split-flap (airport departure board) text display for the web. Vanilla core + native <split-flap> custom element + optional React wrapper.

Readme

@sn0wfr/split-flap

Render text like an airport departure board. Every flap turns forward one glyph at a time until it reaches its target — the same way a Solari board does.

Live demo and documentation →

🇫🇷 Lire ce README en français

  • Zero dependencies. The stylesheet ships inside the bundle and is injected on first use.
  • Runs anywhere. A vanilla core, a native <split-flap> custom element, and a React wrapper. Vue, Svelte, Angular and Astro use the element.
  • Accessible. The value stays readable to screen readers, the flaps are hidden from them, and prefers-reduced-motion skips the animation.
  • Themeable. Everything runs through CSS custom properties.
  • Typed. TypeScript declarations, ESM, CJS, and an IIFE build for a plain <script> tag.

Install

npm install @sn0wfr/split-flap

Or skip the install entirely:

<script src="https://unpkg.com/@sn0wfr/split-flap"></script>

Usage

Vanilla

import { SplitFlap } from "@sn0wfr/split-flap";

const board = new SplitFlap("#board", {
  length: 12,
  chars: "letters",
  duration: 110,
  stagger: 55,
});

await board.set("BOARDING");
await board.set("GATE 24");

Custom element

Importing the entry registers <split-flap>. The text between the tags is both the initial value and the no-JavaScript fallback.

import "@sn0wfr/split-flap/element";
<split-flap length="12" chars="letters" theme="airport">DEPARTURES</split-flap>

Attributes mirror the options in kebab-case (min-steps, extra-loops, pad-char). Set value to animate to a new string, or call element.set(...) for a promise that resolves once the board settles.

React

import { SplitFlap } from "@sn0wfr/split-flap/react";

export function Status({ state }) {
  return <SplitFlap value={state} length={12} theme="amber" />;
}

Changing value animates; changing any other prop reconfigures the instance in place. A useSplitFlap() hook is exported for imperative control.

Styles

The stylesheet is injected automatically. If your bundler owns CSS, pass injectStyles: false and import it yourself:

import "@sn0wfr/split-flap/style.css";

Word mode

A real Solari destination unit does not spell a city out letter by letter — each leaf carries a whole name, and the module turns until it reaches the right one. Pass words to get that:

const dest = new SplitFlap("#dest", {
  words: ["PARIS CDG", "LISBOA", "REYKJAVIK", "SINGAPORE"],
});

await dest.set("REYKJAVIK"); // turns through the list, never backwards

The display becomes a single flap, wide enough for the longest entry. A blank leaf is added at the front, so set("") returns it to empty. chars, length and align belong to character mode and no longer apply.

Accents are matched loosely in both directions, so set("GENEVE") reaches a leaf printed GENÈVE. In markup the list is comma-separated:

<split-flap words="PARIS CDG, LISBOA, REYKJAVIK" value="LISBOA"></split-flap>

Colours

A real board does not paint every leaf the same. colors gives a flap its own background and glyph colour according to what it shows — amber for a delayed service, red for a cancelled one:

import { SplitFlap, flapColors } from "@sn0wfr/split-flap";

const status = new SplitFlap("#status", {
  words: ["ON TIME", "DELAYED", "CANCELLED"],
  colors: {
    DELAYED: flapColors.amber,
    CANCELLED: flapColors.red,
  },
});

await status.set("CANCELLED"); // the leaf that lands is the red one

ON TIME has no key on purpose: it keeps the theme's own flaps, so the two colours that do appear are the two states worth looking at.

The colour rides the leaf rather than the frame: a flap turning from red to green drops a red leaf onto a green one, the way a real board carries the colour on the leaf itself.

Keys are entries — a glyph in character mode, a whole word in word mode — and are matched the way the value is, so SUPPRIME reaches a leaf printed SUPPRIMÉ. A bare CSS colour is shorthand for the background; the long form takes the glyph colour too, which matters the moment a background is light:

new SplitFlap("#gate", {
  chars: " 0123456789",
  colors: {
    1: "#a32b22", // background only
    2: { bg: "#d9a406", color: "#241802" }, // dark glyph on amber
    3: { bg: "#1c7a45", bgBottom: "#196b3d" }, // two-tone leaf
  },
});

flapColors ships six ready-made pairs — red, orange, amber, green, blue, slate — each one a background and a glyph colour that clears WCAG AA together. A background on its own leaves the theme's near-white glyph in place, which is fine on a dark colour and invisible on a light one.

That is the rule of thumb: colour the exceptions, not the rule. On a board where most rows are running to time, painting them all makes a field of colour the exceptions have to fight their way out of. Leaving the normal case bare does mean the paints have to read against bare flaps as well as against each other, so prefer a saturated colour to a muted one — slate on the dark default theme is close enough to the background to vanish beside it.

A key may also be given null outright, for a map built from data rather than written by hand:

colors: { "ON TIME": null, DELAYED: flapColors.amber };

Pass a function to colour by position instead of by value. It is handed the entry and the flap's index, and null means no colour there too:

new SplitFlap("#board", {
  colors: (entry, index) => (index === 0 ? flapColors.red : null),
});

In markup the map reads like an inline style. Semicolons separate the pairs rather than the commas words uses, because a CSS colour is allowed to contain one; JSON is accepted for the full form:

<split-flap words="ON TIME, DELAYED" colors="DELAYED: #d9a406"></split-flap>

Boards

A grid of displays that refresh together, for a departure board or any large sign. Columns are configured exactly like standalone displays.

import { SplitFlapBoard } from "@sn0wfr/split-flap";

const board = new SplitFlapBoard("#departures", {
  labels: ["TIME", "FLIGHT", "DESTINATION"],
  columns: [
    { length: 5, chars: " 0123456789:" },
    { length: 6 },
    { words: ["PARIS CDG", "LISBOA", "REYKJAVIK"] },
  ],
  order: "rows",
  cascade: 110,
});

await board.set([
  ["11:42", "KL441", "PARIS CDG"],
  ["12:05", "TP431", "LISBOA"],
]);

set() takes the values row-major and resolves once the last cell settles. Omit rows and the board sizes itself to the data.

Refresh order

order decides the sequence in which cells start turning, and cascade how many milliseconds separate one rank from the next.

| Order | Effect | | ---------------- | ---------------------------------------------------- | | "simultaneous" | Everything at once. The default. | | "rows" | Row by row — how a departure board actually updates. | | "columns" | Column by column, left to right. | | "cells" | Cell by cell in reading order. | | "random" | Cell by cell, shuffled afresh on every refresh. |

Each of those is a rank function underneath: the board sorts by rank and holds each cell back by rank × cascade. Passing your own is the same shape, which makes a diagonal ripple one line:

board.setOptions({ order: ({ row, column }) => row + column });

Board options

| Option | Type | Default | Description | | ---------- | -------------------- | ---------------- | ----------------------------------------------------- | | columns | SplitFlapOptions[] | required | One entry per column. | | defaults | SplitFlapOptions | — | Applied to every column, before its own options. | | labels | string[] | — | Column headings. Omit for a bare grid. | | rows | number | auto | Fixed row count. Omitted, the board follows the data. | | order | RefreshOrder | "simultaneous" | Sequence cells start turning in. | | cascade | number | 90 | Milliseconds between one rank and the next. | | onSettle | (detail) => void | — | Fired when every cell has settled. |

cell(row, column) hands back the underlying display, so a single column can still be driven on its own. Re-labelling through setOptions({ labels }) updates the headings in place and leaves those displays alive.

Options

| Option | Type | Default | Description | | ---------------------- | ---------------------------------- | ---------------- | -------------------------------------------------------------- | | value | string | "" | Text to display. | | words | string[] | — | Whole words to turn through instead of characters. | | length | number | auto | Number of flaps. Omitted, the board sizes itself to the value. | | chars | AlphabetName \| string | "alphanumeric" | Glyphs each flap cycles through. | | align | "left" \| "center" \| "right" | "left" | Where a value shorter than the board sits. | | padChar | string | " " | Glyph used to fill unused flaps. | | duration | number | 110 | Milliseconds to advance a single glyph. | | stagger | number | 55 | Delay of each flap behind the one to its left. | | jitter | number | 0.18 | Timing jitter, 0–1. | | minSteps | number | 1 | Minimum glyphs any moving flap travels through. | | extraLoops | number | 0 | Extra full revolutions added to every moving flap. | | uppercase | boolean | true | Upper-case the value before display. | | normalize | boolean | true | Strip accents missing from the alphabet (DÉPARTDEPART). | | theme | ThemeName | — | Applied as an sf--<name> class. | | colors | ColorMap | — | Flap background and glyph colour, by entry or by position. | | size | string | 3rem | Shorthand for --sf-size. | | sound | boolean | false | Mechanical click per step. Needs a prior user gesture. | | volume | number | 0.25 | Click volume, 0–1. | | injectStyles | boolean | true | Inject the stylesheet on first use. | | respectReducedMotion | boolean | true | Apply the value with no animation when the OS asks for it. | | ariaLive | "off" \| "polite" \| "assertive" | "off" | Value of aria-live on the root element. |

Built-in alphabets: letters, digits, alphanumeric, full, hex, mixedCase. Pass any string to define your own — order decides how far a flap has to travel, and duplicates are removed.

Methods

| Member | Returns | Description | | ---------------------- | --------------- | ------------------------------------------------------------ | | set(value, options?) | Promise<void> | Animate to a value. options.immediate skips the animation. | | value | string | Read or write the displayed text. | | isAnimating | boolean | True while at least one flap is turning. | | length | number | Number of flaps currently rendered. | | setOptions(patch) | void | Merge new options in. | | stop() | void | Freeze the flaps where they stand. | | randomize() | Promise<void> | Flip to a random value of the current length. | | destroy() | void | Tear the display down and release timers, audio and DOM. |

Events

Dispatched on the root element, and also available as onStart, onFlip and onSettle options.

| Event | detail | | ------------------ | ------------------------ | | splitflap:start | { from, to } | | splitflap:flip | { index, char, final } | | splitflap:settle | { value } |

Theming

A theme is a set of CSS custom properties — no fork required.

.sf--midnight {
  --sf-color: #cfe3ff;
  --sf-bg-top: #10203a;
  --sf-bg-bottom: #0a1729;
  --sf-divider: rgba(0, 0, 0, 0.7);
  --sf-radius: 0.12em;
}
new SplitFlap("#board", { theme: "midnight" });

Shipped themes: airport, amber, vintage, terminal, paper. The full list of variables is in the documentation.

A theme paints the whole display. colors paints one flap at a time, on top of whichever theme is in force.

Browser support

Any browser with the Web Animations API — Chrome 84+, Firefox 75+, Safari 13.1+. Older engines still get correct text, stepped on schedule, without the 3D rotation.

Contributing

See CONTRIBUTING.md. This repository uses Conventional Commits and release-please; commit messages drive the version bump and the changelog.

Licence

MIT