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

mullion

v0.4.0

Published

A tiling layout for a page you already have: it adopts the document, puts it back when the window narrows, and tells a pane nobody is looking at to stop working.

Readme

mullion

ci

A tiling layout for a page you already have.

A mullion is the bar that divides a window into panes. This is that, for the web: splits, dividers, tabs, a drawer and key bindings, over a document somebody else wrote.

The playground: an edit that reruns the preview; the Console stacked over the Preview, which stops drawing as the Activity chart shows; a pane closed to the drawer and another split out of it; a zoom; and a switch of layouts

Try it — a small code playground, tiled, on a window wider than 60em; on anything narrower, the same page untiled.

npm install mullion

No dependencies, no framework, no build step. One function and a stylesheet.

What makes it different

Every other tiling layout for the web — golden-layout, dockview, lumino, FlexLayout, rc-dock — asks you to render into its containers, from a configuration object, usually inside a framework. mullion does the opposite three times over.

It adopts the document. Mark elements data-pane and they are moved into a layout. Every id survives and every component keeps the element it was handed, so nothing that found a box by name stops finding it.

<section id="editor" data-pane data-pane-title="Editor" data-pane-min="320">
  …whatever was already here…
</section>

Turn it off and you have your page back. Below the threshold — a narrow window, or a screen with only a finger to point with — every element goes back under its own parent, folded the way it was. The untiled page is the fallback, not a second mobile layout, so there is one markup, one stylesheet and one set of tests.

A pane nobody is looking at is told so. onShow is the contract, and it is the reason tiling can pay for itself rather than cost: two canvases stacked as tabs draw one picture, not two. A pane is out of sight behind another tab, in the drawer, behind a zoomed pane, folded, off with its mode, scrolled more than 100px out of the window when the page is not tiled, or in a browser tab or window nobody is looking at.

import { createPanes } from 'mullion';
import 'mullion/panes.css';

const panes = createPanes({
  root: document.getElementById('layout'),
  catalog: ['editor', 'console', 'inspector'],
  mode: 'default',
  layouts: {
    default: {
      dir: 'row', size: [0.6, 0.4], kids: [
        { dir: 'col', size: [0.7, 0.3], kids: [
          { tabs: ['editor'] },
          { tabs: ['console'] }] },
        { tabs: ['inspector'] }],
    },
  },
  onShow: (id, on) => { if (id === 'console') tail.running = on; },
});

The layout

A tree of splits and leaves, and it is plain data — yours to store, diff or write by hand:

{ dir: 'row', size: [0.62, 0.38], kids: [
    { dir: 'col', size: [0.7, 0.3], kids: [
        { tabs: ['editor'] },
        { tabs: ['console'] }] },
    { tabs: ['inspector'] }] }

A leaf holding more than one pane is tabs. The panes no leaf holds are the drawer: listed above the layout, one click from coming back. Nothing a person does destroys a pane: closing one puts it away, or, for a pane the page added as ephemeral, asks the page (see How a pane ends). Panes come and go only when the page says so, with add and remove, and even then the element is the page's: mullion makes none and deletes none.

Brought back, from the drawer or by present(), a pane goes where it was: into the stack it left, or — if it was the last pane in its leaf and the split around it collapsed — beside the same neighbor, on the same side, with the same share. Where that neighbor has gone too, it goes to the pane last focused.

What a layout may hold, how a kept one is read back and what is kept is written down in docs/layout.md, with cases in test/layouts.json that any other implementation of the same model can run.

What a person can do

| | what it does | |---|---| | drag a tab | onto a tab strip to put it there, onto a pane to stack, onto an edge to split, onto the drawer to close; Esc to take it back | | drag a closed pane | out of the drawer, the same way | | the cross on a tab | close it: to the drawer, or for an ephemeral pane, ask the page | | ← → Home End on a tab | the tab that way along its strip, or the first or last | | a divider | drag, or focus it and use the arrows; double-click to even up | | Alt + arrow | move the focus to the pane that way | | Alt Shift + arrow | move the pane that way | | Alt \ / Alt - | split the pane in front off right / down; in a leaf of one, open the first closed pane there | | Alt Enter | zoom the pane in front to fill the layout, or back | | Alt W | close the pane in front, as its cross does | | Alt 0 | forget the saved layout and start over (or the reset button, or reset()) |

