Dock, detach, return
A floating shape inspector previews edge docking and returns to its previous position without losing its properties.

Drag the title to an allowed edge, use the dock buttons, or move with arrow keys. Detach restores the prior floating position.
Subframe Original · MIT
Add to your project
Component source
Standalone React component. Styles are included.
React only. No additional packages.
Render the default export inside a container with a defined height. Its optional active prop stops movement when false. This example works with React 19; Next.js projects can use it as a client component.
View source code
/**
* MIT License
* Copyright (c) 2026 Subframe
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
"use client"
import * as React from "react"
export type Config = {
dockThreshold: number
panelWidth: number
allowedEdge: "left" | "right" | "both"
settleMs: number
}
export const DEFAULT_CONFIG: Config = { dockThreshold: 32, panelWidth: 180, allowedEdge: "right", settleMs: 180 }
type Point = { x: number; y: number }
type Mode = "floating" | "left" | "right"
const bound = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, Number.isFinite(value) ? value : min))
const DockIcon = ({ edge, detached = false }: { edge: "left" | "right"; detached?: boolean }) => (
<svg viewBox="0 0 18 18" aria-hidden>
<rect x="2.5" y="3" width="13" height="12" rx="1.8" />
{detached ? (
<path d="M6 6h6v6H6Z" />
) : (
<path d={edge === "right" ? "M11 3v12m0-10h2.5v8H11" : "M7 3v12M4.5 5H7v8H4.5"} />
)}
</svg>
)
export default function Example({ active = true, config }: { active?: boolean; config?: Partial<Config> }) {
const settings = { ...DEFAULT_CONFIG, ...config }
settings.dockThreshold = bound(settings.dockThreshold, 16, 64)
settings.panelWidth = bound(settings.panelWidth, 140, 240)
settings.settleMs = bound(settings.settleMs, 0, 260)
if (!["left", "right", "both"].includes(settings.allowedEdge)) {
settings.allowedEdge = "right"
}
const uid = React.useId()
const workspace = React.useRef<HTMLDivElement>(null)
const dockAction = React.useRef<HTMLButtonElement>(null)
const restoreActionFocus = React.useRef(false)
const [size, setSize] = React.useState({ width: 540, height: 315 })
const [mode, setMode] = React.useState<Mode>("floating")
const [position, setPosition] = React.useState<Point>({ x: 310, y: 42 })
const [target, setTarget] = React.useState<"left" | "right" | null>(null)
const [dragging, setDragging] = React.useState(false)
const [keyboard, setKeyboard] = React.useState(false)
const [radius, setRadius] = React.useState(32)
const [fill, setFill] = React.useState("#f2cf4b")
const [stroke, setStroke] = React.useState(false)
const lastFree = React.useRef<Point>({ x: 310, y: 42 })
const drag = React.useRef<{
pointer: Point
position: Point
mode: Mode
owner: HTMLButtonElement
pointerId: number
moved: boolean
} | null>(null)
const panelWidth = Math.min(settings.panelWidth, size.width - 12)
const panelHeight = Math.min(238, size.height)
const clamp = (point: Point) => ({
x: bound(point.x, 0, Math.max(0, size.width - panelWidth)),
y: bound(point.y, 0, Math.max(0, size.height - panelHeight)),
})
const rendered = mode === "floating" ? position : { x: mode === "left" ? 0 : size.width - panelWidth, y: 0 }
const allowed = (edge: "left" | "right") => settings.allowedEdge === "both" || settings.allowedEdge === edge
const cancel = React.useCallback(() => {
const previous = drag.current
drag.current = null
if (previous) {
if (previous.owner.hasPointerCapture(previous.pointerId)) {
previous.owner.releasePointerCapture(previous.pointerId)
}
setMode(previous.mode)
setPosition(previous.position)
}
setDragging(false)
setTarget(null)
}, [])
React.useEffect(() => {
const node = workspace.current
if (!node) {
return
}
const observer = new ResizeObserver(() => {
const rect = node.getBoundingClientRect()
setSize({ width: node.clientWidth, height: node.clientHeight })
if (rect.width === 0) {
cancel()
}
})
observer.observe(node)
return () => observer.disconnect()
}, [cancel])
React.useLayoutEffect(() => {
if (restoreActionFocus.current) {
restoreActionFocus.current = false
dockAction.current?.focus({ preventScroll: true })
}
}, [mode])
React.useEffect(() => {
cancel()
const clampPoint = (point: Point) => ({
x: bound(point.x, 0, Math.max(0, size.width - panelWidth)),
y: bound(point.y, 0, Math.max(0, size.height - panelHeight)),
})
setPosition((previous) => clampPoint(previous))
lastFree.current = clampPoint(lastFree.current)
}, [size.width, size.height, panelWidth, panelHeight, cancel])
React.useEffect(() => {
if (mode !== "floating" && settings.allowedEdge !== "both" && mode !== settings.allowedEdge) {
setMode("floating")
setPosition(lastFree.current)
}
}, [settings.allowedEdge, mode])
React.useEffect(() => {
cancel()
}, [settings.allowedEdge, cancel])
React.useEffect(() => {
if (!active) {
cancel()
}
}, [active, cancel])
React.useEffect(() => {
const hide = () => {
if (document.hidden) {
cancel()
}
}
document.addEventListener("visibilitychange", hide)
return () => document.removeEventListener("visibilitychange", hide)
}, [cancel])
const dock = (edge: "left" | "right", focusAction = false) => {
if (!active || !allowed(edge)) {
return
}
if (mode === "floating") {
lastFree.current = position
}
restoreActionFocus.current = focusAction
setMode(edge)
setTarget(null)
}
const detach = () => {
if (!active) {
return
}
restoreActionFocus.current = true
setMode("floating")
setPosition(clamp(lastFree.current))
}
const begin = (event: React.PointerEvent<HTMLButtonElement>) => {
if (!active || event.button !== 0) {
return
}
event.currentTarget.setPointerCapture(event.pointerId)
drag.current = {
pointer: { x: event.clientX, y: event.clientY },
position: rendered,
mode,
owner: event.currentTarget,
pointerId: event.pointerId,
moved: false,
}
if (mode === "floating") {
lastFree.current = position
}
setPosition(rendered)
setMode("floating")
setDragging(true)
}
const move = (event: React.PointerEvent<HTMLButtonElement>) => {
const current = drag.current
if (!current) {
return
}
const rect = workspace.current!.getBoundingClientRect()
const dx = ((event.clientX - current.pointer.x) * size.width) / rect.width
const dy = ((event.clientY - current.pointer.y) * size.height) / rect.height
if (Math.hypot(dx, dy) > 3) {
current.moved = true
}
const next = clamp({ x: current.position.x + dx, y: current.position.y + dy })
setPosition(next)
setTarget(
allowed("left") && next.x <= settings.dockThreshold
? "left"
: allowed("right") && next.x + panelWidth >= size.width - settings.dockThreshold
? "right"
: null,
)
}
const drop = () => {
const current = drag.current
if (!current) {
return
}
drag.current = null
if (current.owner.hasPointerCapture(current.pointerId)) {
current.owner.releasePointerCapture(current.pointerId)
}
if (!current.moved) {
setMode(current.mode)
setPosition(current.position)
} else if (target) {
setMode(target)
} else {
lastFree.current = position
}
setTarget(null)
setDragging(false)
}
return (
<section
className="sf-panel-docking"
aria-label="Dockable shape inspector"
data-still={!active || keyboard || dragging}
onPointerDownCapture={() => setKeyboard(false)}
onKeyDownCapture={(event) => {
setKeyboard(true)
if (event.key === "Escape") {
cancel()
}
}}
style={{ "--pd-duration": `${settings.settleMs}ms` } as React.CSSProperties}
>
<div className="pd-workspace" ref={workspace}>
<div
className="pd-canvas"
style={{ marginLeft: mode === "left" ? panelWidth : 0, marginRight: mode === "right" ? panelWidth : 0 }}
>
<svg
viewBox="0 0 320 280"
aria-label={`Rectangle, ${radius} pixel corners${stroke ? ", outlined" : ""}`}
role="img"
>
<g transform="translate(31 49)">
<rect
x="0"
y="0"
width="164"
height="164"
rx={radius}
fill={fill}
stroke={stroke ? "#332c2b" : "none"}
strokeWidth="3"
/>
<path d="M96 30 51 91h37l-17 47 56-67H91Z" fill="#282b28" />
<rect x="-5" y="-5" width="174" height="174" fill="none" stroke="#7e9dcc" strokeWidth=".9" />
{[
[-5, -5],
[169, -5],
[-5, 169],
[169, 169],
].map(([x, y], i) => (
<rect
key={i}
x={x - 2.5}
y={y - 2.5}
width="5"
height="5"
fill="#fff"
stroke="#647eae"
strokeWidth=".7"
/>
))}
</g>
</svg>
</div>
{target ? (
<div className="pd-target" style={{ width: panelWidth, [target]: 0 }}>
<span>Dock {target}</span>
</div>
) : null}
<div
className="pd-panel"
data-docked={mode !== "floating"}
style={{
width: panelWidth,
height: mode === "floating" ? panelHeight : size.height,
transform: `translate(${rendered.x}px,${rendered.y}px)`,
}}
>
<header>
<button
type="button"
className="pd-grip"
aria-label="Move inspector. Arrow keys move; Home docks left; End docks right."
aria-describedby={`${uid}-help`}
disabled={!active}
onPointerDown={begin}
onPointerMove={move}
onPointerUp={drop}
onPointerCancel={cancel}
onLostPointerCapture={() => {
if (drag.current) {
cancel()
}
}}
onKeyDown={(event) => {
if (!active) {
return
}
const delta = event.shiftKey ? 30 : 10
const next =
event.key === "ArrowLeft"
? { x: rendered.x - delta, y: rendered.y }
: event.key === "ArrowRight"
? { x: rendered.x + delta, y: rendered.y }
: event.key === "ArrowUp"
? { x: rendered.x, y: rendered.y - delta }
: event.key === "ArrowDown"
? { x: rendered.x, y: rendered.y + delta }
: null
if (next) {
event.preventDefault()
const clamped = clamp(next)
setMode("floating")
setPosition(clamped)
lastFree.current = clamped
}
if (event.key === "Home") {
event.preventDefault()
dock("left")
}
if (event.key === "End") {
event.preventDefault()
dock("right")
}
}}
>
<svg viewBox="0 0 8 14" aria-hidden>
<path d="M2 3h.1M6 3h.1M2 7h.1M6 7h.1M2 11h.1M6 11h.1" />
</svg>
<span>Shape</span>
</button>
{mode !== "floating" ? (
<button
type="button"
className="pd-dock"
ref={dockAction}
aria-label="Detach inspector"
title="Detach inspector"
disabled={!active}
onClick={detach}
>
<DockIcon edge="right" detached />
</button>
) : (
<>
{allowed("left") ? (
<button
type="button"
className="pd-dock"
ref={dockAction}
aria-label="Dock inspector left"
title="Dock left"
disabled={!active}
onClick={() => dock("left", true)}
>
<DockIcon edge="left" />
</button>
) : null}
{allowed("right") ? (
<button
type="button"
className="pd-dock"
ref={dockAction}
aria-label="Dock inspector right"
title="Dock right"
disabled={!active}
onClick={() => dock("right", true)}
>
<DockIcon edge="right" />
</button>
) : null}
</>
)}
</header>
<div className="pd-properties">
<div className="pd-field">
<label htmlFor={`${uid}-radius`}>Corners</label>
<output htmlFor={`${uid}-radius`}>
{radius}
<span>px</span>
</output>
<input
id={`${uid}-radius`}
type="range"
min="0"
max="64"
step="1"
value={radius}
disabled={!active}
onChange={(event) => setRadius(Number(event.target.value))}
/>
</div>
<fieldset>
<legend>Fill</legend>
<div className="pd-swatches">
{[
{ value: "#f2cf4b", label: "Yellow" },
{ value: "#e9694b", label: "Vermilion" },
].map((color) => (
<label key={color.value} title={color.label}>
<input
type="radio"
name={`${uid}-fill`}
aria-label={color.label}
value={color.value}
checked={fill === color.value}
disabled={!active}
onChange={() => setFill(color.value)}
/>
<span style={{ background: color.value }}>
{fill === color.value ? (
<svg viewBox="0 0 16 16" aria-hidden>
<path d="m4 8 2.5 2.5L12 5" />
</svg>
) : null}
</span>
</label>
))}
</div>
</fieldset>
<label className="pd-stroke">
<span>Outline</span>
<input
type="checkbox"
checked={stroke}
disabled={!active}
onChange={(event) => setStroke(event.target.checked)}
/>
</label>
</div>
</div>
<span id={`${uid}-help`} className="pd-sr">
Drag the title to an allowed edge to dock. Detach restores the previous floating position. Properties stay
unchanged.
</span>
<span className="pd-sr" role="status">
{target
? `Release to dock ${target}`
: mode === "floating"
? "Inspector floating"
: `Inspector docked ${mode}`}
</span>
</div>
<style>{`
.sf-panel-docking{width:100%;height:100%;min-height:260px;box-sizing:border-box;display:grid;place-items:center;padding:24px;background:#e7e9e5;color:#332c2b;font:12px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;overflow:hidden;isolation:isolate}.sf-panel-docking *{box-sizing:border-box}.sf-panel-docking button,.sf-panel-docking input{font:inherit;color:inherit}.sf-panel-docking button{cursor:pointer}.sf-panel-docking button:disabled{cursor:default}.sf-panel-docking .pd-workspace{position:relative;width:100%;max-width:560px;height:100%;max-height:315px;min-height:220px;background:#f4f4f0;border:1px solid #d5d8d0;border-radius:10px;overflow:hidden;background-image:radial-gradient(#90978b45 .6px,transparent .7px);background-size:12px 12px}.sf-panel-docking .pd-canvas{height:100%;display:grid;place-items:center;min-width:0}.sf-panel-docking .pd-canvas>svg{width:100%;height:100%;max-width:430px;max-height:300px}.sf-panel-docking .pd-panel{position:absolute;top:0;left:0;z-index:2;background:#ffffff;border:1px solid #323b2720;border-radius:9px;box-shadow:0 2px 5px #323b2712,0 12px 24px #323b2720;overflow:hidden;transition:transform var(--pd-duration) cubic-bezier(.22,1,.36,1)}.sf-panel-docking .pd-panel[data-docked=true]{border-radius:0;border-top:0;border-bottom:0;box-shadow:none}.sf-panel-docking header{height:44px;display:flex;align-items:center;border-bottom:1px solid #e7e9e3}.sf-panel-docking header button{height:44px;border:0;background:transparent;touch-action:none;display:flex;align-items:center;justify-content:center;padding:0}.sf-panel-docking .pd-grip{flex:1;min-width:0;gap:7px;justify-content:flex-start;padding:0 12px;cursor:grab;text-align:left}.sf-panel-docking .pd-grip:active{cursor:grabbing}.sf-panel-docking .pd-grip span{font-size:11px;font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sf-panel-docking .pd-grip svg{width:8px;height:14px;stroke:#b6a69e;stroke-linecap:round;stroke-width:1.7;flex:none}.sf-panel-docking .pd-dock{width:44px;flex:none;color:#8c7a70}.sf-panel-docking .pd-dock svg{width:17px;height:17px;fill:none;stroke:currentColor;stroke-width:1.1}.sf-panel-docking .pd-properties{padding:14px}.sf-panel-docking .pd-field{display:grid;grid-template-columns:1fr auto;align-items:center;gap:7px;font-size:10px}.sf-panel-docking output{font-variant-numeric:tabular-nums}.sf-panel-docking output span{margin-left:3px;color:#a09288;font-size:9px}.sf-panel-docking input[type=range]{grid-column:1/-1;width:100%;height:25px;margin:0;accent-color:#4c423c;cursor:pointer}.sf-panel-docking fieldset{border:0;padding:0;margin:10px 0 0}.sf-panel-docking legend{font-size:10px;padding:0;color:#71635a}.sf-panel-docking .pd-swatches{display:flex;gap:2px;margin-left:-5px}.sf-panel-docking .pd-swatches label{width:44px;height:44px;position:relative;display:grid;place-items:center;cursor:pointer}.sf-panel-docking .pd-swatches input{position:absolute;inset:0;margin:0;width:100%;height:100%;opacity:0;cursor:pointer}.sf-panel-docking .pd-swatches span{display:grid;place-items:center;width:26px;height:26px;border-radius:6px;box-shadow:inset 0 0 0 1px #51413515;pointer-events:none}.sf-panel-docking .pd-swatches svg{width:14px;height:14px;fill:none;stroke:#5f5144;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}.sf-panel-docking .pd-swatches input:focus-visible+span{outline:2px solid #4267b2;outline-offset:3px}.sf-panel-docking .pd-stroke{display:flex;align-items:center;justify-content:space-between;min-height:44px;font-size:10px;cursor:pointer;border-top:1px solid #e7e9e3}.sf-panel-docking .pd-stroke input{accent-color:#4c423c;width:13px;height:13px;margin:0}.sf-panel-docking .pd-target{position:absolute;top:0;bottom:0;z-index:1;background:#a7e8df6b;border:2px solid #c9fff6;display:grid;place-items:end center;padding:14px;pointer-events:none}.sf-panel-docking .pd-target span{font-size:10px;font-weight:600;padding:5px 8px;border-radius:4px;background:#dcfff4;color:#245c4c}.sf-panel-docking .pd-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}.sf-panel-docking button:focus-visible,.sf-panel-docking input:focus-visible{outline:2px solid #4267b2;outline-offset:-3px}.sf-panel-docking[data-still=true] *{transition:none!important}@media(prefers-reduced-motion:reduce){.sf-panel-docking *{transition:none!important}}@media(max-width:430px){.sf-panel-docking{padding:14px}.sf-panel-docking .pd-properties{padding:12px}}
`}</style>
</section>
)
}
Where it works well
Reclaim canvas space while keeping inspector state and keyboard focus intact.
Reduced motion
Panel transforms settle immediately for keyboard input, reduced motion and inactive hosts. Host pause and pointer cancellation restore the pre-drag state.
Things to know
One inspector and one original lightning icon. Docking is local component state, not a general window manager. The inspector changes corner radius, fill and outline; editor configuration controls docking behavior separately.