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

@aiquants/resize-panels

v2.2.0

Published

Reusable resizable panel components and utilities for React

Readme

@aiquants/resize-panels

Resizable panel layout components for React, built on one rule: there is a single function that decides panel sizes, and the handle is the boundary rather than a thing positioned near it.

Every state change — first paint, container resize, drag, collapse, restored layout — runs the same allocator, so the panels always fill the container exactly and the handle can never drift away from the edge it moves.

Key Features

  • One allocator, one geometry: allocatePanelSizes distributes the container among the panels (proportional water-filling with per-panel minimum/maximum bounds). Σ panel sizes == the group's content box holds after every transition the declared bounds allow, and the reducer logs a breach of that invariant rather than letting it pass silently — a container the minimums overflow, or that the maximums cannot reach, is reported as a constraint violation instead.
  • A handle that cannot drift: the resize handle renders a zero-length in-flow flex item at the boundary and draws its hit area symmetrically around it. Nothing computes its position, so it stays on the boundary in LTR and RTL, horizontal and vertical, with group padding, and during every frame of a live container resize. A drag divides the pointer's travel by the group's visual scale, so the grab point stays under the cursor even below a transformed ancestor.
  • Predictable proportions: a flexible panel's size is a weight — only the ratio between weights matters, unless the panel declares flexAdjustPriority, which reads the same number as a claim on that share of the container. A dragged layout becomes a fixed point of the allocator, so it survives the next resize as a ratio instead of snapping back.
  • Directional Collapsible Control: declarative collapsible settings (from: 'start' | 'end' | 'both') and the usePanelControls hook support collapsing/expanding from drags and from UI controls; expanding restores the layout the panel had before it closed.
  • Keyboard & Touch Accessibility: handles are role="separator" widgets whose aria-valuenow / aria-valuemin / aria-valuemax publish the boundary and the travel this handle can reach, collapsing included; a collapsed neighbour is reported as length 0 rather than making the values disappear. Arrow keys resize (5x step with Shift), reopen a collapsed neighbour from the side it closed towards and collapse a panel the pair's travel cannot shrink further, while a handle with nowhere to go leaves the key to the page. A drag needs the primary button and survives multi-touch and pointercancel.
  • A click instead of a drag: give a handle activationSizes and a click or tap moves the boundary through those preset sizes — the single-pointer alternative to dragging that WCAG 2.5.7 asks for (see Clicking instead of dragging).
  • Auto-Persistence: set autoSaveId to store each panel's open length and collapsed flag in localStorage next to its panel id, so a layout saved by a different set of panels is rejected instead of being applied by position.
  • Layout notifications with a cause: onLayout receives the allocated lengths together with why they changed — a drag, a key, a host call, a container resize, a panel change or a restored layout — so a host can keep what the reader chose apart from what the container forced.
  • Debug & Visualization: enable showDebugInfo to inspect allocations through the per-panel PanelDebugInfo badge and a global overlay (lazily imported; production bundles without it create no per-panel observers at all). A constraint violation needs no flag: the group draws its own placeholder over the panels whenever the allocator reports one.

Installation

Monorepo workspace dependency:

// consumer package.json
{
  "dependencies": {
    "@aiquants/resize-panels": "workspace:*"
  }
}

Standard package manager installation:

pnpm add @aiquants/resize-panels

Quick Start

import { useId } from "react"
import { PanelGroup, Panel, PanelResizeHandle } from "@aiquants/resize-panels"

export const Example = () => {
    const baseId = useId()

    return (
        <PanelGroup id={`${baseId}-group`} direction="horizontal" style={{ height: "24rem" }}>
            <Panel id={`${baseId}-left`} defaultSize={{ value: 200, unit: "pixels" }} className="bg-slate-100">
                Left Panel
            </Panel>
            <PanelResizeHandle id={`${baseId}-handle`} />
            <Panel id={`${baseId}-right`} defaultSize={{ value: 60, unit: "percentage" }} className="bg-slate-50">
                Right Panel
            </Panel>
        </PanelGroup>
    )
}