In a page that reads right to left, every direction is the one on the screen: the arrow pointing left along a strip is the next tab, a divider goes the way it is dragged, and a pane dropped on or moved off a left edge goes on the left.

Every command is a chord with Alt in it, because wherever the bare letters already mean something — a text editor, a chat composer, a keyboard instrument — a tiler that took W for itself would have taken it. All of them are inert while the focus is in a text box, and the modifier and the letters are an argument (keys) for a page where that is still wrong; the arrows are the arrows.

Markup

| attribute | what it says | |---|---| | data-pane | this element is a pane | | data-pane-title | what its tab says (a <details>'s <summary> otherwise) | | data-pane-min | how narrow a divider may make it, in pixels (240) | | data-pane-icon | an image for its tab: beside the title in a strip, in place of it in the corner | | data-pane-off | this pane's mode is not up — set it, or call available() |

data-pane-off and not hidden, deliberately: hidden is a word most pages are already using for something else, and a layout that read it would lose panes to an unrelated el.hidden = true and never put them back.

Options

Everything below is a default rather than a rule.

| option | what it is | default | |---|---|---| | root | where the layout is drawn | — | | catalog | the ids of the panes, in document order | — | | mode | which named layout to open on | — | | layouts | one default tree per mode | every pane in one leaf | | onShow | (id, on) — a pane came into view, or left it | — | | onLayout | (layout, mode) — the layout changed, by a person or through the handle | — | | on | tile when the screen allows it | false | | store | the key a layout is kept under, in storage; the mode is appended | 'panes' | | editing | extra selector for "a key here is text" | — | | media | the screen worth tiling on | (min-width: 60em) and (any-pointer: fine) | | split | a divider's thickness in pixels | 6 | | leaf | a leaf's own floor in pixels | 64 | | edge | how much of a leaf's edge is an edge | 0.2 | | param | query parameter that forces it on or off | 'panes' | | storage | where a layout is kept; its methods may return promises | localStorage | | keys | the commands, merged over the defaults | Alt chords | | strip | 'scroll' keeps tabs and the drawer at their own widths in a row that scrolls | 'shrink' | | lone | whether a leaf with one tab has a tab strip: false for none, or the ids of the panes that go without one | true | | header | where the tabs are: a strip across each leaf, or 'corner', over its top corner (see Styling) | 'strip' | | closed | the drawer's label | 'Closed:' | | drawer | where the closed panes are listed: a row above the layout (null), an element of yours such as a side menu, or nowhere (false) | null | | reset | label for a button that starts the layout over, at the end of the first tab strip (the drawer's row when every leaf is bare) | null | | version | which version of your layouts this is; a layout kept under another is not read back | — | | later | (id) — whether a pane not here yet will be added, so a kept layout keeps its place | none will | | onDiscard | (id) — a person closed an ephemeral pane; end it with remove(id), or don't | — |

And the handle it returns: available(id, on), mode(name), visible(id), present(id, { focus }), close(id), setTitle(id, text), setIcon(id, src), setHeader(kind), add(id, { near, focus, keep }), remove(id), panes(), layout(), setLayout(layout), reset(), overlay(), tiled(), setLayouts(layouts, { store, split, version }), destroy().

present, close and setTitle are about the layout, and with the tiler off — a narrow window — present and close do nothing: the page is the document, and every pane is already on it.

setLayouts swaps in another set of layouts without taking the tiler down: the layout that is up stays kept under its store, as every change to it was kept when it was made, and the mode's layout from the new set replaces it. It is for a page with more than one shape, such as a phone that has one layout upright and another on its side:

const side = matchMedia('(orientation: landscape)');
side.addEventListener('change', () =>
  panes.setLayouts(side.matches ? SIDEWAYS : UPRIGHT,
                   { store: side.matches ? 'app:side' : 'app:up' }));

On a touch screen, strip: 'scroll', a thicker split and a lone list naming the pane that should not spend a row on its name (a keyboard, a toolbar) are the usual changes, with a reset button, since there is no Alt 0 to press; with a media that admits a coarse pointer, the tiler runs there too. lone: false goes further and takes the strip off any pane moved into a leaf of its own, which leaves it no tab to drag or close by.

The row of closed panes above the layout is height a phone's layout wants too. drawer puts the list somewhere else: in an element of yours, such as a side menu, where it is added while the tiler is on and taken out when it goes off, and nothing else in the element is touched. To place it among your own items, give it a box of its own in the menu:

<nav id="menu">
  <div id="menu-panes"></div>          <!-- drawer: this -->
  <button id="settings">Settings</button>
</nav>

Or drawer: false draws no list at all, and the menu is entirely yours: panes() says every pane and where it is, present(id) brings one to the front or out of the drawer, and onShow and onLayout between them hear every change a person makes to either. That is also the way to list every pane in a phone's menu, not only the closed ones, since going to a pane matters there as much as reopening one:

const menu = () => list.replaceChildren(...panes.panes().map((p) => {
  const item = document.createElement('button');

  item.textContent = titles[p.id];
  item.classList.toggle('open', p.where === 'front');
  item.onclick = () => panes.present(p.id);

  return item;
}));

With no drawer, a tab cannot be dragged there to close it; its cross and Alt W still do, and a reset button with no tab strip to sit in is not drawn — reset() is for a menu of your own.

A layout is kept in localStorage under the store and the mode. A page with accounts keeps it on a server instead: storage takes the same three methods, and any of them may return a promise.

  • The page opens on its default, and the kept layout replaces it when it arrives, unless somebody has moved something first.
  • Nothing is written over it while it is on its way. What the page does in the meantime (panes it adds or raises) is done again over it when it comes.
  • Writes go one at a time, in the order they were made, and a read waits for the writes before it, so a slow request never puts an old layout back over a new one.

onLayout is told of every change, by a person or through the handle, with a copy of the tree, which is also what an undo button needs. A change that changes nothing (a pane raised that was already in front) is not one:

createPanes({
  // …
  storage: {
    getItem: (k) => fetch(`/prefs/${k}`).then((r) => (r.ok ? r.text() : null)),
    setItem: (k, v) => fetch(`/prefs/${k}`, { method: 'PUT', body: v }),
    removeItem: (k) => fetch(`/prefs/${k}`, { method: 'DELETE' }),
  },
  onLayout: (layout, mode) => history.push({ layout, mode }),
});

setLayout(layout) goes the other way: it puts a layout up for the mode that is up — a preset, one read out of a link, a step back through that undo — and keeps it and tells onLayout like any other change. It is read the way a kept layout is read: a pane the page does not have is dropped, and a tree that is not the shape of a layout, or that names no pane the page has, is refused and false is returned.

A kept layout outlives the default it was made from, so when you move a pane in your defaults, or add one, somebody who has been here before goes on seeing what they left, with the new pane in the drawer. version is how you tell them: a layout kept under another version, or kept before there was one, is not read back, and your new default comes up.

createPanes({ /* … */ version: 3 });

Panes the page adds

A page that opens things — files in an editor, a chart per query — has panes that are not in its markup when it loads. It puts the element into the document itself, where it should sit when the window is narrow, and then hands it over:

const el = document.createElement('section');

el.id = `file-${name}`;
el.dataset.pane = '';
el.dataset.paneTitle = name;
document.getElementById('files').append(el);

panes.add(el.id, { near: 'editor' });

Tiled, it goes where a kept layout had it, as a tab in near's leaf, or where the person last was, in front and with the focus. With focus: false, for a page putting back what was open last time, a pane going back into a place kept for it goes back behind whatever was in front there.

How a pane ends

The page owns what is in a pane, so the page decides when one ends. There are two kinds:

| | lasting | ephemeral | |---|---|---| | what it is | every pane in the catalog, and add(id, { keep: true }) | what add takes on otherwise | | a person closes it | it goes to the drawer | onDiscard(id) asks the page | | in the drawer | one click from coming back | never | | a layout comes up without it | it stays in the drawer | it is put back in | | it ends | when the page calls remove(id) | when the page calls remove(id) |

A person's close of an ephemeral pane — its cross, Alt W, a drop on the drawer, or close(id) from the page while tiled — moves nothing. It calls onDiscard, and the page ends the pane with remove, now or after asking about unsaved changes, or keeps it by doing nothing:

createPanes({
  // …
  onDiscard: async (id) => {
    if (await unsaved(id) && !await confirmDiscard(id))
      return;

    panes.remove(id)?.remove();
  },
});

A page with no onDiscard offers no way to close an ephemeral pane: no cross, and Alt W does nothing. Nothing here ends a pane on its own.

remove(id) is the only way a pane ends. It leaves the layout the way a close takes it, onShow hears it go, and its element is put back where it was in the document and returned — while tiled that is back in the body, so a page that is done with it removes it in the same breath, as above. The same id can be added again — and a pane later says will come back keeps its place while it is gone, so it comes back to it.

panes() lists every pane with its kind, where it is (front, behind, drawer, off, or document when untiled) and whether it is visible: what a page needs to keep count, close the oldest, or show what is open.

Places kept for panes still to come

A kept layout drops the panes the page does not have when it is read, which is every added pane. later says which ones will come:

createPanes({ /* … */ later: (id) => openFiles.has(id) });

and their places are kept, not drawn, until they are added. It is asked again whenever the layout is kept, so a later that says what really exists lets go of the place for a file that has since been deleted. And it is asked when a pane is removed: one that will come back keeps its place, which is what a component unmounted and mounted again needs (see Frameworks). So a page ending a pane for good says so first — out of openFiles, then remove(id) — or its place is kept for a pane that is not coming.

destroy() is the way back out: every pane under its own parent again, every listener off the window, and the page as it was found. A page that mounts this into something it later unmounts needs it, and so does anyone calling createPanes a second time over the same document — two tilers answer the same chord twice.

Frameworks

mullion moves elements, and React and Vue each expect the elements they render to stay where they put them. Let a framework render the data-pane elements themselves, as ordinary children, and it breaks as soon as it changes that list: React throws on inserting beside a moved pane or removing one, and takes the app down with it; Vue loses a pane it removes, which stays on the screen. With React, events in a pane mullion has moved outside React's own container stop reaching React at all.

So the rule is that the framework never owns the element mullion moves. Two ways to keep it, both tested with React 19 and Vue 3.5.

The page owns the panes, and the framework renders into them. The data-pane elements are the page's markup, or made by the page, and a portal (React) or <Teleport> (Vue) draws inside each. Nothing the framework does can reach the element mullion moves:

const panes = createPanes({ root, catalog: ['editor', 'console'], mode });

function App () {
  return ['editor', 'console'].map((id) =>
    createPortal(<PaneBody id={id} />, document.getElementById(id), id));
}
// Vue: one Teleport per pane
h(Teleport, { key: id, to: document.getElementById(id) }, h(PaneBody, { id }))

A pane opened later is an element made first, then added, then drawn into; closing it is its portal going and remove(id)?.remove().

The framework renders each pane inside a box of its own, which it keeps, and hands the pane over when it mounts and takes it back before it unmounts. The box never moves, so the framework's own inserts and removals find it where they left it:

function Pane ({ id, children }) {
  const panes = useContext(Tiler);   // the handle, made by a parent

  // A layout effect, so the pane is back in its box before React takes
  // the box out.
  useLayoutEffect(() => {
    panes.add(id, { keep: true, focus: false });
    return () => panes.remove(id);
  }, [panes, id]);

  return (
    <div>
      <section id={id} data-pane="" data-pane-title={id}>{children}</section>
    </div>
  );
}

The parent makes the tiler with catalog: [], since the panes arrive as components, and with a later that says yes to every pane the layouts name — so the default's places are kept for them, and so a pane unmounted and mounted again comes back to its place. React's StrictMode and a hidden <Activity> both do that.

In Vue the same is a component with onMounted calling add and onBeforeUnmount calling remove. Under <KeepAlive>, which puts a component away without unmounting it, onDeactivated has to remove it and onActivated add it again; without them the pane stays on the screen while Vue thinks it is gone.

onShow goes into either framework as state: a store read with useSyncExternalStore in React, a reactive object in Vue.

Styling

panes.css draws the layout and nothing that is in it. Which of your boxes takes the room a pane has is yours to say — no stylesheet shipped with a tiler can know that:

body.tiled .panebody > .editor { flex: 1 1 auto; min-height: 0; }
body.tiled .panebody > .toolbar { flex: 0 0 auto; }

The colors are read under this module's own names, each with a fallback, so it stands alone and you can map your theme onto it:

:root {
  --pane-fg: var(--text);       --pane-bg: var(--surface);
  --pane-dim: var(--muted);     --pane-panel: var(--surface-2);
  --pane-line: var(--border);   --pane-held: var(--accent);
}

--pane-held — the selected tab, a divider under the pointer, a drop target — defaults to AccentColor, the color the page's own sliders and checkboxes use. That is the system accent where the browser exposes it (an installed web app, for one) and the browser's default elsewhere. Map it, as above, to use your own instead.

Your overrides go in a stylesheet loaded after panes.css: its selectors are ordinary class selectors, so at equal specificity the later rule wins.

--pane-height is the layout's height, 100dvh by default. A page that tracks visualViewport — because dvh is wrong the moment an on-screen keyboard appears — should set this instead.

With header: 'corner' there is no strip: a leaf's tabs sit over its top corner, in sight while the pointer or the focus is in the leaf or a tab is being dragged. They are the panes' icons (data-pane-icon; the title for a pane without one), a grip for a pane alone in its leaf, and a cross for the one in front, and they drag, drop and take the arrow keys as a strip does. Laid over the pane, they cover whatever its first row has in that corner, so the leaf carries their width as --pane-corner:

.panecorner .toolbar { margin-inline-end: var(--pane-corner, 0); }

Popovers

A pane is a box that scrolls, so a popover inside one is clipped by it. overlay() returns an element over every pane, at the document's origin, as wide as the body's containing block and of no height, so an absolutely positioned child of it is placed and sized as it would be in the body:

import { placePopover } from 'mullion/popover.js';

panes.overlay().append(menu);
placePopover(menu, x, y);   // and held inside the window

The demo

npm run demo   # http://127.0.0.1:8080/demo/

Or the same page on the web.

demo/ is a code playground: an editor per file, a preview, a console, and a chart of what the preview drew. It is an ordinary page of sections, and everything mullion does to it is one createPanes call over them, and a handful of calls back:

  • The preview's program is held still while its pane is off the screen, the chart stops drawing behind a tab, and the Console counts on its tab what arrived while it was hidden. That is onShow, doing the job it is for.
  • New file makes a module that runs before app.js. Its editor is a pane the page adds, ephemeral: closing its tab closes the editor and not the file, and a visit later it is open again where it was (add, onDiscard, remove, later).
  • Undo layout keeps each layout onLayout reports and puts the last one back with setLayout.
  • Plain page in its header turns the tiler off, and ?touch tiles it on a phone, one layout upright and another on its side (setLayouts).

The tests drive a different page, test/fixture/, built for the claims they make: a fold, a box that scrolls sideways, a popover the pane would clip.

npm install && npx playwright install chromium firefox webkit
npm test              # the fixture, in Chromium
npm test -- firefox   # or webkit
npm run test:demo     # the playground still works
npm run types         # the declarations, which are hand-written
npm run shot          # the gif above and the demo's link preview, re-recorded (needs ffmpeg)

Browsers

The suite runs in Chromium, Firefox and WebKit. A pane that moves — a split collapsing around it, a tab dragged to another leaf — is moved with moveBefore where the browser has it (Chromium and Firefox), so an <iframe> in it does not load again; where it does not (Safari), it is an ordinary move, and the <iframe> reloads. Where a box in the pane was scrolled to is kept either way.

Chromium's renderer has crashed on some of those moves, taking the page with it. mullion works around it, and will until the browser's fix is what people have.

License

MIT. Copyright (c) 2026 Misha Nasledov.