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

panelset

v1.2.5

Published

Flexible panel management with smooth transitions

Readme

PanelSet

Version Downloads

Flexible panel management with smooth transitions.


What is it?

It is a transition helper for panels that you want to show or hide. The package exports three classes. Two main classes: Panel and PanelSet and one additional class PanelControl. Both main classes animate a size between values using an LMAU* cycle:

  • Panel animates height or width
  • PanelSet animates height of a whole set of panels, showing one at a time
  • PanelControl is an addition to PanelSet that handles navigation for tabs

⠀The two main classes use the same animation core and share most config options. Animation is CSS-only: JS just measures and sets pixel values, then lets your browser do the rest with CSS. For async content, you can pass a promise to let the panel wait until it resolves. A spinner kicks in if loading takes longer than what you set it to be. Both also handle focus management.

  • LMAU: Lock-Measure-Animate-Unlock (I should trademark the term).

Documentation: the full guides, every option, and live examples are at https://martinomagnifico.github.io/panelset/. This README is a quick reference.


Installation

npm install panelset
import { Panel, PanelSet, PanelControl } from 'panelset';
import 'panelset/style.css';

Or as a script tag (IIFE bundle):

<link rel="stylesheet" href="panelset.css">
<script src="panelset.js"></script>
<!-- exposes window.Panel / PanelSet / PanelControl and registers
     <ps-panel> / <ps-panelset> / <ps-panelcontrol> -->

The lock-measure-animate-unlock (LMAU) cycle

Panel and PanelSet seem to be able to transition to or from a natural size, but they just use measurement to get explicit values. ‘Explicit’ sounds fancy, but it just means using real or relative values instead of auto. It is the same basic approach as used in for example react-collapse, but extended here to horizontal axes and panel switching. The sequence is the same for both:

  1. Lock: write the current dimension as an inline style. This gives CSS a concrete start value.
  2. Measure: make the target content briefly visible but absolutely positioned (so it does not affect flow), then read its offset dimension (offsetHeight or offsetWidth). This value is used in the next step.
  3. Animate: set the container’s inline style to the measured value inside a requestAnimationFrame call. The CSS transition runs between the locked start and the measured end.
  4. Unlock: once the transition ends, remove the inline style. The container is responsive again.

Panel

Panels are closed by default (no class needed). Add is-open to start open.

<button aria-controls="my-panel">Toggle</button>

<div id="my-panel" data-panel>
  <div class="panel-wrapper">Content here</div>
</div>
import { Panel } from 'panelset';
Panel.init();

Any [aria-controls] element is a trigger. As a shortcut, a [data-panel-trigger] button placed next to a panel (or as the direct child of a heading next to it) gets its id and ARIA set up automatically.

Accordion (group)

<div data-panel-group data-panel-close-siblings>
  <button aria-controls="acc-1">Item 1</button>
  <div id="acc-1" data-panel><div class="panel-wrapper">Answer 1</div></div>

  <button aria-controls="acc-2">Item 2</button>
  <div id="acc-2" data-panel><div class="panel-wrapper">Answer 2</div></div>
</div>

data-panel-close-siblings (or closeSiblings: true) makes opening one panel close the others.

Panel manages the aria-expanded attributes. You do not need to do that.

Panel options

| Option | Type | Default | Description | |---|---|---|---| | align | 'start' \| 'center' \| 'end' | 'start' | Content alignment within the clipped container | | autoFocus | false \| true \| 'heading' \| 'first' \| 'input' | false | Move focus into the panel on open | | axis | 'vertical' \| 'horizontal' | 'vertical' | Which dimension animates (height or width) | | closeOnResize | boolean | false | Close the panel when the window is resized | | closeSiblings | boolean | false | Close other open panels in the same group | | debug | boolean | false | Log events to the console | | deepLink | boolean | false | Reflect open state in the ?panel= URL | | interruptible | boolean | true | Allow a new open/close to interrupt one in progress | | loadingDelay | number | 320 | ms before the spinner appears during async loading | | loadingHeight | number | 150 | px reserved while async content loads | | persist | boolean | false | Save open/closed state to localStorage | | returnFocus | boolean | true | Return focus to the trigger on close | | static | boolean \| media query | false | Make the panel plain content. Pass a media query to do it only while that query matches, e.g. "(min-width: 769px)" for a nav that is a drawer on mobile and an ordinary sidebar on desktop. Just like the docs. | | transitions | boolean | true | Enable/disable CSS transitions |

Async content

const panel = new Panel('#my-panel');[]()

panel.onBeforeOpen((el, signal) => {
  return fetch('/api/content', { signal })
    .then(r => r.text())
    .then(html => { el.querySelector('.panel-wrapper').innerHTML = html; });
}, { once: true });

