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

@madronejs/splitpanel

v0.2.0

Published

A CSS-Grid-driven split-panel runtime. Framework-free core, optional Vue 3 wrapper. No reactivity dependency, no lodash, no animation library — plain CSS transitions do the work.

Readme

@madronejs/splitpanel

A CSS-Grid-driven split-panel runtime. Framework-free core, optional Vue 3 wrapper. No reactivity dependency, no lodash, no animation library — plain CSS transitions do the work.

pnpm add @madronejs/splitpanel

Two entry points:

import { SplitGrid }       from '@madronejs/splitpanel';        // framework-free core
import { SplitGridView }   from '@madronejs/splitpanel/vue';    // Vue 3 wrapper
import                       '@madronejs/splitpanel/style';     // CSS (required)

The CSS import is mandatory — it ships the .sp-container { display: grid } rules, the track-template wiring, drag/animation states, and the default resize indicator. Without it you'll see flat, un-gridded panels.


Quick start (Vue)

<script setup lang="ts">
import { SplitGridView, SplitPanel, SplitContainer } from '@madronejs/splitpanel/vue';
import { PanelDirection } from '@madronejs/splitpanel';
import '@madronejs/splitpanel/style';
</script>

<template>
  <SplitGridView :root="{ id: 'app', direction: PanelDirection.Row }">
    <SplitPanel panel-id="sidebar" size="220px" min="160px" max="320px">
      Sidebar content
    </SplitPanel>

    <SplitContainer direction="column">
      <SplitPanel min="120px">Editor</SplitPanel>
      <SplitPanel size="30%" min="80px">Console</SplitPanel>
    </SplitContainer>
  </SplitGridView>
</template>

That's the whole API for the common case. v-for over <SplitPanel> to drive the tree off your data; toggle individual panels with v-if; the runtime reacts.


Sizes — size, min, max

Each panel accepts a LengthInput:

| Form | Meaning | |-------------|------------------------------------| | 120 | pixels (numeric shorthand) | | '120px' | pixels | | '33%' | percent of the container's axis | | 'auto' | flex-fill (one 1fr share) |

Omit size and the panel flex-fills ('auto'). min and max clamp at the CSS level via clamp()/max() in the track template — so a percentage min means "min N% of the full grid container, including resizer tracks", matching how CSS resolves the same percentage natively.

Percentages and px can be mixed freely; the runtime stores sizes internally as the appropriate unit and CSS does the resolution.

Direction

import { PanelDirection } from '@madronejs/splitpanel';

const root = { id: 'app', direction: PanelDirection.Row };

PanelDirection is a string enum (Row = 'row', Column = 'column'). TypeScript callers must use the enum members — a bare 'row' literal is a type error at API boundaries. At runtime the values are the same plain strings CSS Grid expects, so Vue template-string objects that can't import the enum (<SplitGridView :root="{ direction: 'row', ... }">) still work — the template compiles its expression, the runtime sees 'row', CSS sees 'row'. The enum is for TS-checked call sites; the strings are for template scope.

Nest by putting a <SplitContainer :direction="…"> inside a <SplitPanel> slot, or — using the framework-free API — building a tree with nested Container nodes.

Declarative root: children is optional

The <SplitGridView> root prop is typed ContainerInput<T>, a permissive variant of Container<T> where children is optional:

<SplitGridView :root="{ id: 'app', direction: PanelDirection.Row }">
  <SplitPanel panel-id="a">…</SplitPanel>
  <SplitPanel panel-id="b">…</SplitPanel>
</SplitGridView>

Slot children register themselves with the wrapper during setup, so you don't need to spell out children: []. When using new SplitGrid({ root }) directly (framework-free), the stricter Container<T> is what the constructor sees — children is required there.


Gestures

| Gesture | Action | |--------------------------------|---------------------------------------------------| | Drag a divider | Resize neighbors with push-cascade + LIFO recovery | | Double-click a divider | Toggle maximize/minimize on the closer-edge panel | | Triple-click a divider | Equalize the parent container | | Double-click resizer.first/last chrome | Toggle the adjacent panel | | Triple-click edge chrome | Equalize |

Double-click is delayed by ~250ms (TRIPLE_CLICK_GRACE_MS in SplitGrid.ts) in case a follow-up third click is coming. The third click cancels the pending toggle and runs equalize instead — so each gesture produces exactly one effect.


Resizer chrome (resizer.first / resizer.last)

