# wmkit **Headless window manager for the web.** Draggable, resizable, snappable windows with a taskbar model, keyboard accessibility and state persistence — for vanilla JS and every major framework. [Русская версия](./README.ru.md) · [Live demo](https://wmkit.vercel.app) · [Mirror (Pages)](https://surdeddd.github.io/wmkit/) · [GitHub](https://github.com/Surdeddd/wmkit) [![CI](https://github.com/Surdeddd/wmkit/actions/workflows/ci.yml/badge.svg)](https://github.com/Surdeddd/wmkit/actions/workflows/ci.yml) [![npm](https://img.shields.io/npm/v/@surdeddd/wmkit)](https://www.npmjs.com/package/@surdeddd/wmkit) [![license](https://img.shields.io/badge/license-MIT-2dd4a8)](./LICENSE) [![wmkit — live demo desktop](https://raw.githubusercontent.com/Surdeddd/wmkit/main/.github/assets/hero.png)](https://surdeddd.github.io/wmkit/)

Every window above is real — open the demo and drag one.

- 🪟 **Full window lifecycle** — open, close, focus, minimize, maximize, restore, drag, 8-direction resize - 🧠 **Headless core** — a serializable state machine plus a DOM controller; bring your own markup or use the glass theme - ⚛️ **Official adapters** — `@surdeddd/wmkit/react`, `/vue`, `/svelte`, `/solid`, `/angular`, all thin sugar over one core - ⊞ **Snap zones** — halves, quarters and drag-to-top maximize with a live preview - 🧲 **Magnetism** — window edges align to neighbours and the viewport while dragging - ↩️ **Undo/redo** — every mutation is one step; a whole drag collapses into a single history entry - 🗂️ **Named layouts & arrange** — save/load desktop snapshots, `cascade`/`tile` in one call - ⌨️ **Accessible** — keyboard move/resize, F6 window cycling, focus-trapped modals, `aria-live` announcements - ⚡ **Fast** — `transform`-only positioning, rAF-batched pointer input, structural sharing; 50 windows drag at 60fps - 💾 **Persistence** — one call to serialize the desktop, one call to restore it - 🎨 **Three themes** — dark glass, light glass and Win98 retro, or bring your own CSS - 🖼️ **Popout** *(experimental)* — send a window into Document Picture-in-Picture - 📦 **Zero dependencies**, strict TypeScript, ESM + CJS, ~9.3 kB brotli core ## Install ```bash npm install @surdeddd/wmkit # or pnpm add @surdeddd/wmkit ``` ## Quick start (vanilla) ```js import { createWindowManager, attachDesktop } from '@surdeddd/wmkit' import '@surdeddd/wmkit/themes/glass.css' const wm = createWindowManager() const desktop = attachDesktop(wm, document.querySelector('#desktop')) const win = wm.open({ title: 'Hello', width: 420, height: 280 }) const el = document.createElement('section') el.innerHTML = `
Hello
Anything you want.
` document.querySelector('#desktop').append(el) desktop.attachWindow(win.id, el, { removeOnClose: true }) ``` The desktop element becomes the coordinate space. Your markup stays yours — wmkit wires behavior onto `data-wm-*` attributes: | Attribute | Meaning | | --- | --- | | `data-wm-drag` | drag handle (usually the titlebar); double-click toggles maximize | | `data-wm-title` | window title node, linked via `aria-labelledby` | | `data-wm-close` / `data-wm-minimize` / `data-wm-maximize` | control buttons, wired by delegation | | `data-wm-content` | scrollable content area (styled by themes) | `removeOnClose` detaches the controller and removes the element when the window closes. Touch devices get larger resize hit areas and snap thresholds automatically (`pointer: coarse`); tune via `attachDesktop(wm, el, { hitAreas: { edge, corner } })`. The controller adds resize handles (`[data-wm-resize]`), a snap preview (`[data-wm-snap-preview]`) and a visually hidden live region for screen readers. ## React ```tsx import { useWindowManager, useDesktop, useWmState, useWmWindowRef } from '@surdeddd/wmkit/react' import '@surdeddd/wmkit/themes/glass.css' function Desktop() { const wm = useWindowManager() const { ref, binder } = useDesktop(wm) const state = useWmState(wm) return (
{state.order.map((id) => { const win = state.windows[id] return win ? : null })}
) } function Win({ binder, win }) { const ref = useWmWindowRef(binder, win.id) return (
{win.title}
Your React tree lives here — no portals, no innerHTML.
) } ``` `useWmState` subscribes through `useSyncExternalStore`; unchanged windows keep referential identity, so memoized children skip re-renders. ## Vue ```vue ``` ## Svelte ```svelte
{$main?.title}
stores and actions, no wrapper components
``` ## Solid ```tsx import { For } from 'solid-js' import { useWindowManager, createDesktop, useWmState } from '@surdeddd/wmkit/solid' function Desktop() { const wm = useWindowManager() const dk = createDesktop(wm) const state = useWmState(wm) wm.open({ title: 'Hello' }) return (
{(id) => (
{state().windows[id]?.title}
fine-grained, obviously
)}
) } ``` ## Angular ```ts import { AfterViewInit, Component, ElementRef, ViewChild } from '@angular/core' import { useWindowManager, createDesktop, useWmState } from '@surdeddd/wmkit/angular' @Component({ selector: 'app-desktop', standalone: true, template: `
Hello
signals inside
`, }) export class DesktopComponent implements AfterViewInit { wm = useWindowManager() dk = createDesktop(this.wm) state = useWmState(this.wm) @ViewChild('desktop') desktopRef!: ElementRef @ViewChild('hello') helloRef!: ElementRef ngAfterViewInit(): void { this.wm.open({ id: 'hello', title: 'Hello' }) this.dk.desktop(this.desktopRef.nativeElement) this.dk.window('hello')(this.helloRef.nativeElement) } } ``` `useWmState` returns a read-only `Signal` and `useWmWindow(wm, id)` a computed per-window signal, so templates track updates fine-grained. Hooks called in an injection context clean up through `DestroyRef` automatically; outside one they simply skip auto-cleanup. ## Snap zones in action [![Snap zones — window tiled to the left half](https://raw.githubusercontent.com/Surdeddd/wmkit/main/.github/assets/snap.png)](https://surdeddd.github.io/wmkit/) Throw a window against an edge or corner — a live preview shows the target zone, releasing tiles it. Halves, quarters, and drag-to-top maximize. ## Core API ### `createWindowManager(options?)` Pure state machine — no DOM access, safe to create during SSR. ```ts interface ManagerOptions { viewport?: { width: number; height: number } keepInViewport?: boolean // clamp windows so the titlebar stays reachable (default true) minVisible?: number // minimum visible strip in px (default 48) defaultSize?: { width: number; height: number } cascadeOffset?: number // auto-position step for new windows (default 32) cascadeOrigin?: { x: number; y: number } idPrefix?: string historyLimit?: number // undo/redo depth (default 50, 0 disables history) } ``` Manager methods: | Method | Notes | | --- | --- | | `open(init?)` → `WindowState` | throws on duplicate `id`; cascades position when `x`/`y` omitted | | `close(id)` / `closeAll()` | focus moves to the next eligible window | | `focus(id)` / `blur()` / `cycleFocus(dir?)` | focusing a minimized window restores it; modals block focus below them | | `minimize(id)` / `maximize(id)` / `restore(id)` / `toggleMaximize(id)` | restore returns to the pre-minimize stage, including maximized/snapped | | `snap(id, zone)` | `'left' \| 'right' \| 'top' \| 'bottom' \| 'top-left' \| …` | | `move(id, x, y)` / `moveBy(id, dx, dy)` / `resize(id, patch)` | resizing a snapped window unsnaps it | | `restoreTo(id, bounds)` | used for drag-off-snap; stage → `normal` at explicit bounds | | `update(id, patch)` | title, layer, min/max size, per-window flags, `meta` | | `setViewport(size)` | re-derives maximized/snapped bounds, clamps the rest | | `serialize()` / `hydrate(data)` | JSON-safe snapshot of the whole desktop | | `undo()` / `redo()` / `canUndo()` / `canRedo()` / `clearHistory()` | every mutation is one step; a whole drag or resize collapses into a single entry | | `saveLayout(name)` / `loadLayout(name)` / `deleteLayout(name)` / `layoutNames()` | named desktop snapshots; `getLayout`/`setLayout` for external storage | | `arrange('cascade' \| 'tile')` | cascade staggers restored sizes, tile fills the viewport in a grid | | `minimizeAll()` / `restoreAll()` | bulk stage switches in one history step | | `subscribe(fn)` / `on(event, fn)` | granular events: `open, close, focus, move, resize, stage, update, order, modalblocked` | | `batch(fn)` | coalesce many operations into one `change` notification | Windows carry `layer: 'normal' | 'floating' | 'modal'` — floating stays on top, modals trap focus and block interaction below (blocked attempts emit `modalblocked` and flash the modal). ### `attachDesktop(wm, element, options?)` DOM controller: pointer drag with capture (touch/pen included), 8-direction resize, snap detection with preview, keyboard handling, ARIA wiring, FLIP-to-taskbar animation. ```ts interface DesktopOptions { snap?: boolean | { threshold?: number; cornerSize?: number; preview?: boolean; topEdge?: 'maximize' | 'top' | 'none' } keyboard?: boolean | { moveStep?: number; cycle?: boolean } announce?: boolean | Partial // localize screen-reader strings here autoViewport?: boolean // ResizeObserver → wm.setViewport (default true) magnetism?: boolean | { threshold?: number } // edge-align to neighbours + viewport while dragging (default on, 8 px / 12 px coarse) hitAreas?: { edge?: number; corner?: number } // resize handle thickness (auto-doubles on touch) minimizeTarget?: (win: WindowState) => Element | null // FLIP ghost target on minimize onTitlebarContextMenu?: (win: WindowState, event: MouseEvent) => void // right-click / long-press hook for your own menu } ``` Keyboard defaults: arrows move the focused window (16 px), `Alt` for 1 px steps, `Shift+arrows` resize, `F6` / `Shift+F6` cycle windows, `Escape` cancels an in-flight drag or resize. ### `persist(wm, options?)` — `@surdeddd/wmkit/persist` ```js import { persist } from '@surdeddd/wmkit/persist' const store = persist(wm, { key: 'my-desktop' }) // auto-restores, then debounce-saves on change store.clear() ``` Storage defaults to `localStorage` (probed safely — SSR and private-mode friendly) and accepts any `getItem/setItem/removeItem` implementation. ### `popout(wm, id, contentEl, options?)` — `@surdeddd/wmkit/popout` *(experimental)* Moves a window's content into a [Document Picture-in-Picture](https://developer.mozilla.org/docs/Web/API/Document_Picture-in-Picture_API) always-on-top OS window, keeping the same JS context and state. Feature-detect with `isPopoutSupported()`. ## Theming `@surdeddd/wmkit/themes/glass.css` styles the `data-wm-*` attributes and exposes CSS variables: ```css [data-wm-desktop] { --wm-radius: 14px; --wm-bg: rgba(22, 24, 34, 0.55); --wm-accent: #7c6cff; /* --wm-border, --wm-shadow, --wm-titlebar-bg, --wm-text, --wm-blur, --wm-transition … */ } ``` Two more ready-made themes ship alongside: `themes/light.css` (light glass) and `themes/retro.css` (pixel-perfect Win98 nostalgia). All three style the same `data-wm-*` attributes, so switching is a one-line import swap. Skip the import entirely and the library stays headless: state attributes (`data-wm-stage`, `data-wm-focused`, `data-wm-dragging`, `data-wm-flash`, `[hidden]`) are yours to style. ## SSR The core never touches `window`/`document` — create managers and even `hydrate()` state on the server, then call `attachDesktop` after mount. `persist` no-ops without usable storage. ## Comparison | | wmkit | WinBox | jsPanel4 | Dockview | Zag floating-panel | | --- | --- | --- | --- | --- | --- | | Maintained | ✓ 2026 | ✗ since 2023 | ✗ since 2022 | ✓ | ✓ | | Headless core | ✓ | ✗ | ✗ | ~ own UI | ✓ | | Official adapters | React·Vue·Svelte·Solid·Angular | community | ✗ | React·Vue·Angular | via Ark UI | | Multi-window (z-order, taskbar, modals) | ✓ | partial | partial | dock groups | ✗ single panel | | Snap zones + preview | ✓ | ✗ | ✗ | — | ✗ | | Keyboard + screen reader | ✓ | ✗ | ✗ | partial | partial | | Persistence built in | ✓ | ✗ | ✗ | ✓ | ✗ | | Document PiP popout | ✓ | ✗ | ✗ | window.open | ✗ | | TypeScript | strict | @types | ✗ | ✓ | ✓ | *(checked July 2026: commit history, npm downloads, open feature requests)* ## Quality - 172 unit tests, **100%** line/branch/function/statement coverage on the core state machine and persistence - 178+ Playwright scenarios on Chromium, WebKit and mobile emulation: drag, 8-way resize, snap, magnetism, undo after drag, keyboard, touch, persistence across reloads, 50-window stress, modal traps, axe accessibility scans, visual regression screenshots - performance benchmarks run in CI on every push (`vitest bench`): 1 000 windows open in ~150 ms, a move among 50 windows costs ~1.2 µs, a full 100-step undo/redo sweep ~52 µs - `publint` + `@arethetypeswrong/cli` validate the published package, `size-limit` guards bundle budgets ## Development ```bash pnpm install pnpm dev # landing + playground on Vite pnpm test # unit tests pnpm test:e2e # Playwright matrix pnpm verify # the full gate: lint, types, coverage, build, size, publint, e2e ``` ## License [MIT](./LICENSE) © Maksim Kravcov