A draggable, resizable window inside a container.
Notes
Drag by the header. Resize from any side or corner.
Preview
Windows stay within the container.
You can have multiple windows.
1import { useState } from "react";2
3import { Window, WindowContainer } from "@/components/ui/pxl/window";4
5export default function WindowDemo() {6 const [windows, setWindows] = useState([7 { id: 1, title: "Notes", x: 24, y: 24, w: 320, h: 180 },8 { id: 2, title: "Preview", x: 180, y: 120, w: 360, h: 200 },9 ]);10
11 return (12 <WindowContainer className="absolute size-full flex-1 min-h-0">13 {windows.map((w) => (14 <Window15 key={w.id}16 title={w.title}17 initialX={w.x}18 initialY={w.y}19 initialWidth={w.w}20 initialHeight={w.h}21 onClose={() =>22 setWindows((prev) => prev.filter((x) => x.id !== w.id))23 }24 >25 {w.id === 1 ? (26 <p className="text-sm text-muted-foreground">27 Drag by the header. Resize from any side or corner.28 </p>29 ) : (30 <div className="text-sm text-muted-foreground space-y-2">31 <p>Windows stay within the container.</p>32 <p>You can have multiple windows.</p>33 </div>34 )}35 </Window>36 ))}37 </WindowContainer>38 );39}Installation
Section titled “Installation”pnpm dlx shadcn@latest add pxl-ui/registry/window
Copy and paste the following code into your project.
1/** biome-ignore-all lint/a11y/useAriaPropsSupportedByRole: <explanation> */2"use client";3
4import * as React from "react";5
6import { cn } from "@/lib/utils";7
8const WindowContainerContext = React.createContext<{9 getRect: () => DOMRect | null;10 bumpZ: () => number;11 topZ: number;12} | null>(null);13
14export interface WindowContainerProps15 extends React.HTMLAttributes<HTMLDivElement> {}16
17export function WindowContainer({18 className,19 children,20 ...props21}: WindowContainerProps) {22 const containerRef = React.useRef<HTMLDivElement | null>(null);23 const zCounterRef = React.useRef<number>(10);24 const [topZ, setTopZ] = React.useState<number>(zCounterRef.current);25
26 const getRect = React.useCallback((): DOMRect | null => {27 const el = containerRef.current;28 if (!el) return null;29 return el.getBoundingClientRect();30 }, []);31
32 const bumpZ = React.useCallback((): number => {33 zCounterRef.current = zCounterRef.current + 1;34 setTopZ(zCounterRef.current);35 return zCounterRef.current;36 }, []);37
38 return (39 <WindowContainerContext.Provider value={{ getRect, bumpZ, topZ }}>40 <div41 ref={containerRef}42 data-window-container43 className={cn("relative w-full h-full", className)}44 {...props}45 >46 {children}47 </div>48 </WindowContainerContext.Provider>49 );50}51
52function useContainerBounds() {53 const ctx = React.useContext(WindowContainerContext);54 return ctx?.getRect ?? (() => null);55}56
57function useZOrder() {58 const ctx = React.useContext(WindowContainerContext);59 return { bumpZ: ctx?.bumpZ ?? (() => 1), topZ: ctx?.topZ ?? 1 };60}61
62export interface WindowProps extends React.HTMLAttributes<HTMLDivElement> {63 title?: string;64 initialX?: number;65 initialY?: number;66 initialWidth?: number;67 initialHeight?: number;68 minWidth?: number;69 minHeight?: number;70 onClose?: () => void;71}72
73export function Window({74 className,75 children,76 title = "Window",77 initialX = 24,78 initialY = 24,79 initialWidth = 360,80 initialHeight = 220,81 minWidth = 240,82 minHeight = 120,83 ...props84}: WindowProps) {85 const getRect = useContainerBounds();86 const { bumpZ, topZ } = useZOrder();87
88 const [position, setPosition] = React.useState({ x: initialX, y: initialY });89 const [size, setSize] = React.useState({90 width: initialWidth,91 height: initialHeight,92 });93 const [isDragging, setIsDragging] = React.useState(false);94 const [isResizing, setIsResizing] = React.useState<95 | null96 | "right"97 | "left"98 | "top"99 | "bottom"100 | "top-right"101 | "top-left"102 | "bottom-right"103 | "bottom-left"104 >(null);105 const dragOffsetRef = React.useRef({ dx: 0, dy: 0 });106 const HEADER_HEIGHT_PX = 36; // 2.25rem at 16px base107
108 const [hidden, setHidden] = React.useState(false);109 const [isMinimized, setIsMinimized] = React.useState(false);110 const [isMaximized, setIsMaximized] = React.useState(false);111 const prevForMaximizeRef = React.useRef<{112 x: number;113 y: number;114 width: number;115 height: number;116 } | null>(null);117 const prevHeightForMinimizeRef = React.useRef<number | null>(null);118 const [animateNext, setAnimateNext] = React.useState(false);119 const isInteracting = isDragging || Boolean(isResizing);120 const [zIndex, setZIndex] = React.useState<number>(() => bumpZ());121 const isFocused = zIndex === topZ;122
123 React.useEffect(() => {124 if (!animateNext) return;125 const id = window.setTimeout(() => setAnimateNext(false), 320);126 return () => window.clearTimeout(id);127 }, [animateNext]);128
129 const clampToBounds = React.useCallback(130 (x: number, y: number, width: number, height: number) => {131 const rect = getRect();132 if (!rect) return { x, y };133 const maxX = Math.max(0, rect.width - width);134 const maxY = Math.max(0, rect.height - height);135 return {136 x: Math.min(Math.max(0, x), maxX),137 y: Math.min(Math.max(0, y), maxY),138 };139 },140 [getRect],141 );142
143 const onHeaderPointerDown = (e: React.PointerEvent) => {144 (e.target as Element).setPointerCapture?.(e.pointerId);145 // Avoid starting a drag if this pointerdown is part of a double-click (handled separately)146 if ((e as any).detail >= 2) return;147 setIsDragging(true);148 setZIndex(bumpZ());149 const rect = getRect();150 const localX = rect ? e.clientX - rect.left : e.clientX;151 const localY = rect ? e.clientY - rect.top : e.clientY;152 dragOffsetRef.current = {153 dx: localX - position.x,154 dy: localY - position.y,155 };156 };157
158 const onResizePointerDown = (159 e: React.PointerEvent,160 direction:161 | "right"162 | "left"163 | "top"164 | "bottom"165 | "top-right"166 | "top-left"167 | "bottom-right"168 | "bottom-left",169 ) => {170 (e.target as Element).setPointerCapture?.(e.pointerId);171 setAnimateNext(false);172 setIsResizing(direction);173 setZIndex(bumpZ());174 };175
176 React.useEffect(() => {177 const handleMove = (e: PointerEvent) => {178 const rect = getRect();179 const localX = rect ? e.clientX - rect.left : e.clientX;180 const localY = rect ? e.clientY - rect.top : e.clientY;181 if (isDragging) {182 const nextX = localX - dragOffsetRef.current.dx;183 const nextY = localY - dragOffsetRef.current.dy;184 const clamped = clampToBounds(nextX, nextY, size.width, size.height);185 setPosition(clamped);186 } else if (isResizing) {187 if (!rect) return;188 if (189 isResizing === "right" ||190 isResizing === "bottom-right" ||191 isResizing === "top-right"192 ) {193 const maxWidth = Math.max(minWidth, rect.width - position.x);194 const desired = localX - position.x; // width based on pointer195 const newWidth = Math.max(minWidth, Math.min(maxWidth, desired));196 setSize((s) => ({ ...s, width: newWidth }));197 }198 if (199 isResizing === "left" ||200 isResizing === "bottom-left" ||201 isResizing === "top-left"202 ) {203 const minX = 0;204 const maxX = position.x + size.width - minWidth;205 const newX = Math.max(minX, Math.min(maxX, localX));206 const newWidth = Math.max(minWidth, size.width + (position.x - newX));207 setPosition((p) => ({ ...p, x: newX }));208 setSize((s) => ({ ...s, width: newWidth }));209 }210 if (211 isResizing === "bottom" ||212 isResizing === "bottom-right" ||213 isResizing === "bottom-left"214 ) {215 const maxHeight = Math.max(minHeight, rect.height - position.y);216 const desired = localY - position.y; // height based on pointer217 const newHeight = Math.max(minHeight, Math.min(maxHeight, desired));218 setSize((s) => ({ ...s, height: newHeight }));219 }220 if (221 isResizing === "top" ||222 isResizing === "top-right" ||223 isResizing === "top-left"224 ) {225 const minY = 0;226 const maxY = position.y + size.height - minHeight;227 const newY = Math.max(minY, Math.min(maxY, localY));228 const newHeight = Math.max(229 minHeight,230 size.height + (position.y - newY),231 );232 setPosition((p) => ({ ...p, y: newY }));233 setSize((s) => ({ ...s, height: newHeight }));234 }235 }236 };237
238 const handleUp = () => {239 setIsDragging(false);240 setIsResizing(null);241 // Ensure position remains within bounds after interactions242 setPosition((p) => clampToBounds(p.x, p.y, size.width, size.height));243 };244
245 window.addEventListener("pointermove", handleMove);246 window.addEventListener("pointerup", handleUp);247 return () => {248 window.removeEventListener("pointermove", handleMove);249 window.removeEventListener("pointerup", handleUp);250 };251 }, [252 isDragging,253 isResizing,254 clampToBounds,255 getRect,256 minHeight,257 minWidth,258 position.x,259 position.y,260 size.width,261 size.height,262 ]);263
264 // Keep inside bounds if container size changes265 React.useEffect(() => {266 const ro = new ResizeObserver(() => {267 setPosition((p) => clampToBounds(p.x, p.y, size.width, size.height));268 });269 // Try to observe the nearest container element in DOM tree270 const containerEl = document?.querySelector?.(271 "[data-window-container]",272 ) as HTMLElement | null;273 if (containerEl) ro.observe(containerEl);274 return () => ro.disconnect();275 }, [clampToBounds, size.width, size.height]);276
277 return hidden ? null : (278 <div279 className={cn(280 "absolute select-none pixel-border pixel-color-border pixel-size-lg p-(--pixel-size) bg-card shadow-lg shadow-shade overflow-hidden",281 animateNext && !isInteracting && "transition-all duration-300 ease-out",282 isDragging && "cursor-grabbing",283 className,284 )}285 style={{286 left: position.x,287 top: position.y,288 width: size.width,289 height: size.height,290 zIndex,291 }}292 {...props}293 onPointerDown={() => setZIndex(bumpZ())}294 >295 <div296 className={cn(297 "flex items-center justify-between px-3 py-2 border-b-4 border-b-border bg-muted text-muted-foreground cursor-grab active:cursor-grabbing",298 )}299 onPointerDown={onHeaderPointerDown}300 onDoubleClick={(e) => {301 e.stopPropagation();302 const rect = getRect();303 if (!rect) return;304 setAnimateNext(true);305 if (!isMaximized) {306 prevForMaximizeRef.current = {307 x: position.x,308 y: position.y,309 width: size.width,310 height: size.height,311 };312 setPosition({ x: 0, y: 0 });313 setSize({ width: rect.width, height: rect.height });314 setIsMaximized(true);315 setIsMinimized(false);316 } else {317 const prev = prevForMaximizeRef.current;318 if (prev) {319 setPosition({ x: prev.x, y: prev.y });320 setSize({ width: prev.width, height: prev.height });321 }322 setIsMaximized(false);323 }324 }}325 aria-label="Drag window"326 >327 <div328 className="flex items-center gap-1.5 cursor-default"329 onPointerDown={(e) => e.stopPropagation()}330 >331 <button332 type="button"333 onClick={() => {334 if (typeof props.onClose === "function") props.onClose();335 else setHidden(true);336 }}337 title="Close"338 className="size-3 pixel-rounded pixel-size-md bg-danger-foreground hover:brightness-90 active:brightness-95 transition-all"339 aria-label="Close window"340 />341 <button342 type="button"343 onClick={() => {344 setAnimateNext(true);345 if (!isMinimized) {346 prevHeightForMinimizeRef.current = size.height;347 setSize((s) => ({ ...s, height: HEADER_HEIGHT_PX }));348 setIsMinimized(true);349 } else {350 const prev = prevHeightForMinimizeRef.current ?? initialHeight;351 setSize((s) => ({ ...s, height: prev }));352 setIsMinimized(false);353 }354 }}355 title="Minimize"356 className="size-3 pixel-rounded pixel-size-md bg-warning-foreground hover:brightness-90 active:brightness-95 transition-all"357 aria-label="Minimize window"358 />359 <button360 type="button"361 onClick={() => {362 setAnimateNext(true);363 const rect = getRect();364 if (!rect) return;365 if (!isMaximized) {366 prevForMaximizeRef.current = {367 x: position.x,368 y: position.y,369 width: size.width,370 height: size.height,371 };372 setPosition({ x: 0, y: 0 });373 setSize({ width: rect.width, height: rect.height });374 setIsMaximized(true);375 setIsMinimized(false);376 } else {377 const prev = prevForMaximizeRef.current;378 if (prev) {379 setPosition({ x: prev.x, y: prev.y });380 setSize({ width: prev.width, height: prev.height });381 }382 setIsMaximized(false);383 }384 }}385 title="Maximize"386 className="size-3 pixel-rounded pixel-size-md bg-success-foreground hover:brightness-90 active:brightness-95 transition-all"387 aria-label="Maximize window"388 />389 </div>390 <span391 className={cn(392 "text-xs truncate tracking-wide px-2 flex-1 min-w-0 text-center mr-12",393 isFocused ? "font-bold" : "font-medium",394 )}395 >396 {title}397 </span>398 </div>399 <div className="w-full h-[calc(100%-2.25rem)] p-3 overflow-auto">400 {children}401 </div>402 {/* Resize handles */}403 <div404 className="absolute right-0 top-0 h-full w-1 cursor-ew-resize"405 onPointerDown={(e) => onResizePointerDown(e, "right")}406 aria-label="Resize right"407 />408 <div409 className="absolute left-0 top-0 h-full w-1 cursor-ew-resize"410 onPointerDown={(e) => onResizePointerDown(e, "left")}411 aria-label="Resize left"412 />413 <div414 className="absolute left-0 bottom-0 w-full h-1 cursor-ns-resize"415 onPointerDown={(e) => onResizePointerDown(e, "bottom")}416 aria-label="Resize bottom"417 />418 <div419 className="absolute left-0 top-0 w-full h-1 cursor-ns-resize"420 onPointerDown={(e) => onResizePointerDown(e, "top")}421 aria-label="Resize top"422 />423 <div424 className="absolute right-0 bottom-0 size-3 cursor-nwse-resize"425 onPointerDown={(e) => onResizePointerDown(e, "bottom-right")}426 aria-label="Resize corner"427 />428 <div429 className="absolute left-0 bottom-0 size-3 cursor-nesw-resize"430 onPointerDown={(e) => onResizePointerDown(e, "bottom-left")}431 aria-label="Resize corner"432 />433 <div434 className="absolute right-0 top-0 size-3 cursor-nesw-resize"435 onPointerDown={(e) => onResizePointerDown(e, "top-right")}436 aria-label="Resize corner"437 />438 <div439 className="absolute left-0 top-0 size-3 cursor-nwse-resize"440 onPointerDown={(e) => onResizePointerDown(e, "top-left")}441 aria-label="Resize corner"442 />443 </div>444 );445}446
447export default Window;Update the import paths to match your project setup.
import { Window } from "@/components/ui/pxl/window"<WindowContainer className="w-full h-full border border-border border-dashed rounded-2xl flex-1 min-h-0"> <Window title={w.title} initialX={w.x} initialY={w.y} initialWidth={w.w} initialHeight={w.h} onClose={() => setWindows((prev) => prev.filter((x) => x.id !== w.id)) } > Content </Window></WindowContainer>