Or listen for the panel:beforeopen event and call e.detail.waitUntil(promise) to hold the open until it resolves.


PanelSet

<nav role="tablist" data-panelcontrol>
  <button role="tab" aria-controls="panel-1" aria-selected="true">Tab 1</button>
  <button role="tab" aria-controls="panel-2">Tab 2</button>
</nav>

<div data-panelset>
  <div class="panel-wrapper">
    <div id="panel-1" role="tabpanel" class="active">Content 1</div>
    <div id="panel-2" role="tabpanel" hidden>Content 2</div>
  </div>
</div>
import { PanelSet, PanelControl } from 'panelset';
PanelSet.init();
PanelControl.init();   // keyboard navigation + state for the tab strip

Mark the starting panel with class="active" and every other panel hidden.

PanelSet options

| Option | Type | Default | Description | |---|---|---|---| | align | 'start' \| 'center' \| 'end' | 'start' | Alignment while opening/closing | | autoFocus | false \| true \| 'heading' \| 'first' \| 'input' | false | Move focus into the panel on activation | | closable | boolean | false | Allow the whole container to open and close | | closeOnTab | boolean | false | Clicking the active tab closes the container (needs closable) | | debug | boolean | false | Log events to the console | | deepLink | boolean | false | Reflect the active panel in the ?panel= URL | | disabledMode | 'aria' \| 'native' | 'aria' | How data-ps-next / -prev buttons are disabled at the ends | | interruptible | boolean | true | Allow a new activation to interrupt one in progress | | levels | boolean | false | Give panels a depth order from DOM position; forward/back slide opposite ways | | loadingDelay | number | 320 | ms before the spinner appears during async loading | | loadingHeight | number | 150 | px reserved while async content loads | | loop | boolean | false | next() / prev() wrap around the ends | | manageLabels | boolean | true | Link each panel to its tab via aria-labelledby (auto-generates a tab id if needed) | | manageTriggers | boolean | true | Reflect selection (aria-selected on real tabs, aria-current otherwise) / activating state onto [aria-controls] triggers | | persist | boolean | false | Save the active panel id to localStorage | | returnFocus | boolean | false | Return focus to the trigger on close | | transitions | boolean \| { panels?: boolean, height?: boolean } | true | Enable/disable transitions (or per axis) |

Async content

const ps = new PanelSet('#my-panelset');

ps.onBeforeOpen((targetPanel, signal) => {
  return fetch(`/api/${targetPanel.id}`, { signal })
    .then(r => r.json())
    .then(data => { targetPanel.innerHTML = render(data); });
}, { once: true });

PanelControl

Drives one PanelSet from its trigger elements, so you do not hand-write the tab interaction. Put data-panelcontrol on the container; add role="tablist" (with role="tab" buttons) to switch on the keyboard model (arrow keys, Home / End, roving tabindex). It finds its PanelSet through the panels the triggers point at, so the control can live anywhere in the DOM.

Lock or unlock a tab from your own code:

control.setTabState('panel-3', 'disabled');  // lock
control.setTabState('panel-3', 'enabled');   // unlock

| Option | Type | Default | Description | |---|---|---|---| | activation | 'manual' \| 'auto' | 'manual' | manual: arrows move focus, Enter/Space/click activates. auto: arrows activate too. | | debug | boolean | false | Log to the console |


Configuration sources

Every option can also be set as a data attribute in the markup, or as a plain attribute on the web component. When the same option is set more than once, the most specific wins: defaults -> JS options -> data attribute. The exact attribute names are listed per option in the docs.

Web components

register() defines <ps-panel>, <ps-panelset>, and <ps-panelcontrol> (the script-tag build calls it for you). On the elements, options are plain attributes, no data- prefix.

import { register } from 'panelset';
register();            // default 'ps' prefix
register('acme');      // <acme-panel>, <acme-panelset>, <acme-panelcontrol>

CSS

Timing and sizing are CSS custom properties, set on the element or any ancestor (all timing values need a unit, e.g. 0.25s not 0). For example, on a Panel:

[data-panel] {
  --ps-open-speed:   0.25s;
  --ps-open-timing:  ease-in-out;
  --ps-close-speed:  0.25s;
  --ps-close-timing: ease-in-out;
}

PanelSet exposes a similar set for its fade and height timing. See the docs for the full list.

On iOS Safari, an opacity animation next to a busy main thread (a rotating SVG chevron, say) can stutter. The library prevents this automatically with --ps-gpu-nudge (an invisible translateY(-0.25px)) that promotes the animating wrapper to the GPU only while it moves. It is on by default; to opt out and get a plain fade, set --ps-gpu-nudge: none on the element (or globally).

Support

PanelSet is free and open source. If it saves you time, consider sponsoring my work.

License

MIT © Martinomagnifico