@nobadeer/floating-widgets
v0.2.2
Published
Draggable, snappable corner-docked panel stack ("floating widgets") for React 19, with a responsive docked mode for narrow viewports.
Readme
@nobadeer/floating-widgets
Last updated: 2026-08-18
A draggable, snappable stack of corner-docked panels — "floating widgets" — for React 19, which presents as a single docked surface on narrow viewports. Extracted from Pitchcraft's sidebar HUD (Usage / Diagnostics / Telemetry / Demo).
Each widget is either snapped (placed by a shared coordinator that stacks
widgets below your header and reflows them as they collapse/expand) or
floating (the user grabbed it and dragged it somewhere; its position
persists to localStorage). Drag a snapped widget out of its slot and it takes
ownership; drop it back near the dock and it re-joins the stack.
Floating assumes there is room beside your content. Below a breakpoint you choose there usually isn't, so the stack switches to a dock: one collapsible surface pinned to an edge, holding every widget as an accordion. See Responsive dock.
The package ships the mechanism — chrome, coordinator, persistence — and is agnostic about what goes in the panels. You supply the panel contents.
Runtime contract
Two things this package cannot enforce through package.json or types. Get
them wrong and it fails quietly (unstyled panels, or widgets that don't track
the header), so they're stated up front.
1. Peer dependencies — React 19+
react >= 19, react-dom >= 19, lucide-react >= 0.400React 19 is a hard floor, not a preference. The code relies on two React-19-only features:
refas an ordinary prop (FloatingWidgetStacktakesrefdirectly, noforwardRef). On React 18 the ref never arrives.<Context value>as the provider element form. On React 18 this silently fails to provide context, anduseLayout()throws.
lucide-react supplies the grip and chevron icons and is a peer so you share
one copy with the host app.
2. Styling — shadcn tokens by default, overridable everywhere
This package imports no CSS and ships no styles; it borrows the host's. By default it borrows shadcn's tokens:
bg-card text-card-foreground border shadow-lg
text-muted-foreground text-foregroundIf your app is already shadcn + Tailwind, they resolve and you need do nothing. If they don't resolve, the panels render structurally correct but unstyled — a silent failure, which is why this is stated as a contract rather than a detail.
You are not stuck with them. See Styling: every element carries a
data-fw-part attribute you can target from your own stylesheet with no prop at
all, classNames appends per part, and unstyled drops the defaults entirely
for an app with no shadcn tokens.
3. Placement: one <LayoutProvider> around header + stack
The coordinator needs to know where your header ends (the "stack origin") and
who the snapped widgets are. So three things must live inside one
<LayoutProvider>:
<StackOriginReporter headerRef={...} />— measures the header bottom,- the header element referenced by
headerRef, <FloatingWidgetStack ... />.
The stack deliberately does not render its own provider: the provider has to also enclose the header, and that boundary belongs to you, not to the stack.
Usage
import { useMemo, useRef } from "react";
import {
LayoutProvider,
StackOriginReporter,
FloatingWidgetStack,
type FloatingWidgetStackHandle,
type WidgetDef,
} from "@nobadeer/floating-widgets";
// 1. Your app's "context" — the bundle of already-computed live state the
// widgets read. A plain object (view-model), NOT a place to call hooks.
type Ctx = {
usage: UsageLog;
error: string | null;
demoMode: boolean;
};
// 2. The registry. Stable identity (useMemo([])) — the functions read
// everything from `ctx` at call time, so the array never needs to change.
// Array ORDER is the stack order (top to bottom).
const WIDGETS: WidgetDef<Ctx>[] = [
{
id: "usage",
header: (c) => <UsageSummaryLine usage={c.usage} />,
render: (c) => <UsageTable usage={c.usage} />,
},
{
id: "diagnostics",
defaultCollapsed: true,
header: () => <span className="text-[11px] font-medium">Diagnostics</span>,
render: (c) => <DiagnosticsPanel error={c.error} />,
},
{
id: "demo",
mountWhen: (c) => c.demoMode, // optional widgets gate on live state
header: () => <span>Demo</span>,
render: () => <DemoBody />,
},
];
function AppShell() {
const headerRef = useRef<HTMLElement>(null);
const stackRef = useRef<FloatingWidgetStackHandle>(null);
// Rebuilt each render; threaded into every def function by the stack.
const ctx: Ctx = { usage, error, demoMode };
// Stable — the registry never closes over live state directly.
const widgets = useMemo(() => WIDGETS, []);
return (
{/* dockBelow: below this width the stack presents as one docked surface.
Omit it for always-floating (the 0.1.0 behavior). */}
<LayoutProvider dockBelow={1024}>
<StackOriginReporter headerRef={headerRef} />
<header ref={headerRef}>{/* your app header / banners */}</header>
{/* ...page... */}
<FloatingWidgetStack<Ctx>
ref={stackRef}
ctx={ctx}
widgets={widgets}
/>
{/* Imperatively re-dock everything (e.g. a "reset layout" button): */}
<button onClick={() => stackRef.current?.snapAll()}>Snap all</button>
</LayoutProvider>
);
}Why ctx is a plain object and not per-widget hooks
The stack maps the registry and calls header(ctx) / render(ctx) — never a
hook — from inside that map. So your app calls all of its hooks normally, at
the top of your component, and packs the results into ctx. This keeps the
Rules of Hooks trivially satisfied even as widgets mount and unmount via
mountWhen. (An alternative design gives each WidgetDef its own useModel()
hook; it's more decoupled but calls hooks in a loop, which is fragile. Not used
here.)
Responsive dock
Pass dockBelow and the stack switches presentation on its own:
<LayoutProvider dockBelow={1024}>Below that width every widget leaves the page and joins one docked surface — an
accordion pinned to the bottom edge (dockEdge="right" for a side drawer),
closed by default. Above it, nothing changes: the same drag, snap and reflow as
before. dockBelow defaults to false, so a consumer that doesn't opt in
gets the pre-0.2.0 behavior exactly.
Pick the same breakpoint your layout uses to reserve a gutter beside the content, since that gutter is what makes floating viable in the first place.
Driving it from your own chrome
useDock() is the reactive read. An app that wants its own toggle owns no
state of its own — which is the point, since two owners of "is the HUD
visible" is how these get out of sync:
import { useDock } from "@nobadeer/floating-widgets";
function HudToggle() { // anywhere inside <LayoutProvider>
const { isDock, open, toggle } = useDock();
if (!isDock) return null; // desktop: the widgets speak for themselves
return (
<button onClick={toggle} aria-pressed={open}>
{open ? <EyeOff /> : <Eye />}
</button>
);
}Pass renderDockTrigger={() => null} to the stack so it doesn't also render its
own affordance. If you'd rather hold the state yourself, dockOpen +
onDockOpenChange on the provider make it controlled.
The imperative handle also has openDock() / closeDock() / toggleDock() /
isDockOpen(). Use those to act, not to reflect: a ref read can't re-render
the component holding it, so an icon derived from isDockOpen() goes stale.
Crossing the breakpoint
Presentation is global chrome state, not per-widget state. While docked, widgets
don't register with the coordinator and never write a position — their stored
mode and position are untouched. So a widget you dragged to the middle of a
1440px desktop is still exactly there after a phone visit, and crossing back and
forth costs nothing. Collapse state is shared, deliberately: it belongs to the
widget, not to the layout.
Switching presentation remounts each widget. Everything persistent is in
localStorage and re-read on mount, so this is invisible.
Accessibility
The dock is a persistent HUD, not a modal: role="region" with an accessible
name (dockLabel), focus moved into the panel on open and returned to whatever
opened it on close, and Escape to close. Focus is not trapped — trapping it
in an always-present HUD would strand keyboard users on the rest of the page.
Pass dockModal if you genuinely want modal behavior; that adds a scrim,
aria-modal, a Tab cycle and close-on-scrim-click.
The closed dock is pointer-transparent, so it never swallows clicks meant for the page behind it.
Staying out of the way — avoidRects
Widgets float over the page, so other UI can land underneath them: a side sheet, a toast stack, the iOS software keyboard. Hand the stack the regions to keep clear and each widget slides by only as much as it needs to escape them.
const [sheetRects, sheetRef] = useAvoidElement();
<SheetContent ref={sheetRef}>…</SheetContent>
<FloatingWidgetStack avoidRects={sheetRects} … />useAvoidElement hands you a callback ref, which is what makes it correct
for content you don't mount yourself — Radix / shadcn Sheet, Dialog,
Popover, DropdownMenu, anything Presence-wrapped or mounted from its own
effect. React invokes a callback ref when it attaches the element, however
many commits later that happens, so there is no window where the element exists
and the hook hasn't seen it.
It re-measures on attach, the next animation frame, ResizeObserver,
animationend, transitionend, and window resize. The animation events earn
their place: a sheet that slides in via transform changes its box without
changing its size, so ResizeObserver alone would leave the rect at the
off-screen position the animation started from.
It returns an array (empty or one rect) so several regions compose by spreading, with nothing to memoize:
const [sheetRects, sheetRef] = useAvoidElement();
const [toastRects, toastRef] = useAvoidElement();
<FloatingWidgetStack avoidRects={[...sheetRects, ...toastRects]} … />
useAvoidRects([ref])is deprecated and only works for DOM you render inline. ARefObjectcannot notify —ref.currentis mutated invisibly to React — so its only re-measure trigger is a commit of the calling component. A Radix portal mounts via state inside a descendant, which never re-renders the host, so the hook measures once againstnulland nothing tells it to look again. Use it only for a panel you mount yourself, or to measure several host-owned elements through one hook.
Not tracked by either hook: an avoided element that moves with scroll (in
normal flow rather than fixed/absolute). Watching that costs a
getBoundingClientRect per scroll frame, which isn't worth the layout cost for
a HUD — build the rect yourself and pass it to avoidRects directly.
AvoidRect is { left, top, right, bottom } in viewport coordinates, which
DOMRect satisfies structurally, so you can also pass
element.getBoundingClientRect() results or synthesize a rect yourself. For the
iOS keyboard, visualViewport gives you one:
// The strip the keyboard covers: below the visual viewport, above the layout one.
const vv = window.visualViewport!;
const keyboard = {
left: 0, right: innerWidth,
top: vv.height + vv.offsetTop, bottom: innerHeight,
};How it decides which way to move
The smallest displacement that clears each rect, preferring a direction that keeps the widget on-screen. Direction falls out of the geometry rather than being configured — a full-height side sheet can only be escaped sideways, a full-width keyboard only upward. Snapped widgets move on x only, because the coordinator owns their y: shifting one vertically would slide it into siblings that don't know to reflow.
The shift is a CSS transform, so stored positions are untouched and removing the rect springs each widget back to exactly where it was. If the user drags a widget while it's shifted, the offset is baked into its position and it stays put — they meant to move it. Ignored entirely while docked.
Replaces 0.1.0's
settingsOpen/settingsPanelWidth, which said the same thing in one consumer's vocabulary with the rect hardcoded as a width measured from the right edge — a shape that couldn't express a keyboard.
Styling
Three layers, so you can go as far as you need and no further.
1. data-fw-part — every element carries one, and you can style any of them
from your own stylesheet with no prop at all. Also the escape hatch: if a part
isn't reachable through classNames, it's still reachable here.
[data-fw-part="grip"] { cursor: move; }
[data-fw-part="dock"][data-state="open"] [data-fw-part="dockPanel"] { … }| Part | Element |
| --- | --- |
| root | Positioned wrapper (fixed + coordinator-placed when floating). |
| card | The bordered panel surface. |
| header | Header row: grip + title + toggle. |
| grip | Drag affordance. Float only. |
| title | Holds your header(ctx) output. |
| toggle | Chevron button — the accessible expand/collapse control. |
| body | Holds your render(ctx). Absent while collapsed. |
| dock | Fixed container pinned to the dock edge. |
| dockPanel | The open dock surface. |
| dockList | Accordion list inside the panel. |
| dockTrigger | Closed-state affordance. |
| dockBackdrop | Scrim; only when dockModal. |
State comes as data attributes too: data-state (open/closed,
expanded/collapsed), data-presentation, data-mode, data-edge,
data-fw-id.
2. classNames — a bag keyed by the same part names, on the stack (all
widgets) or on a WidgetDef (one widget). Your classes are appended to the
defaults, not merged token-by-token: this package has no tailwind-merge
dependency, so a real merge isn't on offer and pretending otherwise would be the
unpredictable option. Append adds; unstyled subtracts.
<FloatingWidgetStack classNames={{ dockPanel: "rounded-t-2xl", body: "p-3" }} … />3. unstyled — drop every default class. Structure, data attributes and
geometry remain. This is the path for an app with no shadcn tokens.
What the library keeps
Geometry, applied inline and not overridable: position, edge offsets, drag transform, transition, the float↔dock reflow. Two values that are really style decisions are emitted as CSS custom properties instead, so you can change them without a prop:
| Property | Default | What |
| --- | --- | --- |
| --fw-widget-width | 17rem | Floating panel width. Drag, clamp and snap math measure the element, so an override stays consistent. |
| --fw-dock-z | 50 | Dock stacking order. |
| --fw-dock-max-h | 60dvh | Dock accordion height before it scrolls. |
Also inline, and deliberately not reachable by unstyled, because they are
correctness rather than taste: touch-action: none / user-select: none on the
grip (without them a touch drag scrolls the page instead of moving the widget),
env(safe-area-inset-*) padding on the dock (an iPhone bottom dock otherwise
renders under the home indicator), and the dock's scroll containment.
API
Composition surface (the 90% path)
| Export | What |
| --- | --- |
| LayoutProvider | Context provider; wrap header + reporter + stack. Owns the responsive presentation and dock open state. |
| StackOriginReporter | Measures headerRef bottom → stack origin. Renders nothing. |
| FloatingWidgetStack<Ctx> | Renders the registry in either presentation; exposes the handle via ref. |
| useDock() | Reactive { presentation, isDock, dockEdge, open, setOpen, toggle }. How your own chrome reflects and drives the dock. |
| useAvoidElement() | [rects, callbackRef]. Tracks one element, correct for portaled/animated content. |
| useAvoidRects(refs) | Deprecated. RefObject-based; only sees DOM mounted in the same commit as the caller. |
| WidgetDef<Ctx> (type) | One widget: id, header, render, optional mountWhen / dockWhen / peek / defaultCollapsed / classNames. |
| FloatingWidgetStackHandle (type) | { snapAll(); resetAll(); openDock(); closeDock(); toggleDock(); isDockOpen(); }. |
LayoutProvider props: dockBelow? (default false), dockEdge? (default
"bottom"), defaultDockOpen? (default false), dockOpen?,
onDockOpenChange?.
FloatingWidgetStack props: widgets, ctx, avoidRects?,
storagePrefix?, classNames?, unstyled?, dockLabel?,
renderDockTrigger?, dockModal?, ref?. Pass storagePrefix if two apps
share an origin, so their widget:<id> localStorage keys don't collide.
Primitives (escape hatch — custom layouts)
FloatingWidget, FloatingWidgetHandle, useLayout, LayoutContextValue,
useBelowWidth, useWidgetLayout, clampPosition, WIDGET_W,
WIDGET_MARGIN, and the WidgetState / WidgetMode / WidgetPosition /
WidgetPart / PartClassNames / Presentation / DockEdge types. Use these
only if you're building a layout other than the stack.
Upgrading from 0.1.0
Narrower than the version bump suggests. No export was removed — 0.2.0 is
purely additive at the module level — and the localStorage format is
unchanged (same widget:<id> key, same { position, collapsed, mode }
shape), so saved layouts survive untouched. dockBelow defaults to false and
the default class strings are the 0.1.0 ones, so an upgrade that changes nothing
else looks and behaves as before.
The one breaking change
settingsOpen / settingsPanelWidth → avoidRects, on both
FloatingWidgetStack and FloatingWidget. A compile error, so TypeScript finds
every call site for you. Instead of a boolean plus a hardcoded panel width, pass
the region to avoid — useAvoidElement() measures it from the live element, so
the avoided region can't drift away from the real panel. See
above for both migration forms.
0.2.1 → 0.2.2
Bug fix only. useAvoidElement crashed with "Maximum update depth exceeded"
when its callback ref was attached to a Radix-composed component (shadcn
Sheet, Dialog, Popover) — React unmounted the whole subtree. If you tried
useAvoidElement on 0.2.1 and backed it out, 0.2.2 is the one to retry.
0.2.0 → 0.2.1
useAvoidElement was added and useAvoidRects deprecated. If you shipped
against 0.2.0 with useAvoidRects and a portaled sheet, the hook was inert —
it returned [] and your widgets never moved. Switch to useAvoidElement; the
change is one line at the call site and one at the element.
Silent changes — the ones actually worth reading
None of these will fail to compile, so they're the ones that cost debugging time if you hit them without knowing.
- The grip's
touch-noneclass is gone, replaced by inlinetouch-action: none(plususer-select: none). They are drag correctness rather than skin, and had to move somewhereunstyledcan't delete them. If any of your CSS or tests select the grip by that class name, they now miss. - Bring-to-front moved from
onMouseDowntoonPointerDown, so a touch can raise a widget without dragging it. A test that firesmouseDownto assert z-order will silently stop exercising anything — switch it topointerDown. - A floating widget may now escape a rect vertically. 0.1.0's Settings shift
was x-only for every widget;
avoidRectsis x-only for snapped widgets (the coordinator owns their y) but both axes for floating ones. For a full-height sheet the result is identical, since a vertical escape would have to leave the viewport. If your sheet is not full height, a floating widget that used to slide sideways may now slide up. - The chevron gained
aria-expanded/aria-controls, and the body gained anidto match. Additive, but it changes the accessibility tree — update any a11y assertions that enumerated it. - Markup gained attributes. Every element now carries
data-fw-part, plusdata-state,data-presentation,data-modeanddata-fw-id. Classes are unchanged. Expect churn in DOM snapshot tests. - A stored position is no longer rewritten to fit the viewport. Through
0.1.0 a resize clamped each floating widget's position and saved the clamp,
so merely opening the page narrow permanently moved a widget: one stored at
x=900 became x≈100 and stayed stranded when the window widened again.
Clamping now happens where the position is read, so the stored value stays the
user's intent and the widget is still drawn on-screen. If you were relying on
the old behavior to rescue off-screen widgets,
resetAll()is the deliberate version of that.
Version pinning
0.2.0 rather than 0.1.1 is deliberate. Under semver's 0.x rules a caret
range allows patch bumps only — ^0.1.0 means >=0.1.0 <0.2.0 — so a
caret-pinned consumer will not cross this break on a reinstall, and can stay
on 0.1.0 as long as it likes. Only >=, * or latest ranges would drift.
Consuming the package by file: path, workspace link or git ref bypasses this
entirely.
Terminology
- Floating widget — one draggable, snappable panel (the mechanism).
- HUD — the cluster of them in a given app (in Pitchcraft: Usage, Diagnostics, Telemetry, Demo). The package is the mechanism; "the HUD" is what a host app assembles from it.
Development
npm install
npm run typecheck # tsc --noEmit, mirrors Pitchcraft's strictness
npm test # vitest — regression tests for the useAvoid* hooks
npm run build # tsup → dist/index.js (ESM) + dist/index.d.tsAll three run in CI on every PR, and prepublishOnly runs them again before a
publish. The test suite exists because two consecutive releases shipped broken
useAvoid* hooks: test/avoid.test.tsx covers both the portal-mount timing
and the callback-ref churn that caused them.
Licensed under the MIT License; see LICENSE.
