One container owns which panels are open. No panel knows about any other.
An app grows floating things — a player, a chat, a settings panel, a
cookie notice — and each one arrives with its own corner, its own
toggle, and its own localStorage key. Nothing knows about
anything else, so they overlap, they all shout at once, and after a
reload the screen is whatever each panel independently decided.
The fix is not a component library. It is moving one thing — presence — out of the panels and into a container. The dock knows what exists, what is open, and where it sits. A panel knows how to draw itself. That is the whole split, and it is worth doing at three panels, not thirty.
Pull the bar at the bottom of the frame. Three panels, one dock, no framework — drag them, minimise them, reload the frame and they come back where you left them.
The demo is one HTML file; the dock is one script, no dependencies. Public domain — take it, rename it, change everything.
Paste this into Claude, in your own project. It builds the dock from scratch, in your stack, with the bugs below already dodged.
Build a "dock" for my app's floating panels: one container that owns which
panels are open, so no panel has to know about any other.
THE SHAPE
- One pull tab, bottom centre of the viewport. Clicking it opens a tray.
- The tray has a header row (what this is, and an X on the right that closes
it), then a row of panels: glyph, label, and a dot showing open / minimised.
- Clicking a tray row opens that panel in a popout window with a titlebar, a
minimise and a close. Clicking it while open minimises it instead.
- Windows drag by their titlebar, land near the centre of the screen in the
first spot that overlaps nothing, and remember where they were.
- ONE record in localStorage holds every panel's state and position. That
record is the source of truth — not a flag inside each panel.
- A toast API for the things that are not panels: a line of text and a couple
of actions, stacked above the tab.
REGISTRATION — three tiers, because panels come in three shapes
1. { tag: 'my-panel' } — a custom element the dock creates and owns. The dock
sets an `embedded` attribute on it; the element then renders its body only
and leaves chrome, positioning and close to the dock.
2. { mount(body, api) } — the dock hands you an empty box and { close,
minimize }. For plain markup, or a framework portal target.
Panels mount inside the dock's shadow root, so the host page's CSS does not
reach them — that is what lets the dock survive on a page whose styles it
has never met. A panel brings its own: its own shadow root, or a <style> in
the markup it mounts. Say so in the docs; it is not obvious.
3. { external: true, onOpen, onMinimize, onClose } — a panel my app already
renders and positions. The dock owns only its state and its tray row. My
app calls dock.setState(id, 'open'|'min'|'closed') when its own UI changes
it, and on first mount each panel adopts dock.getState(id) — so the dock
decides what is up after a reload, not each panel's own default.
API
register(item) · unregister(id)
open(id) · minimize(id) · close(id) · toggle(force?)
getState(id) → 'open' | 'min' | 'closed' · setState(id, state)
toast({ text, actions: [{ label, onClick, primary }], sticky, ttl })
fires a `dock-ready` event on document with { dock }, and sets a global
handle, so panels can register whichever loads first.
RULES THAT MATTER — every one of these is a bug already paid for
- Read attributes in connectedCallback, NEVER in the constructor. The dock
sets them after createElement, so a constructor never sees them.
- Guard the position clamp against a 0x0 viewport. Background tabs, prerender
and hidden frames report that, and the arithmetic sends every window to a
negative corner.
- Settle animations on the Animation's finished promise, never on a timer that
has to agree with a duration in a stylesheet. Under prefers-reduced-motion,
resolve immediately and cut.
- Write the state record before the position — they share one entry, and the
order decides whether a restored window keeps where it was.
- Count only the dock's own windows in the tab badge. External panels have a
tray dot but are not windows, and counting them makes the badge lie.
- Wrap the whole file in an IIFE and put the UI in a shadow root. It gets
dropped into pages that may already declare the same names.
- Escape minimises the active window — the one last raised — and only closes
the tray when nothing is up.
- A container that moves between DOM trees (Turbo, morphdom, a router) fires
disconnect then connect. Build once, and defer teardown a tick so a move
does not tear the dock down.
- The dock may connect before the code that registers panels runs, or after
it. Support both: read the global handle if it is already there, otherwise
listen once for the ready event. Getting this wrong is the most common way a
panel silently never appears — it cost me the first run of my own demo.
- If a panel is registered by tag and that tag's script never loaded, the
element upgrades to nothing and the window opens empty — it reads as a broken
widget, not a missing file. Check customElements.get(tag) when you spawn, say
which tag is missing, and clear the note if whenDefined(tag) later resolves.
Style everything from CSS custom properties with defaults, so a host page can
theme it by setting a handful of variables and nothing else. No dependencies.
The reason this works on an app that already exists: you do not have to rewrite a panel to dock it. Pick the tier that matches what you already have.
| tier | you give | dock owns | use when |
|---|---|---|---|
tag |
a custom element name | the element, its window, its state | you are writing the panel now |
mount |
a function that fills a box | the box, its window, its state | plain markup, or a portal target |
external |
three callbacks | the state and the tray row only | the panel already renders itself |
The third tier is the one that earns its keep. A React panel with its own badge, its own drawer and its own animation keeps every bit of that — it just stops being the authority on whether it is open.
Second prompt, for the retrofit. It is a smaller job than it looks: one hook, one line per panel.
My app already has several floating panels, each holding its own open/closed
state (useState, a store, or a localStorage key each). Wire them into the
dock as EXTERNAL items without moving any of their markup.
- Write one hook/helper — useDockItem({ id, label, glyph, title, open,
setOpen, enabled }) — that registers the panel with the dock as
{ external: true }, maps the dock's onOpen/onMinimize/onClose onto setOpen,
and reports every change the panel makes itself back with setState.
- The dock may load after the app, or before it. Handle both: read the global
handle if it is there, otherwise listen once for the ready event.
- On first registration the panel adopts dock.getState(id) — the dock's
memory beats the panel's own default, so one place decides what is open
after a reload.
- Keep the callbacks in a ref so they never close over a stale setter.
- Unregister on unmount, so a panel scoped to one route leaves the tray. Its
remembered state stays, so returning to the route restores it.
- Add exactly one line per panel. Do not touch their rendering, their styling
or their own toggles — those keep working and now report in.
Then delete whatever ad-hoc corner toggles the panels had, if the dock's tray
has replaced them.
Six panels, six corners, six storage keys. Two overlap on a laptop screen. The cookie bar covers the player. Nothing can ask "what is open right now?" because nothing holds the answer.
One tray. One record. A new panel is one register call
and inherits placement, persistence, drag, minimise, keyboard and
theming. Anything can ask the dock what is up — including the next
feature you have not written.
createElement and then setAttribute('embedded'). A constructor runs in between, so it sees nothing. Read at connectedCallback. This cost an afternoon of a panel rendering its own chrome inside the dock's chrome.finished promise. Under prefers-reduced-motion there is no animation to wait for, so resolve immediately — otherwise the handoff never runs and the panel is stuck half-gone.<script> block runs. Every registration written as "listen for ready" then silently does nothing. Check the global first, listen second. My own demo shipped with this bug and the tray came up empty.<script> on one page, and the element upgrades to nothing: the tray offers it, the window opens, and there is nothing inside and no error anywhere. It cost a day here — a widget was in the dock's built-ins but its script was only wired into one of five sites. Check customElements.get(tag) at spawn and name the missing tag on screen.<style> or its own shadow root.const IWLS; so did a widget. The browser refused the second script entirely and the panel simply never existed. One IIFE, one shadow root, and the only thing you publish is the element and one handle.Five Lucian Labs properties run this: the shop, radio, clock, hire and labs widgets used to be five fixed corners across lucianlabs.ca, tides, mood, waveloop.app and elijahlucian.ca. They are one dock now, and the personal site's own React panels ride in it as external items — fourteen of them, one line each.
dock.js on this page is that dock with the branding taken
out. It is the pattern, not the product: no Lucian tokens, no widgets,
nothing to strip before you use it.