Enabling Collapsible Panels

Specify collapsible={{ from: "start" }} or collapsible={{ from: "end" }} on a Panel to enable automatic collapse/expand when dragging the corresponding handle to container boundaries. Set defaultCollapsed to set initial state to collapsed. Use collapsible={{ from: "both" }} to allow collapsing from either edge.

<PanelGroup direction="horizontal" style={{ height: "24rem" }}>
    <Panel id="sidebar" defaultSize={{ value: 240, unit: "pixels" }} collapsible={{ from: "end" }}>
        Sidebar
    </Panel>
    <PanelResizeHandle id="handle" />
    <Panel id="main" defaultSize={{ value: 60, unit: "percentage" }}>
        Main Content
    </Panel>
</PanelGroup>

Programmatic Controls via Hook

Use usePanelControls(panelId) inside any child component of PanelGroup to trigger collapse, expand, or toggle actions:

import { PanelGroup, Panel, PanelResizeHandle, usePanelControls } from "@aiquants/resize-panels"

const PanelToggleButtons = ({ panelId, label }: { panelId: string; label: string }) => {
    const { panel, isCollapsed, collapse, expand, toggle, canCollapseFromStart, canCollapseFromEnd } = usePanelControls(panelId)
    const direction = panel?.collapsedByDirection ?? (canCollapseFromStart ? "start" : "end")

    return (
        <div className="flex items-center gap-2">
            <span>{label}</span>
            <button onClick={() => collapse(direction)} disabled={(!canCollapseFromStart && !canCollapseFromEnd) || isCollapsed}>
                Collapse
            </button>
            <button onClick={() => expand(direction)} disabled={!isCollapsed || (!canCollapseFromStart && !canCollapseFromEnd)}>
                Expand
            </button>
            <button onClick={() => toggle(direction)} disabled={!canCollapseFromStart && !canCollapseFromEnd}>
                {isCollapsed ? "Switch to Expand" : "Switch to Collapse"}
            </button>
        </div>
    )
}

export const Example = () => (
    <PanelGroup direction="horizontal" style={{ height: "24rem" }}>
        <Panel id="sidebar" defaultSize={{ value: 240, unit: "pixels" }} collapsible={{ from: "end" }}>
            <PanelToggleButtons panelId="sidebar" label="Sidebar" />
        </Panel>
        <PanelResizeHandle id="split" />
        <Panel id="main" defaultSize={{ value: 60, unit: "percentage" }}>
            <PanelToggleButtons panelId="details" label="Details Panel" />
        </Panel>
        <PanelResizeHandle id="split-right" />
        <Panel id="details" defaultSize={{ value: 40, unit: "percentage" }} collapsible={{ from: "start" }}>
            Details
        </Panel>
    </PanelGroup>
)

Component API

PanelGroup Properties

| Property | Type | Description | | --- | --- | --- | | id | string | Group identifier (reflected in data-panel-group-id). Recommended when mounting multiple groups | | direction | 'horizontal' \| 'vertical' | Panel layout orientation | | className / style | string / React.CSSProperties | Additional styling for the container | | children | React.ReactNode | Panel and PanelResizeHandle child elements | | showDebugInfo | boolean | Enables the per-panel PanelDebugInfo badge and the global debug overlay | | onLayout | (sizes: number[], change: PanelLayoutChange) => void | Called with the panels' allocated pixel lengths (DOM order) each time they change once the container is measured; change.source says why (see Layout Notifications) | | autoSaveId | string | Key used to automatically persist and restore panel sizes in localStorage |

Panel Properties