Decorative tracks before the first child or after the last — non-draggable "header bars" you customize via the #resizer slot:

<script setup lang="ts">
import { PanelDirection } from '@madronejs/splitpanel';
import { SplitGridView, SplitPanel } from '@madronejs/splitpanel/vue';

const root = {
  id: 'root',
  direction: PanelDirection.Row,
  resizer: { size: 20, first: true }, // 20px bar at the leading edge
};
</script>

<template>
  <SplitGridView :root="root">
    <template #resizer="{ resizer }">
      <div v-if="resizer.after" class="panel-header">
        {{ resizer.after.data?.title }}
      </div>
    </template>

    <SplitPanel v-for="w in widgets" :key="w.id" :panel-id="w.id" :data="w">
      …
    </SplitPanel>
  </SplitGridView>
</template>

resizer.after is the panel immediately to the right (or below) the bar; resizer.before the panel to the left/above. Both are Node defs, so .data carries whatever payload you put on the SplitPanel. The slot template renders into every divider — guard with v-if="resizer.after" if you only want to label one side.

Per-panel #resizer slot

Alternatively, declare a #resizer slot on an individual <SplitPanel> to teleport content into the divider on its leading side:

<SplitPanel panel-id="b">
  B's content
  <template #resizer>
    <span>This renders in the divider between A and B</span>
  </template>
</SplitPanel>

Useful when each panel "owns" the divider before it. The wrapper-level #resizer slot fills any divider a panel hasn't claimed.


Drag-drop reorder

Pass a :draggable config to enable pointer-driven reordering:

<script setup lang="ts">
import type { DraggableConfig } from '@madronejs/splitpanel';

const draggable: DraggableConfig<Tile> = {
  dragSelector: '.drag-handle',                  // only this child starts a drag
  ghostAnchor: () => ({ x: 0.5, y: 0.5 }),       // cursor at ghost center
  onDrop: ({ sourceId, targetId, grid }) => {
    grid.swapData(sourceId, targetId);           // default; you can swap, move, anything
  },
};
</script>

<SplitGridView :root="root" :draggable="draggable">
  <template #leaf="{ panel }">
    <div :class="{ dragging: panel.isDragging, target: panel.isDropTarget }">
      <span class="drag-handle">⠿</span>
      {{ panel.data?.label }}
    </div>
  </template>
</SplitGridView>

Three mutation primitives are available inside onDrop:

  • grid.swap(a, b) — structural reorder (panel IDs move with their positions).
  • grid.swapData(a, b) — slots stay; data flows between them.
  • grid.moveData(a, b) — splice from source, insert at target; data shifts.

The slot-scope flags isDragging, isDropTarget, isDropZone let you style both the held panel and candidate targets in real time.


Programmatic API

Expose-ref into the wrapper:

<script setup lang="ts">
import { ref } from 'vue';
import type { SplitGridViewApi } from '@madronejs/splitpanel/vue';

const gridRef = ref<SplitGridViewApi<MyData> | null>(null);

function maximizeFirst() {
  gridRef.value?.maximize('first-panel-id');
}
</script>

<SplitGridView ref="gridRef" :root="…" />

| Method | Purpose | |--------------------------------|----------------------------------------------------| | setSize(id, size, opts?) | Resize one panel; siblings rebalance | | setBounds(id, bounds, opts?) | Update size/min/max + reflow | | maximize(id, opts?) | 100% of available space; snapshot prior layout | | minimize(id, opts?) | 0%; clear snapshot | | toggleMaximize(id, opts?) | Flip between the two | | toggleExpand(id, opts?) | Maximize ↔ restore prior layout (snapshot path) | | expandNext(containerId) | Walk the maximize state forward through children | | expandPrev(containerId) | Walk backward | | equalize(containerId, opts?) | Even shares; respects mins; clears snapshots | | reset(containerId, opts?) | Restore to the definition's bounds.size values | | addChild/removeChild/swap| Structural mutations | | setData/setDataArray/swapData/moveData | Data-only mutations (panels stay put) | | setDirection(containerId) | Flip row ↔ column at runtime | | getSize(id) | Returns { px, pct } for the current track size | | isMaximized(id) | True iff the panel is currently the snapshotted max | | isAtDefault(id) | True iff size matches bounds.size at definition | | settle(containerId?) | Promise<void> resolving on transitionend | | getPanelState(id) | Reactive PanelState view for parent components (see below) | | instance | Underlying SplitGrid for anything not on this surface |

