Skip to content
Tier

Dock window

Pro

Content the person can float in a window over the page and bring back: a running agent's log, a call, a draft they want beside another page. Docked, the content is in place with a Float button. Floating, a short note in place says where it went, with Bring back, and the content is in a non-modal window (role="dialog", no focus trap, the page still usable) that floats inside the page, over everything, through LayerPortal, so it works inside another library's dialog too.

The content is rendered once, through a portal into an element of its own, and that element is moved between the two places, so it is never remounted: a form keeps what was typed, a stream keeps its place, and its events reach React wherever it is. Where the browser has moveBefore, the move keeps an iframe's page loaded, both ways: as the window closes, the content is moved back before the window leaves the page. On the server and while hydrating, the content is drawn in place; it is mounted once more, into its own element, as hydration ends.

When the person floats the content or brings it back, or the app changes docked, focus moves to the window's title or back to Float in place. Content that starts floating takes no focus as the page loads. While it floats, the note in place has Go to window, which moves focus to the window's title: the keyboard's way from the page to the window, which sits outside the page's tab order where it floats.

The window moves by dragging its title bar, or with the arrow keys on its move handle (16 px a press, 64 px with Shift), and is kept inside the viewport. The arrows move it on the screen, so Left moves it left in a right-to-left page too. locked says why the content can't float now; Float stays focusable and says so.

Markdown
Version 5.2.0· other versions

This page is for 5.2.0, the current release. An earlier release’s page is the Markdown that release published, so the docs match the version you have installed (candor-lock.json records it).

Not in the docs of 1.3.0, 1.4.0, 1.5.0, 1.5.1, 1.6.0, 1.7.0, 1.8.0, 1.8.1, 1.9.0, 1.9.1, 2.0.0, 2.1.0, 2.2.0, 2.3.0, 2.4.0, 2.5.0, 2.5.1, 2.6.0, 2.6.1, 2.6.2, 2.7.0, 2.8.0, 2.9.0, 2.10.0, 3.0.0, 4.0.0, 4.0.1, 4.0.2, 4.1.0 and 4.2.0: the page came later, or had another name then.

Docs pages weren’t kept for 1.0.0, 1.0.1, 1.1.0, 1.1.1, 1.2.0 and 1.2.1: they are kept from 1.3.0 on, and before that only llms.txt was published.

Preview

Loading the preview

Install

With the Candor CLI

First time? Get started sets up the registry and theme. Or with shadcn directly: npx shadcn@latest add @candor/dock-window

Brings in: bidi, button, heading-level, heading-scope, layer, locale, locked, motion, tokens, utils · npm: lucide-react@^1.67.0

Usage

TSX · The example above
"use client"

import * as React from "react"
import { DockWindow } from "@/components/candor/dock-window"
import { TooltipProvider } from "@/components/candor/ui/tooltip"

const lines = ["Building storefront@418", "Installing dependencies (1.2s)", "Compiling 214 modules", "Uploading to Staging", "Running smoke tests"]

export default function Example() {
  const [shown, setShown] = React.useState(2)
  const [deploying, setDeploying] = React.useState(false)
  return (
    <TooltipProvider>
      <div className="flex flex-col gap-3">
        <label className="flex items-center gap-1.5 text-xs text-muted-foreground">
          <input type="checkbox" checked={deploying} onChange={(e) => setDeploying(e.target.checked)} />
          A deploy is starting
        </label>
        <div className="h-56 rounded-lg border p-3">
          {/* The log is rendered once: floated and brought back, it keeps its lines and the note typed into it. */}
          <DockWindow title="Deploy log" locked={deploying ? "The log can't float while a deploy is starting." : undefined}>
            <div className="flex flex-col gap-2 p-1 text-xs">
              <ol className="flex flex-col gap-0.5 font-mono">
                {lines.slice(0, shown).map((line) => (
                  <li key={line}>{line}</li>
                ))}
              </ol>
              <button type="button" className="self-start text-primary underline-offset-2 hover:underline" onClick={() => setShown((n) => Math.min(lines.length, n + 1))}>
                Show the next line
              </button>
              <input aria-label="Note on this deploy" placeholder="Note on this deploy" className="h-8 rounded-md border bg-card px-2 text-sm" />
            </div>
          </DockWindow>
        </div>
        <p className="text-xs text-muted-foreground">Floating, drag the window by its title bar, or focus its handle and use the arrow keys (Shift moves further).</p>
      </div>
    </TooltipProvider>
  )
}

<DockWindow>

PropTypeDefaultDescription
titlerequiredstring—What the content is ("Deploy log"): the window's name, and in the buttons' names.
dockedboolean | undefined—Whether the content is in place, when you control it. Leave it out and DockWindow keeps its own state.
defaultDockedboolean | undefinedtrueWhether the content starts in place, when DockWindow keeps its state. Defaults to true.
onDockedChange((docked: boolean) => void) | undefined—Called when the person floats the content or brings it back.
placement"end-bottom" | "end-top" | "start-bottom" | "start-top" | undefined"end-bottom"The corner of the viewport where the window first floats, on the inline start or end. Defaults to "end-bottom".
lockedstring | null | undefined—Why the content can't float now. Float stays focusable, marked `aria-disabled`, and says why. `null` or left out: unlocked.
headingLevelHeadingLevel | undefined—The level of the floating window's title. Defaults to one below the nearest `HeadingScope`, else 2. While the content floats it sits under that title, in a scope at this level; in place it sits where the page puts it.
classNamestring | undefined—Classes for the box in the page.
childrenrequiredReact.ReactNode—The content, rendered once and kept whether it is in place or floating.

Functions

useDockMorph(docked: boolean)

A FLIP move between an app's docked box and its floating one, for your own docking: put sourceRef on the box in the page, targetRef on the floating box, and style on both. When docked changes, the box that now shows starts where the other one was and slides to its own place, through the move family (dock-window's morph moment). Under reduced motion, data-motion="none" or a brand's level 0 it jumps. DockWindow uses it.

Strings

The fixed words, in English by default. Change any of them under dockWindow in <LocaleProvider strings={{ … }}>; the table is exported as dockWindowStrings.

KeyEnglish
floatFloat
floatTitleFloat {title}
floating{title} is in a floating window
bringBackBring back
bringBackTitleBring back {title}
goToWindowGo to window
goToWindowTitleGo to window: {title}
moveMove {title}
moveHintArrow keys move the window, Shift with an arrow moves it further.

Motion

MomentFamilyWhat it does
morphmoveThe window slides from the content's place in the page to where it floats, and back. Under reduced motion it jumps.

Set with --cd-motion-dock-window-<moment>-* in your brand file; the families are in the design reference.

Terms used here

Calm level
What Candor's motion does for a person whose operating system asks for reduced motion: the highest-precedence setting, above the app's own motion level (none, calm, subtle or expressive) and the brand file. A theme tunes how a change looks, never when it happens, so nothing depends on seeing the motion.

Every term is defined in the glossary (Markdown).

Types from other items

The props above take these. Each is defined by another item; import it from the path shown.

HeadingLevel from @/lib/candor/heading-level
A heading's level in the page's outline: `1` is `<h1>`, `6` is `<h6>`. Every Candor item that draws a heading takes its `headingLevel` as this type; the level is where the heading sits in the outline, never how large it looks.
1 | 2 | 3 | 4 | 5 | 6