Floating Window
A movable, resizable window over your app, the way a desktop keeps a second document at hand. Drag it by its title bar, resize it from the corner, double-click the bar to fold it away. Geometry is in rem, so it scales with the root font size, and the app keeps the state.
Installation#
Usage#
import { FloatingWindow, FloatingWindowLayer } from "@/components/ui/floating-window"
export function Example() {
const [win, setWin] = React.useState({ x: 2, y: 2, width: 24, height: 16 })
return (
<div className="relative h-screen">
<FloatingWindowLayer>
<FloatingWindow
title="Unit 12"
{...win}
onMove={(x, y) => setWin((w) => ({ ...w, x, y }))}
onResize={(width, height) => setWin((w) => ({ ...w, width, height }))}
>
<UnitDetails />
</FloatingWindow>
</FloatingWindowLayer>
</div>
)
}Anatomy#
import { FloatingWindow, FloatingWindowLayer } from "@/components/ui/floating-window"
<FloatingWindowLayer>
<FloatingWindow title="…" x={…} y={…} width={…} height={…}>
…
</FloatingWindow>
</FloatingWindowLayer>Managing windows#
The window is controlled: it reports a drag through onMove(x, y) and a resize through onResize(width, height), already kept inside the layer and above the minimum, and draws whatever you pass back. The z-order is yours too: give each window a zIndex and raise it in onFocusWindow, which fires when the window is pressed anywhere or focus moves into it from outside. A few lines of state are the whole window manager.
function useWindows(initial: Win[]) {
const [windows, setWindows] = React.useState(initial)
const top = React.useRef(initial.length)
const patch = (id: string, change: Partial<Win>) =>
setWindows((ws) => ws.map((w) => (w.id === id ? { ...w, ...change } : w)))
return {
windows,
focus: (id: string) => patch(id, { zIndex: ++top.current }),
move: (id: string, x: number, y: number) => patch(id, { x, y }),
resize: (id: string, width: number, height: number) => patch(id, { width, height }),
toggle: (id: string) =>
setWindows((ws) => ws.map((w) => (w.id === id ? { ...w, minimized: !w.minimized } : w))),
close: (id: string) => setWindows((ws) => ws.filter((w) => w.id !== id)),
}
}Minimize and controls#
minimized folds the window up to its title bar, like a window shade. Its content stays mounted (a half-written note survives) and turns inert until it unfolds. The bar shows a control only for a handler you pass: onToggleMinimize adds the minimize button and the double-click on the bar, onClose adds the close button. Double-click the folded bar below.
Sizing in rem#
x, y, width, height, minWidth (18 by default) and minHeight (4.25: the bar and 2rem of content) are all rem, so a UI that scales by changing the root font size scales its windows with it. The pointer is converted with the root font size read when a drag starts. A move keeps 6rem of the window inside the layer on the left (the controls) and 4rem on the right, and the bar never leaves the top or bottom; a resize stops half a rem short of the layer's far edges. Without a FloatingWindowLayer around it, a window is unbounded.
API reference#
FloatingWindowLayer
An absolute inset-0 <div> over its positioned parent, transparent to the pointer except where a window is. Its box bounds the windows inside it; a FloatingWindow outside a layer still works, without bounds. Every div prop is forwarded, ref included; className is merged last.
| Data attribute | Description |
|---|---|
| data-slot | "floating-window-layer". |
FloatingWindow
A non-modal role="dialog" named by its title (or your aria-label). Controlled: it reports moves and resizes and the app keeps the state, z-order included. Every other div prop is forwarded; style merges over the geometry and className is merged last.
| Prop | Type | Default | Description |
|---|---|---|---|
| title* | React.ReactNode | - | The window's name: shown in the bar, and the dialog's accessible name. |
| icon | React.ElementType | - | A glyph before the title: the component, not an element (icon={Browser}). |
| x* | number | - | Left edge inside the layer, in rem. |
| y* | number | - | Top edge inside the layer, in rem. |
| width* | number | - | Width in rem. |
| height* | number | - | Height in rem, while not minimized. |
| minimized | boolean | false | Folded to the title bar. The content stays mounted (and inert). |
| zIndex | number | - | Stacking order among the layer's windows. Raise it in onFocusWindow. |
| minWidth | number | 18 | Smallest width a resize can reach, in rem. |
| minHeight | number | 4.25 | Smallest height a resize can reach, in rem: the bar and 2rem of content. |
| onFocusWindow | () => void | - | The window was pressed, or focus moved into it: bring it to the front. |
| onMove | (x: number, y: number) => void | - | A drag of the title bar: the new x and y, in rem, already kept inside the layer. |
| onResize | (width: number, height: number) => void | - | A drag of the corner grip: the new width and height, in rem, within the limits. |
| onToggleMinimize | () => void | - | Shows the minimize button and enables double-click on the bar. |
| onClose | () => void | - | Shows the close button. |
| minimizeLabel | string | "Minimize" | The minimize button's accessible name. |
| closeLabel | string | "Close" | The close button's accessible name. |
| static | boolean | false | Drops the press scale of the bar's controls. |
| Data attribute | Description |
|---|---|
| data-slot | "floating-window", with floating-window-bar, -title, -controls, -minimize, -close, -body and -grip inside. |
| data-state | "open" or "minimized", on the window and on its body. |
| CSS variable | Description |
|---|---|
| --surface | Set to var(--popover) on the body, the ground its controls paint on. |