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

@wippy-fe/layout

v0.0.63

Published

Declarative multi-panel layout manager for Vue 3 - recursive split trees, separator drag resize, programmatic mutation, breakpoint-responsive.

Readme

@wippy-fe/layout

Declarative, programmatically-mutable multi-panel layout manager for Vue 3.

Built for the Wippy web host's managed-layout mode — but entirely framework-agnostic: no dependency on @wippy-fe/proxy, no knowledge of pages / artifacts / chat. Any Vue 3 app can use it to build IDE-style split UIs, dashboard shells, or internal multi-panel layouts with separator drag and breakpoint-responsive variants.

Install

pnpm add @wippy-fe/layout

Peer deps: vue >= 3.5.

Quick start

import { createApp, defineComponent, h } from 'vue'
import { LayoutManager, LayoutManagerView, LAYOUT_MANAGER_KEY } from '@wippy-fe/layout'

const Sidebar = defineComponent({ setup: () => () => h('nav', 'Sidebar content') })
const Main    = defineComponent({ setup: () => () => h('main', 'Main content') })

const manager = new LayoutManager({
  layouts: {
    default: {
      direction: 'horizontal',
      children: [
        { panel: 'sidebar', size: '260px', minSize: '120px', collapsible: true },
        { panel: 'main',    size: '1fr' },
      ],
    },
  },
  panels: {
    sidebar: { component: Sidebar },
    main:    { component: Main },
  },
})

const app = createApp({
  render: () => h(LayoutManagerView, { manager }),
})
app.provide(LAYOUT_MANAGER_KEY, manager)
app.mount('#app')

Drag the separator to resize. Double-click a separator adjacent to a collapsible panel to collapse/expand it.

Concepts

Layout declaration

interface LayoutDeclaration {
  layouts: {
    default: PanelTree               // required
    sm?: PanelTree                   // optional breakpoint variants
    md?: PanelTree
    lg?: PanelTree
    xl?: PanelTree
    [custom: string]: PanelTree      // custom breakpoint names
  }
  breakpoints?: Record<string, number> // default Tailwind: { sm:640, md:768, lg:1024, xl:1280 }
  panels: Record<string, PanelDef>     // id → Vue component + props
  floating?: Record<string, FloatingPanelDef>
  modals?: Record<string, ModalDef>
  dragEnabled?: boolean                // reserved for panelDrag capability
}

PanelTree (recursive split container)

interface PanelTree {
  direction: 'horizontal' | 'vertical'
  children: Array<PanelSlot | PanelTree>
}

PanelSlot (leaf, holds one panel)

interface PanelSlot {
  panel: string           // id into panels map
  size: string            // '1fr' | '300px' | '40%' | 'auto'
  minSize?: string
  maxSize?: string
  collapsible?: boolean
  collapsed?: boolean
  collapseSize?: string   // e.g. '48px' for icon-only strip
  fixed?: boolean         // lock — no drag / no move
}

PanelDef (your Vue component + props)

interface PanelDef {
  component: Component
  props?: Record<string, unknown>
  title?: string
  icon?: string
}

Programmatic API

All mutations emit a change event (coalesced per microtask).

const manager = new LayoutManager(decl, capabilities?)

// Read
manager.getLayout(): LayoutDeclaration
manager.activeBreakpoint: string

// Whole-declaration
manager.setLayout(decl: LayoutDeclaration): void

// Panel mutations
manager.addPanel(id, def, { relativeTo?, position, size? }): void
manager.removePanel(id): void
manager.updatePanel(id, patch): void
manager.resizePanel(id, size: SizeValue): void   // SizeValue = 'auto' | 'Nfr' | 'Npx' | 'N%'
manager.collapsePanel(id): void
manager.expandPanel(id): void
manager.movePanel(id, target): void         // reserved shape (see below)

// Floating + modals
manager.addFloating(id, def): void
manager.removeFloating(id): void
manager.updateFloating(id, patch): void
manager.openModal(id, def): void
manager.closeModal(id): void