| Property | Type | Description | | --- | --- | --- | | id | string | Panel identifier (reflected in data-panel-id). Optional — a useId-derived id is generated when omitted — but fixed at mount: a later change is ignored with a warning | | defaultSize | { value: number; unit: 'pixels' \| 'percentage' } \| number | Initial panel size (numbers parsed as percentages; 50% when omitted) | | minSize / maxSize | FlexibleSize | Panel size boundaries (pixels or percentages) | | collapsible | { from: 'start' \| 'end' \| 'both' } | Declarative collapsible edge configuration | | defaultCollapsed | boolean | Whether the panel starts collapsed | | pixelAdjustPriority | number | Order in which pixel panels are served when space is scarce (higher first) | | flexAdjustPriority | number | Serving order among flexible panels (higher first); declaring it also reads defaultSize as an absolute claim on that share of the container | | contentOverflow | React.CSSProperties["overflow"] | Overflow of the panel's content wrapper (auto by default) | | className / style | string / React.CSSProperties | Panel container styles |

Panel and PanelGroup forward any other DOM attribute (data-testid, aria-*, event handlers) to their element, except the ones they compute themselves: Panel owns aria-hidden, which follows the collapsed state, and its own data-panel-* attributes, while PanelGroup owns data-panel-group-id and data-panel-group-direction — the first is what getPanelGroupElement looks up, the second is what marks an element as a group for findOwningGroup and therefore for every handle. aria-hidden is removed from PanelProps to state that ownership, but TypeScript does not type-check JSX attribute names containing a hyphen, so neither it nor a data-panel-* / data-panel-group-* of your own is rejected at compile time: what enforces the contract is the runtime override, which applies these attributes after everything you forwarded. A data-panel-group-id you pass disappears altogether when the group itself has no id. Both elements merge your className with their own (panel / panel-group) instead of replacing it.

PanelResizeHandle Properties

| Property | Type | Description | | --- | --- | --- | | id | string | Handle identifier (reflected in data-resize-handle-id) | | disabled | boolean | Disables dragging, clicking and keyboard resizing when true | | activationSizes | readonly FlexibleSize[] | Lengths of the panel before the handle that a click or tap moves the boundary through, in the units of a panel's defaultSize / minSize / maxSize (see Clicking instead of dragging); without it a click does nothing | | thickness | number | Primary axis thickness of the handle's interactive area in pixels (8px default) | | indicator | boolean \| PanelResizeHandleIndicatorConfig | Toggle indicator visibility or provide custom pill/bar dimensions and CSS classes | | indicatorThickness | number | Shorthand for indicator pill wrapper thickness in pixels (6px default) | | indicatorLength | number | Shorthand for indicator pill wrapper length in pixels (48px horizontal / 80px vertical default) | | indicatorBarThickness | number | Shorthand for indicator inner bar thickness in pixels (2px default) | | indicatorBarLength | number | Shorthand for indicator inner bar length in pixels (32px horizontal / 64px vertical default) | | title | string | Custom tooltip text for the handle element | | onDragging | (isDragging: boolean) => void | Callback triggered when the primary button presses the handle and when the press ends (including pointercancel) — a drag, or a click on a handle with activationSizes; false comes only after every 'pointer' change of the press has been reported to onLayout | | className / style | string / React.CSSProperties | Additional styling for the resize handle | | children | React.ReactNode | Custom indicator element rendered inside handle (overrides default indicator) |

Handles render as <button role="separator"> elements accessible via arrow keys (←/→ for horizontal, ↑/↓ for vertical); holding Shift increases the step 5x. Under dir="rtl" the drag and keyboard directions follow the visual layout. The handle sits inside a zero-length anchor placed by flex layout, so its hit area is centred on the panel boundary without any position arithmetic.

PanelResizeHandle forwards any other DOM attribute to its <button>, and calls your onPointerDown / onKeyDown before its own handling — calling preventDefault() in them stops the library's gesture from starting. The attributes it computes from the layout (role, aria-orientation, aria-valuenow / aria-valuemin / aria-valuemax, aria-disabled, aria-hidden) are removed from PanelResizeHandleProps, though only role is actually caught by the compiler — the hyphenated names are not type-checked in JSX, and it is the runtime override that keeps them the library's. aria-label stays yours and replaces the built-in description, and title defaults to that same description.

