Tiny, dependency-free movable & resizable in-app modal panes for the web — written in TypeScript, unified mouse/touch/pen input via Pointer Events.
- ~5 KB min+gzip, zero dependencies
- Works with mouse, touch, and pen via Pointer Events
- Ships ESM + CJS + IIFE with
.d.tstypes - Framework-agnostic — drop-in for plain HTML, React, Vue, Svelte, Solid, Angular
- Accessible:
role="dialog"+aria-labelledbywiring, keyboard close requestAnimationFrame-coalesced drag/resize, pointer capture, and proper teardown
- Prerequisites
- Install
- Quickstart
- CDN (no build step)
- Framework examples
- DOM contract
- API
- Styling
- Accessibility
- Browser support
- Building from source
- Security
- Changelog
- License
The package is named
panes-uion npm (the bare namepaneswas taken). The JavaScript global exposed by the CDN/IIFE build is stillPanes.
panes-ui is a browser-only library.
| Requirement | Minimum |
|---|---|
| Runtime | Any modern browser with Pointer Events (Chrome 55+, Firefox 59+, Safari 13+, Edge 79+) |
| Node (build) | Node 16+ (only needed if you consume via a bundler) |
| TypeScript | 4.5+ (optional; JS works fine) |
There is no SSR runtime — construct panes from browser code (useEffect, onMount, etc. in frameworks).
npm install panes-ui
# or
pnpm add panes-ui
# or
yarn add panes-uiThis installs both the JavaScript and the types. Also import the stylesheet once:
import "panes-ui/style.css";If your bundler doesn't handle CSS imports, copy node_modules/panes-ui/dist/pane.css into your static assets and link it with a <link rel="stylesheet">.
<div id="my-pane" style="width:360px;height:240px">
<div class="pane-title">Hello</div>
<button class="pane-close" aria-label="Close">×</button>
<div class="pane-content">
Drag my titlebar. Resize from any edge or corner. Touch works too.
</div>
</div>import { Pane } from "panes-ui";
import "panes-ui/style.css";
const pane = new Pane("#my-pane", { title: "Hello" });
pane.open();Or use the one-line shortcut:
import { open } from "panes-ui";
open("#my-pane", { title: "Hello" }); // lazy-creates + opens<link rel="stylesheet" href="https://unpkg.com/panes-ui/dist/pane.css" />
<script src="https://unpkg.com/panes-ui"></script>
<script>
Panes.open("#my-pane", { title: "Hello" });
</script>Pin a version for production: https://unpkg.com/panes-ui@1.1.0/....
import { useEffect, useRef } from "react";
import { Pane } from "panes-ui";
import "panes-ui/style.css";
export function MyModal({ open }: { open: boolean }) {
const ref = useRef<HTMLDivElement>(null);
const paneRef = useRef<Pane | null>(null);
useEffect(() => {
if (!ref.current) return;
paneRef.current = new Pane(ref.current, { title: "Report" });
return () => paneRef.current?.destroy();
}, []);
useEffect(() => {
if (!paneRef.current) return;
open ? paneRef.current.open() : paneRef.current.close();
}, [open]);
return (
<div ref={ref} style={{ width: 420, height: 300 }}>
<div className="pane-title" />
<button className="pane-close" aria-label="Close">×</button>
<div className="pane-content">…</div>
</div>
);
}<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref } from "vue";
import { Pane } from "panes-ui";
import "panes-ui/style.css";
const root = ref<HTMLDivElement>();
let pane: Pane | null = null;
onMounted(() => { if (root.value) pane = new Pane(root.value, { title: "Report" }).open(); });
onBeforeUnmount(() => pane?.destroy());
</script>
<template>
<div ref="root" style="width:420px;height:300px">
<div class="pane-title" />
<button class="pane-close" aria-label="Close">×</button>
<div class="pane-content"><slot /></div>
</div>
</template><script lang="ts">
import { onMount, onDestroy } from "svelte";
import { Pane } from "panes";
import "panes/style.css";
let el: HTMLDivElement;
let pane: Pane;
onMount(() => { pane = new Pane(el, { title: "Report" }).open(); });
onDestroy(() => pane?.destroy());
</script>
<div bind:this={el} style="width:420px;height:300px">
<div class="pane-title" />
<button class="pane-close" aria-label="Close">×</button>
<div class="pane-content"><slot /></div>
</div>Any element can become a pane. Its optional children are discovered by selector:
| Selector | Role |
|---|---|
.pane-title |
Drag handle. Click-and-drag here to move the pane. |
.pane-content |
Scrollable body. Text is selectable. |
.pane-close |
Element (usually a <button>) that closes it. |
Selectors are configurable per instance (see titleSelector, contentSelector, closeSelector).
The pane element must have a non-static position. Panes defaults to position: fixed if the element is statically positioned.
target— anElement, an elementid, or a CSS selector.options— see below.manager— an optionalPaneManagerfor isolated stacking contexts.
interface PaneOptions {
title?: string | Element;
titleSelector?: string; // default ".pane-title"
contentSelector?: string; // default ".pane-content"
closeSelector?: string; // default ".pane-close"
movable?: boolean; // default true
resizable?: boolean; // default true
minWidth?: number; // default 120
minHeight?: number; // default 80
constrain?: boolean; // default true — keep inside viewport
center?: boolean; // default true — center on first open()
closeOnEscape?: boolean; // default true
onOpen?: (pane: Pane) => void;
onClose?: (pane: Pane) => void;
onMove?: (pane: Pane, left: number, top: number) => void;
onResize?: (pane: Pane, width: number, height: number) => void;
onFocus?: (pane: Pane) => void;
}| Method | Purpose |
|---|---|
open() |
Show the pane (centers on first open). |
close() |
Hide the pane. |
toggle() |
Open if hidden, close if visible. |
isOpen() |
Whether the pane is currently visible. |
focus() |
Raise above other panes. |
center() |
Re-center in the viewport. |
setPosition(left, top) |
Move the pane (respects constrain). |
setSize(width, height) |
Resize the pane (respects min sizes). |
getRect() |
Current viewport-relative geometry. |
destroy() |
Detach listeners and unregister. Idempotent. |
A singleton manager assigns z-index. Calling focus() (or any pointerdown on a pane) raises it to the top. For isolated stacking, construct your own:
import { Pane, PaneManager } from "panes-ui";
const myManager = new PaneManager();
const p = new Pane("#a", {}, myManager);z-index values are automatically rebased when they grow large, so long sessions don't drift upward.
Import panes-ui/style.css for the default look, or write your own — the library only reads geometry, never your colors.
Interactive state is exposed as classes on the root element:
| Class | When |
|---|---|
.pane |
Always, after construction |
.pane--drag |
During a drag |
.pane--resize |
During a resize |
panes-ui sets the following on the root on construction (unless you've set them yourself):
role="dialog"aria-modal="false"— panes are non-blocking in-app windows, not modal dialogs. If you want a true modal, implement a backdrop + focus trap above it.tabindex="-1"— allows programmatic focusaria-labelledby— wired to the title element when one exists
Escape key closes the pane (disable via closeOnEscape: false).
Chrome 55+, Firefox 59+, Safari 13+, Edge 79+ (anything with Pointer Events). No IE support.
git clone https://github.com/silicondaydream/panes.git
cd panes
npm install
npm run build # ESM + CJS + IIFE + .d.ts in dist/
npm run typecheckpanes-uinever executes user-supplied strings, never evaluates event-handler strings, and never injects HTML.options.titleis set viatextContent/appendChild, notinnerHTML.- If you embed user-generated content inside
.pane-content, you are responsible for sanitizing it. - The library does not make any network requests.
Found a vulnerability? See SECURITY.md.
- Pointer Events — one unified code path for mouse, touch, and pen.
requestAnimationFramecoalescing — drag/resize stays smooth under load.- Pointer capture — no lost drags when the cursor leaves the pane.
- Viewport constraints on resize + window-resize reflow — panes can't get stranded off-screen.
- Proper teardown (
destroy()) — no leaked listeners. - Keyboard: Escape closes; close button is focusable.
- Defensive errors: descriptive throws on bad input.
- TypeScript types out of the box.
MIT © Chris Adams