// Events
manager.on('change',     decl      => { ... })  // fires once per microtask after any mutation
manager.on('breakpoint', ({ name }) => { ... }) // fires when container crosses a threshold
manager.on('drag',       ({ panel, delta, direction }) => { ... }) // separator drag

Composables

import {
  useLayout,              // get the LayoutManager instance from provide
  useLayoutBreakpoint,    // Ref<string> — active breakpoint
  useLayoutPanel,         // Ref<PanelDef | null> — reactive single panel
  useLayoutFloating,      // Ref<FloatingPanelDef | null>
  useLayoutDeclaration,   // Ref<LayoutDeclaration> — full decl (use sparingly)
} from '@wippy-fe/layout'

Separator drag

When capabilities.separatorDrag is true (default), the renderer inserts a <LayoutSplitter> between every pair of sibling children of a PanelTree. Drag the splitter to resize:

  • The before sibling's size is converted to pixels + delta, clamped to minSize/maxSize.
  • The after sibling absorbs the change via flex (if it's 1fr) or is itself pixel-sized (update both).
  • If both adjacent siblings are fixed or collapsed, the splitter is non-interactive.
  • Double-click a splitter adjacent to a collapsible panel to toggle collapse.

minSize / maxSize accept pixel sizes only (Npx). Invalid %, fr, em, and rem constraints are rejected by layout validation.

The slot after a splitter may set dividerBefore: 'none' | 'line' to control the persistent boundary. none lets the following panel paint beneath the 1px splitter box, so no manager-canvas strip remains between panel surfaces; hover and keyboard-focus paint may still reveal the splitter. The 10px pointer hit area, keyboard separator, drag math, and accessibility attributes remain intact. Use borderBefore: true when the panel itself owns a permanent edge, including layouts with separator drag disabled:

{
  direction: 'horizontal',
  children: [
    { panel: 'chat', size: '1fr' },
    { panel: 'artifact', size: '420px', dividerBefore: 'none', borderBefore: true },
  ],
}

Responsive breakpoints

Tailwind-standard defaults: { sm: 640, md: 768, lg: 1024, xl: 1280 }. Override per-declaration via breakpoints.

The renderer uses a ResizeObserver on the outermost container and collapses resize events into one pick per animation frame. On width change, it picks the largest declared breakpoint whose threshold ≤ container width (or default if none match). The breakpoint event fires exactly once per transition — not per RO tick.

If the container width drops below the smallest declared narrow breakpoint and no narrow layout was declared, deriveMobileLayout(decl) auto-flattens to the first slot of the default layout as a fallback. A one-time console warning is emitted the first time this happens so the author knows the fallback kicked in.

Instance preservation across breakpoint flips: panel components stay mounted across every layout change. The renderer maintains a flat panel zone keyed by panel id, and each panel's mount is an absolutely-positioned element tracked over its active slot — it is never re-parented. <Teleport> was deliberately not used here: teleporting re-parents the element, and browsers RELOAD an <iframe> that is detached and reinserted, destroying exactly the state this preserves. (Floating panels and modals do use <Teleport>; they have no such content.) In-flight form state, scroll position, iframe contentWindow and any other component-local state survive automatically — no consumer code required. A panel that is absent from the active layout keeps its mount alive off-screen and is re-targeted when it returns; only removePanel tears the mount down.

// Declaration with a narrow-viewport fallback
const manager = new LayoutManager({
  layouts: {
    default: {
      direction: 'horizontal',
      children: [
        { panel: 'sidebar', size: '260px' },
        { panel: 'main',    size: '1fr' },
        { panel: 'inspect', size: '320px' },
      ],
    },
    md: {
      direction: 'horizontal',
      children: [
        { panel: 'sidebar', size: '240px' },
        { panel: 'main',    size: '1fr' },
      ],
    },
    sm: {
      direction: 'vertical',
      children: [{ panel: 'main', size: '1fr' }],
    },
  },
  panels: { ... },
})

// Subscribe to breakpoint transitions
manager.on('breakpoint', ({ name, width }) => {
  console.log(`Layout flipped to "${name}" at ${width}px`)
})

Floating panels

Teleported to document.body, absolute-positioned, with a drag handle on the header and a close button.

import { h, defineComponent } from 'vue'

const Inspector = defineComponent({ setup: () => () => h('div', 'Inspector') })

manager.addFloating('inspector', {
  component: Inspector,
  title: 'Inspector',
  position: { x: 120, y: 80 },
  size: { width: 360, height: 480 },
  dismissable: true,
})

Drag the header to move. On pointerup the position is persisted back to the declaration via updateFloating. Mousedown anywhere on the window bumps its z-index above sibling floatings. Window-viewport bounds are clamped so the window can't be dragged entirely offscreen (a minimum edge stays visible).

A floating window with chrome ships resize grips on all four edges and corners. There are no minimize/maximize buttons — mutate size programmatically via manager.updateFloating(id, { size }) for those. A chrome: false panel has neither header nor grips: it is a bare box for edge affordances, which is also why it is the only kind that honours anchor.

Anchoring instead of absolute pixels

position is left/top in pixels, which cannot express "the right edge" or "vertically centred" — the two things edge chrome always wants. A right-hand tab positioned in pixels is correct at exactly one viewport size.

anchor pins to viewport edges instead. Numbers are pixels; strings pass through, so '50%' and '2rem' work:

manager.addFloating('tab', {
  component: Tab,
  chrome: false,                      // bare flap: no header, no drag
  anchor: { right: 0, top: '50%' },   // right edge, vertically centred
  position: { x: 0, y: 0 },           // still required; ignored on anchored axes
  size: { width: 48, height: 80 },
})

It only overrides the axes it names, so { right: 0 } still takes its vertical position from position.y. Resolved at render time and never written back to the manager — the layout snapshot merges the live position, so baking an anchor into pixels there would hand every subscriber a value that goes stale on the next resize.

Honoured for chrome: false only. A panel with chrome is user-draggable, and dragging writes absolute position, so an anchor would fight the user's own drag.

Anchoring to a panel (CSS Anchor Positioning)

An edge tab usually belongs to the panel it reveals, not to the window. Give anchor.panel and the browser keeps the two together through resizes, splitter drags and breakpoint flips — no measuring, no ResizeObserver:

anchor: {
  panel: 'sidebar',       // pin to that panel's slot
  area: 'left center',    // CSS position-area; default 'right center'
  right: 0, top: '50%',   // fallback for browsers without the feature
}

Every panel slot exposes an anchor-name; panelAnchorName(panelId) is exported if you want to target one from your own CSS.

Progressive enhancement — CSS Anchor Positioning is Chromium-only at time of writing. Always give the viewport-edge fields too: unsupported browsers use those, supported ones use the anchor. Where the two disagree, the anchored result is the better one.

Modals

Teleported to document.body, centered over a backdrop, with ESC close + backdrop-click close.

Two ways to open

1. Pre-register the template, open by id. The recommended pattern when the modal's component / title / size are known up front (i.e. most cases). Declare the template in decl.modals at construction or via manager.registerModal(id, def) later; manager.openModal(id) looks up the registered shape.

// At construction — declared modals are REGISTERED as templates;
// they are NOT auto-opened. Boot state has zero modals visible.
const manager = new LayoutManager({
  // ...layouts, panels...
  modals: {
    confirm: {
      component: ConfirmDialog,
      title: 'Confirm action',
      size: { width: '420px' },
      dismissable: true,
    },
  },
})

// Programmatically register more templates later if you like.
manager.registerModal('upload', { component: UploadDialog, title: 'Upload' })

// Open by id — pure intent at the call site.
manager.openModal('confirm')

2. Inline open with an explicit def. When the def is built dynamically. Also re-registers the template under the same id so subsequent id-only opens reuse the same shape.

manager.openModal('confirm', {
  component: ConfirmDialog,
  title: 'Confirm action',
  props: { message: 'Save changes?' },
  size: { width: '420px' },
  dismissable: true,
})

// Later: id-only — reuses the def from the line above.
manager.openModal('confirm')

Both call shapes throw LayoutValidationError(duplicate-modal-id) if the id is already open. Calling openModal(id) without a registered template throws unknown-modal-id. Malformed defs (missing component) throw bad-modal-def at the mutation site, before the registry is touched.

registerModal contract

Re-registering an id while the same modal is open is non-disruptive: the currently-displayed instance keeps the def it was opened with; the new template applies on the next closeModal → openModal cycle. No change event fires.

Set dismissable: false to disable ESC and backdrop-click closing (useful for required decisions). Multiple modals stack via the browser's top-layer algorithm — closing any modal pops it.

Native dialog by default. Modals render via <dialog showModal()> (component: LayoutModalNative) for top-layer stacking, automatic focus trap (Tab cycles inside the open modal), and inert propagation (the rest of the page becomes non-interactive). The legacy div-overlay implementation (LayoutModal) is @deprecated and will be removed in the next major; opt back into it per-modal via def.useNativeDialog: false while migrating. Platforms without HTMLDialogElement.showModal (very old browsers, JSDOM) fall back to the legacy path automatically.

Capabilities flag

Feature toggles, passed as the second constructor argument:

const manager = new LayoutManager(decl, {
  separatorDrag: true,   // default true  — drag to resize
  panelDrag:     false,  // default false — RESERVED for future (throws if true)
  tabs:          false,  // default false — RESERVED for future (throws if true)
  floating:      true,   // default true  — floating panel support
  modals:        true,   // default true  — modal support
})

panelDrag and tabs are declared so the shape is forward-compatible. Setting them true in v1 throws a LayoutValidationError. See Future capabilities below.

Validation

Every setLayout / addPanel mutation runs validateLayout(decl) internally. Catch LayoutValidationError to surface issues to users:

try {
  manager.setLayout(newDecl)
}
catch (err) {
  if (err instanceof LayoutValidationError) {
    console.error(err.issues) // array of { kind, message, panelId?, panelIds? }
  }
}

Validator catches:

  • duplicate-id — same panel referenced more than once in one breakpoint layout
  • orphan-ref — PanelSlot.panel string that isn't in the panels map
  • bad-size — unparseable size string. The grammar is GoldenLayout v2-aligned: only auto, Nfr, Npx, N% are accepted (e.g. 1fr, 300px, 40%, auto). Anything else (2em, 50vh, clamp(...), min-content, calc(...)) is rejected.
  • empty-tree — a layouts.<name> with no children
  • unknown-breakpoint — non-numeric / non-positive breakpoint threshold
  • bad-modal-def — registerModal(id, def) / openModal(id, def) / pre-registered modals where def is not an object or is missing component
  • unknown-modal-id — openModal(id) with no template registered under id
  • duplicate-modal-id / duplicate-floating-id — openModal(id) / addFloating(id) against an already-open id

Future capabilities (reserved)

capabilities.panelDrag: true (not shipped)

When flipped, the renderer will overlay drop-zones on every slot and enable drag-to-rearrange via pointer events. The data model and movePanel(id, target) already work programmatically today — the drag UI layer is the only missing piece.

PanelTarget shape:

interface PanelTarget {
  relativeTo?: string                     // panel id; undefined = root
  position: 'left' | 'right' | 'top' | 'bottom' | 'center'
  size?: string
}

capabilities.tabs: true (not shipped)

When flipped, PanelSlot will gain an optional tabs?: string[] field — an array of panel IDs sharing one slot with tab switching. Validator + renderer will extend.

Using the helpers directly

The package exports its pure helpers so you can build custom tooling:

import {
  validateLayout,
  deriveMobileLayout,
  findSlotParent,
  insertAtPosition,
  removeNode,
  moveNode,
  walkTree,
  cloneLayout,
  pickBreakpoint,
  isPanelSlot,
  isPanelTree,
} from '@wippy-fe/layout'

All helpers are pure functions with no side effects (except the mutation variants, which mutate in place as documented).

Swapping content without a flash — useSwapBuffer

Replacing one piece of content with another naively means unmounting the old thing and mounting the new one, so the user sees the old page vanish, a blank frame, then the new page appear. Every app re-implements the same fix badly.

useSwapBuffer is that fix: keep N buffers alive, route new content to one while the other stays on screen, and swap only once the incoming content says it has painted.

import { useSwapBuffer } from '@wippy-fe/layout'

const swap = useSwapBuffer<{ url: string }>({
  keyOf: entry => entry.url,
  buffers: 2,             // 2 = classic double buffer
  evict: 'lru',
  readyTimeoutMs: 8000,   // reveal anyway if content never reports
  loaderDelayMs: 250,     // don't flash a spinner at cached content
  loaderMinMs: 400,       // ...and don't flash it away either
})

watch(source, entry => swap.push(entry), { immediate: true })
// on paint: swap.markReady(slot.index, slot.key)
// on failure: swap.markFailed(slot.index, err, slot.key)

swap.slots / swap.topIndex / swap.active / swap.showLoader drive the render. Points worth knowing:

  • Key your render loop on slot.index, never slot.key. Buffer indices are stable; keying on content makes the framework reorder DOM nodes, and a reinserted <iframe> reloads its document — destroying the state the buffer exists to preserve.
  • Pass key to markReady / markFailed for async signals. Buffers are recycled, so a late callback from evicted content would otherwise mark whatever now occupies that index.
  • readyTimeoutMs is not optional in spirit. Content reports its own readiness and content can fail to report; without a ceiling the user gets a loader over stale content forever, with no error anywhere.
  • Errors are per buffer. One failing buffer must not tear down a sibling holding good content. Recovery is explicit — call clearError.

Styling

The package ships minimal scoped CSS. Theming hooks via CSS custom properties:

| Variable | Default | Purpose | |---|---|---| | --wippy-layout-splitter-bg | transparent | Separator idle | | --wippy-layout-splitter-hover-bg | rgba(0,0,0,0.25) | Separator hover + dragging | | --wippy-layout-splitter-size | 1px | Separator thickness (the visible line) | | --wippy-layout-splitter-hit-size | 10px | Pointer hit area, centred on the line and wider than it. Bumped to 24px under pointer: coarse | | --wippy-layout-splitter-z-index | 700 | Splitter layer inside the isolated manager stack; below drawers and backdrops (800+) | | --wippy-layout-splitter-handle-size | 0 | Grab-handle diameter. 0 = off. Set a size to show the hover-revealed handle | | --wippy-layout-splitter-handle-bg | transparent | Grab-handle fill | | --wippy-layout-splitter-handle-border | 0 solid transparent | Grab-handle border (shorthand) | | --wippy-layout-splitter-handle-shadow | none | Grab-handle shadow | | --wippy-layout-splitter-handle-icon-color | transparent | Icon color; the embedded SVG uses currentColor, so consumers can provide theme variants | | --wippy-layout-boundary-color | surface-200 | Persistent dividerBefore: 'line' paint | | --wippy-layout-panel-boundary-color | surface-200 | Panel-owned borderBefore paint | | --wippy-layout-error-color | #b00 | "Missing panel" placeholder color |

The grab handle is off by default so upgrading this package never silently restyles an existing app. Set its size, fill, shadow, and icon color together to opt in. Its embedded SVG uses currentColor, so theme-specific icon colors do not require replacing the asset. It is revealed on hover, drag, and keyboard focus; vertical splitters rotate the icon 90 degrees. It hides itself on a locked separator, where advertising a grab point would lie, and respects prefers-reduced-motion.

License

UNLICENSED.