Your style reaches the button between two library layers. The handle's resolved dimensions are published as CSS custom properties — --rp-handle-thickness, --rp-indicator-thickness, --rp-indicator-length, --rp-indicator-bar-thickness, --rp-indicator-bar-length — before it, so decorations of your own can be sized from the same numbers and may redefine them; the declarations that keep the hit area centred on the boundary (position: absolute, zIndex: 20, touchAction: none, the logical insets and the thickness / cross-axis 100% box) are applied after it and take no override. While the group reports a constraint violation, every handle is hidden (display: none on its anchor) as well as disabled.

Clicking instead of dragging

WCAG 2.2 Success Criterion 2.5.7 (Dragging Movements, level AA) asks that anything done by dragging can also be done with a single pointer and no drag; the arrow keys do not cover it, because a reader with a pointer may have no keyboard. activationSizes is that alternative for a handle, in the form of technique G219: a click or a tap moves the boundary through a list of preset lengths of the panel before the handle.

import { type FlexibleSize, Panel, PanelGroup, PanelResizeHandle } from "@aiquants/resize-panels"

// Keep the stops in one module-level constant: a new array on every render defeats the handle's memo.
const LIST_STOPS: readonly FlexibleSize[] = [
    { value: 200, unit: "pixels" },
    { value: 320, unit: "pixels" },
    { value: 480, unit: "pixels" },
]

<PanelGroup direction="horizontal">
    <Panel id="list" defaultSize={{ value: 320, unit: "pixels" }} minSize={{ value: 200, unit: "pixels" }} maxSize={{ value: 480, unit: "pixels" }} />
    <PanelResizeHandle activationSizes={LIST_STOPS} />
    <Panel id="detail" />