LayoutOptions { animate?: boolean } opts out of the CSS transition when animate: false.

Reading layout state from a parent component

Components that own a <SplitGridView> via a template ref can subscribe to layout state by calling getPanelState(id) inside a computed:

<script setup lang="ts">
import { computed, ref } from 'vue';
import type { SplitGridViewApi } from '@madronejs/splitpanel/vue';

const gridRef = ref<SplitGridViewApi<MyData> | null>(null);

// Reactive view of the root container. The library updates this on every
// layout event, so the computed below re-evaluates automatically.
const rootPanel = computed(() => gridRef.value?.getPanelState('root'));

const allEqual = computed(() => {
  const sizes = rootPanel.value?.childSizes ?? [];
  if (sizes.length < 2) return true;
  const [first, ...rest] = sizes;
  return rest.every((s) => s.unit === first.unit && s.value === first.value);
});

const maxedChildId = computed(() => rootPanel.value?.maximizedChildId ?? null);
</script>

Inside the component tree (a child of <SplitGridView> or one of its slots), use usePanelState(id) instead — it's the same reactive value, plumbed via inject. getPanelState is the imperative-ref counterpart for parents who can't inject.

PanelState's container-only fields:

| Field | Type | Meaning | |-----------------------|-------------------------------|--------------------------------------------------| | childSizes | Length[] \| undefined | Live sizes of this container's children | | maximizedChildId | string \| null \| undefined | Which child (if any) is currently maximized |

Leaf panels see undefined for both. The non-container fields (isMaximized, isDragging, data, size, etc.) work the same on leaves and containers.


Layout-change events

Subscribe to every state change:

<SplitGridView :root="…" @change="onLayoutChange" />
function onLayoutChange(e: LayoutChangeEvent) {
  // e.containerId, e.nodeIds, e.reason ('drag' | 'set-size' | 'add-child' | …)
}

The same signal is available via grid.subscribe(listener) on the framework-free API.


Theming

Every dimension and color of the default resize indicator is exposed as a CSS variable. Override on .sp-container, .sp-resizer, or any ancestor:

.my-grid {
  /* idle indicator */
  --sp-indicator-color: rebeccapurple;
  --sp-indicator-opacity: 0.2;
  --sp-indicator-thickness: 3px;
  --sp-indicator-length: 32px;
  --sp-indicator-radius: 2px;

  /* hover / active-drag indicator */
  --sp-indicator-hover-opacity: 0.6;
  --sp-indicator-hover-thickness: 5px;
  --sp-indicator-hover-length: 56px;
  --sp-indicator-hover-radius: 3px;

  /* programmatic animation duration */
  --sp-anim-ms: 400ms;
}

Opt out of the indicator entirely:

.my-grid .sp-resizer-handle { display: none; }

If you teleport content into a #resizer slot, the default handle hides automatically — no opt-out needed.


Framework-free use

import { SplitGrid, PanelDirection } from '@madronejs/splitpanel';
import '@madronejs/splitpanel/style';

const grid = new SplitGrid({
  root: {
    id: 'root',
    direction: PanelDirection.Row,
    resizer: { size: 6 },
    children: [
      { id: 'a', bounds: { min: '160px' } },
      { id: 'b' },
    ],
  },
  renderLeaf: ({ id, el, leaf }) => {
    el.textContent = id;                  // paint your content into the leaf
  },
  onChange: (e) => console.log(e),
});

grid.mount(document.getElementById('host')!);

grid.setSize('a', '40%');                  // animated
grid.maximize('b', { animate: false });    // instant

renderLeaf runs once per leaf at mount. You own the contents of ctx.el; the runtime owns the element's size, drag, and dblclick behavior.


What's NOT included

By design:

  • No reactivity dependency in the core. The framework-free API exposes a plain subscribe(listener); the Vue wrapper builds reactivity on top.
  • No animation library. Plain transition: grid-template-columns plus a one-frame freeze/no-animate dance for frpct shape changes.
  • No icons / no theming framework. Just CSS variables.
  • No drag-and-drop library. The pointer-event plugin in draggable.ts is ~250 lines and ships disabled by default.

See also

  • ARCHITECTURE.md — how the runtime, the freeze-frame, the bounds-resolution rules, and the Vue wrapper actually work.
  • demo/ — five working examples covering imperative, declarative, drag-drop, dynamic, and the Vue-resizer-slot patterns. pnpm dev to run.