</PanelGroup>
  • Stops: each entry is a length of the panel before the handle, written like a panel's defaultSize / minSize / maxSize — { value, unit: "pixels" }, { value, unit: "percentage" }, or a bare number for a percentage of the group's content box, resolved against the container's length at the moment of the click. The order of the array does not matter.
  • Click or drag: a press of the primary button (a mouse's main button, a touch or a pen) is a click when the pointer is released before it travels 4 CSS px from where it went down. The distance is the straight line in viewport coordinates — the coordinates pointer events carry — taken before the drag converts the travel into the group's layout pixels, so neither right-to-left text nor a scaled ancestor changes what counts as a click, and a press that slides along the handle is not one. Until the pointer travels that far the boundary does not move at all, so a click never nudges it; once it does, the press is an ordinary drag that catches up with the pointer, the travel so far included. Only the pointer that pressed counts: other buttons, other pointers, and a press that ends in pointercancel or lost pointer capture never click.
  • Cycling: a click moves the boundary to the shortest stop longer than the panel's current length; from the longest stop it returns to the shortest.
  • Clamping: every stop is first clamped into the range a plain resize of the two panels can reach (both panels' minSize / maxSize applied to their combined length), so a click never collapses a panel. A stop that lands where the boundary already is gets skipped, and when every stop lands there the click changes nothing.
  • Collapsed neighbours: as with the arrow keys, only the handle a panel collapsed towards can reopen it. A click on that handle reopens the panel and moves the boundary to a stop in the same change; with either neighbour collapsed the boundary sits at an end of the pair, so that stop is the shortest. A click on a handle that cannot reopen its collapsed neighbour does nothing.
  • Notifications: a click is a press like a drag. onDragging(true) comes when the button goes down, the click's one change reaches onLayout as 'pointer', and onDragging(false) comes last, so a host that saves when a drag ends saves a click the same way. aria-valuenow follows the boundary as always.
  • Name: the built-in aria-label and title add click to cycle through preset sizes when stops are given; an aria-label or title of your own still replaces them.
  • No stops: without activationSizes (or with an empty array) a click does nothing, and a drag applies every move as it comes, with no threshold.
  • Invalid stops: a value that is not an array, or an entry that is not a non-negative finite size (NaN, a negative length, an unknown unit, …), throws a RangeError when the handle renders instead of being guessed at.

Hooks & Utilities

  • useResizablePanels: the hook behind PanelGroup. It measures the container, keeps the panel order equal to DOM order, notifies onLayout, persists the layout and returns the group's base style — it never computes a panel size.
  • usePanelGroup / PanelGroupContext: context accessor, and the context itself, for the panel list, the container length, the constraint violation and layoutOrderKey — the document order of the panels and handle anchors, which changes even when only a handle moved to another boundary. Its resizePanels, collapsePanel and expandPanel take an optional last source ('pointer' | 'keyboard' | 'api', 'api' when left out) that onLayout reports for the change, and throw a RangeError for any other value.
  • usePanelControls: collapse, expand and toggle for a given panel id; onLayout reports their changes as 'api'.
  • allocatePanelSizes, resolvePanelBounds, isPixelPanel: the layout calculation itself, exported for tests and for hosts that drive their own container.
  • panelReducer, initialPanelGroupLayoutState, getPanelPreferenceInPixels, findLayoutInvariantBreach: the state machine around the allocator, the length a layout should persist, and the Σ sizes == container check.
  • getContainerSize: measure one axis of an element in layout pixels; it returns { content, border }, and content (padding and borders removed) is the length the allocator fills.
  • findOwningGroup, getGroupLayoutElements: resolve the group that owns an element, and that group's panels and handle anchors in document order — across wrapper elements, display: contents included, while skipping both a nested group's own elements and any data-panel-id that belongs to a panel's content rather than to this group.
  • saveLayout & loadLayout: persist or restore a layout ({ id, size, collapsed }[]).
  • clamp, getConstraintInPixels, normalizeSizeConfig, percentageToPixels, toRoundedPercentage, calculateSnapThreshold: the unit conversions and thresholds the components share.
  • getPanelElement, getPanelGroupElement, getResizeHandleElement: DOM element query helpers via data-* attributes.

Every public type (PanelProps, PanelGroupProps, PanelResizeHandleProps, PanelLayoutData, PanelRegistration, PersistedPanelSize, PanelGroupLayoutState, PanelAction, PanelLayoutChange, PanelLayoutSource, PanelRequestSource, LayoutAllocation, PanelBounds, ContainerAxisMeasurement, …) is re-exported from the package root; docs/specs (00-architecture-overview.md §8) lists the surface in full.

How sizes are decided

| Panel kind | What it declares | What it gets | | --- | --- | --- | | Flexible (percentage, or a bare number) | a weight — only the ratio between such weights matters | its share of what is left after the panels with an absolute target, clamped to its bounds | | Flexible with flexAdjustPriority | the same number, read as a claim on that share of the container | that claim, served after the pixel panels and clamped to its bounds | | Pixel (pixels) | an absolute length | that length, clamped to its bounds, once the panels ahead of it in pixelAdjustPriority order have been served |

Scarcity and surplus are two ends of one order. While space is scarce, targets are served in that order — pixel panels by pixelAdjustPriority, then flexible panels by flexAdjustPriority — so the panel last in line is the one that falls short of its target, even when every declared minimum would fit. While space is left over, it is offered to the ratio panels by weight, then to the prioritized flexible panels by weight, then to the pixel panels in pixelAdjustPriority order up to their maxSize, and last, in equal parts, to the flexible panels that have a weight of 0 and no flexAdjustPriority. A sidebar declared in pixels therefore grows past its declared length when nothing else can absorb the surplus: give it a maxSize to pin it. When a clamp bites, the residue is redistributed among the remaining panels of the same stage in their own ratio, and the container ends up exactly filled as long as one panel can still take the surplus; when none can, the gap is reported — as a constraint violation when the panels' maximums cannot reach the container, and as a logged invariant breach otherwise. The full statement, including the proofs of ratio preservation, continuity and the drag fixed point, is in docs/specs (11-layout-allocator.md).

Layout Notifications

onLayout(sizes, change) runs after every commit that replaces the panels' sizes once the container has been measured, so it never sees the unmeasured zero lengths. sizes lists the allocated lengths in pixels in DOM order, and change.source (PanelLayoutSource) says what caused the change:

| change.source | Cause | | --- | --- | | 'pointer' | A drag on a PanelResizeHandle, including a collapse or an expand at either end of it, or a click that moves the handle to one of its activationSizes | | 'keyboard' | The arrow keys on a PanelResizeHandle, including the collapse and expand they trigger | | 'api' | The host: usePanelControls, or resizePanels / collapsePanel / expandPanel from usePanelGroup() called without a source | | 'container' | The group's content box changed length along its axis, or the group measured it afresh — its first measurement and the one after a direction change included, so a group rendered with its panels reports 'container' first | | 'panels' | A panel mounted, unmounted, changed its props or moved in the DOM | | 'restore' | The layout stored under autoSaveId was applied |

When several changes are committed in one render, source is the cause of the last of them. React's StrictMode does not change the notifications: when a group stops observing its container it drops the measurement, so the development-only remount measures again after the panels have registered again, as the first mount did. A pointer press and release without movement on a handle without activationSizes, a key that pushes a boundary already at the end of its range and a resize of the cross axis change nothing and are not reported; a commit can, however, repeat the previous sizes (a panel re-registering, a stored layout equal to the current one, a direction change), so handlers should accept the same sizes twice. A handler that declares only sizes keeps working.

A drag reports each pointer move it applies, and commits each one before it handles the next pointer event. Every 'pointer' notification of a drag — the last move held back by the 8 ms throttle included — therefore reaches onLayout before the handle calls onDragging(false), however the drag ends (release, pointercancel, lost pointer capture or unmount). A click on a handle with activationSizes is a press too: its one change is committed before the press ends, so it also arrives between onDragging(true) and onDragging(false). Pair 'pointer' with onDragging(false) to act once when the press ends; a key press is a complete change of its own and never calls onDragging.

const saveWidth = (width: number) => localStorage.setItem("tree-width", String(width))
const draggedWidth = useRef<number | null>(null)

<PanelGroup
    direction="horizontal"
    onLayout={(sizes, { source }) => {
        // A container resize redistributes the same preference, so only the reader's own moves are saved.
        if (source === "keyboard") {
            saveWidth(sizes[0])
        } else if (source === "pointer") {
            draggedWidth.current = sizes[0]
        }
    }}>
    <Panel id="tree" defaultSize={{ value: 30, unit: "percentage" }} />
    <PanelResizeHandle
        onDragging={(isDragging) => {
            // Every move of the drag has been reported by now, so this is the width the drag ended on.
            if (!isDragging && draggedWidth.current !== null) {
                saveWidth(draggedWidth.current)
                draggedWidth.current = null
            }
        }}
    />
    <Panel id="content" defaultSize={{ value: 70, unit: "percentage" }} />
</PanelGroup>

Layout Persistence

When autoSaveId is provided to PanelGroup, the layout is written to localStorage under the key panel-layout-<autoSaveId> as { id, size, collapsed }[], 200ms after the layout stops changing: a change arriving inside that window restarts the timer rather than forcing a write, so a drag never runs synchronous storage I/O once per frame. The one write outside that timer is the flush on unmount, which hands over the entries still pending so the last layout is not lost to a navigation. The stored size is the panel's preference — the length it occupies while open — not its allocated size, so a panel saved while collapsed still knows what to reopen to. On the next mount, and again whenever autoSaveId changes, the payload is applied only if it names exactly the panels that are present; a payload from a different panel set is ignored rather than applied by position. Restoring into a container of a different length scales a flexible panel by ratio, because its stored length is re-read as a share of the current container, while a pixel panel restores the absolute length it had.

Debugging & Inspection

Enabling showDebugInfo on PanelGroup renders inspection metrics over each Panel (container length, allocated size, preference, resolved bounds) along with a global inspector: a header counting the groups, panels and resize handles on the page, then one card per group carrying its direction and container box and, for every panel, the state size next to the measured size, both percentages, the preference, the unit, the declared bounds and the two priorities. Individual handle measurements and constraint violations are not part of that overlay; a violation is shown by the placeholder the group draws over its panels, with or without the flag. Debug components are lazily imported (React.lazy); without the flag no per-panel ResizeObserver is created.

Styling (CSS)

The package provides two CSS consumption options:

  • Tailwind v4 Host: Import components-only styles into layer(components) and configure Tailwind to scan package source files:

    /* app tailwind.css */
    @import "@aiquants/resize-panels/styles/resize-panels.css" layer(components);
    @source "../node_modules/@aiquants/resize-panels/src/**/*.{ts,tsx}";
    /* monorepo: @source "../../../../packages/resize-panels/src/**/*.{ts,tsx}"; */
  • Non-Tailwind Host: Import the self-contained standalone CSS bundle:

    @import "@aiquants/resize-panels/styles/resize-panels.standalone.css";

    Dark mode activates based on the .dark class on <html>.

Style ownership

Your className and style reach all three components, but the declarations the layout itself depends on are applied after them and do not accept an override:

  • Group: display: flex, flexDirection, alignItems: stretch, gap: 0px, position: relative, boxSizing: border-box, writingMode: horizontal-tb. The presentation defaults (width, height, minWidth, minHeight, overflow) are yours to change.
  • Panel: the flex triple (flexBasis / flexGrow / flexShrink), order: 0, margin: 0, display: flex, flexDirection: column, position: relative, boxSizing: border-box, minWidth and minHeight: 0, maxWidth and maxHeight: none, the cross-axis 100%, and display: none while collapsed. The presentation defaults you can override are overflow: hidden and isolation: isolate.
  • Handle button: position: absolute, zIndex: 20, touchAction: none, the logical inset that centres it on the boundary, its primary-axis thickness and its cross-axis 100%. The --rp-* custom properties are placed before your style, so those you may redefine.

Both halves of that list are written to the style attribute, so a presentation default is overridable by your style only — a utility class loses to the inline declaration it would replace, which is why the examples above give the group its height through style rather than an h-* class. Every property the components do not write inline (colour, padding, borders, radius, …) stays entirely yours to set by class.

A panel owns maxWidth / maxHeight because a single utility class (max-w-* and friends) would otherwise draw it shorter than the length the allocator handed out, leaving state and rendering disagreeing with the handle stuck. It defaults to isolation: isolate so that a z-index used inside a panel cannot rise above the handles; a panel whose content must escape its own box — a position: fixed maximised overlay, say — opts out with style={{ isolation: "auto" }} and accepts that the content may then cover a handle. That trade-off is inherent to stacking contexts: nothing can both take part in the panel's own stacking order and paint above its siblings.

A panel renders its children inside an in-flow flex: 1 1 auto wrapper, so padding you set on a panel behaves normally; contentOverflow chooses how that wrapper scrolls. The group keeps overflow: hidden so that a handle straddling a boundary cannot raise a scrollbar — at the cost that when an edge panel collapses, the outer half of the handle now sitting on the group's own edge is clipped (its centre stays on the boundary).

Demo App

Run the included workspace demo app:

pnpm --filter '@aiquants/resize-panels-demo' dev

Open http://localhost:5175 to test interactive layouts.

Build

pnpm --filter '@aiquants/resize-panels' build

Outputs built artifacts to dist/.