¶ 01 · Morning survey
The headland path floods at spring tide, so the route notes assume an early start and a second reading at the causeway before the light goes flat.
A toolbar that reconfigures around the selection — with no layout jump.
Field guide — draft 3
Coast survey, morning pass
¶ 01 · Morning survey
The headland path floods at spring tide, so the route notes assume an early start and a second reading at the causeway before the light goes flat.
¶ 02 · Recording the tide line
Take the eastern transect first. The sand holds a clearer edge there, and the wind rarely moves the marker flags before noon.
¶ 03 · Notes for the next pass
Photograph each stake from the same two metres, and log the time beside the frame number so the pair can be matched later.
Select text inside a block, click a block, or shift-click a second block. Clicking the empty space clears the selection.
Or select text / click blocks in the mock document
What you can do should follow what you have selected: nothing selected is a quiet tool shelf, a text selection brings type controls, an object selection brings object actions, a multi-selection brings batch actions. The dock reserves its space and cross-fades between sets, so the interface never shifts under the pointer mid-selection.
In this previewA sample document surface: select text, click a block, or shift-click two blocks. The dock follows the selection and never moves the page.
Works with the shadcn CLI in any project that has a components.json. The component lands in components/lab/contextual-dock/ with its stylesheet beside it.
npx shadcn@latest add <lab-origin>/r/contextual-dock.jsonNo npm packages are installed — the component is self-contained. Prefer manual copying? Every file is below under Source.
Machine-readable item: /r/contextual-dock.json ↗
const sets = {
none: [{ id: "insert", label: "Insert block" }],
text: [
{ id: "bold", label: "Bold" },
{ id: "comment", label: "Comment" },
],
object: [{ id: "duplicate", label: "Duplicate" }],
multi: [{ id: "align", label: "Align" }],
};
<ContextualDock context={selection.kind} sets={sets} onAction={handle} />| Prop | Type | Default | Notes |
|---|---|---|---|
context | "none" | "text" | "object" | "multi" | — | The host’s current selection kind. |
sets | Record<Context, DockAction[]> | — | Actions per context; the dock cross-fades between sets and reserves the widest. |
label | string | — | Status line, e.g. “3 blocks selected”. |
placement | "floating" | "inline" | "floating" | Floats above content or sits in flow. |
onAction | (id: string, context) => void | — | Fired with the action id. |
2 files, 346 lines. Nothing private, no configuration required.
"use client";
import {
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
} from "react";
import "./contextual-dock.css";
export type DockContext = "none" | "text" | "object" | "multi";
export type DockAction = {
id: string;
label: string;
icon?: ReactNode;
onSelect?: () => void;
};
export type ContextualDockProps = {
/** The host’s current selection kind. */
context: DockContext;
/** Actions per context. The dock always reserves the widest set. */
sets: Record<DockContext, DockAction[]>;
/** Status line, e.g. “3 blocks selected”. */
label?: string;
placement?: "floating" | "inline";
/** Fired with the action id after `action.onSelect()`. */
onAction?: (id: string, context: DockContext) => void;
className?: string;
};
const CONTEXT_ORDER: DockContext[] = ["none", "text", "object", "multi"];
const CONTEXT_NAMES: Record<DockContext, string> = {
none: "No selection",
text: "Text selection",
object: "Object selection",
multi: "Multi-selection",
};
/**
* CONTEXTUAL DOCK — a toolbar that reconfigures without moving.
*
* Every context’s action set is rendered into the same grid cell, so
* the dock’s width is inherently the widest set; the inactive sets
* are visibility:hidden and only opacity cross-fades. Nothing shifts
* under the pointer when the selection changes. Focus is roving —
* exactly one button is tabbable — and each change is announced.
*/
export function ContextualDock({
context,
sets,
label,
placement = "floating",
onAction,
className,
}: ContextualDockProps) {
const activeSet = sets[context] ?? [];
const activeCount = activeSet.length;
const [focusIndex, setFocusIndex] = useState(0);
const buttonRefs = useRef<Partial<Record<DockContext, (HTMLButtonElement | null)[]>>>(
{},
);
const safeIndex =
activeSet.length === 0 ? 0 : Math.min(focusIndex, activeSet.length - 1);
// The remembered position survives a context change, but never
// points past the new set.
const max = Math.max(0, activeCount - 1);
if (focusIndex > max) {
setFocusIndex(max);
}
const handleToolbarKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
const count = activeSet.length;
if (count === 0) return;
let next: number | null = null;
switch (event.key) {
case "ArrowRight":
next = (safeIndex + 1) % count;
break;
case "ArrowLeft":
next = (safeIndex - 1 + count) % count;
break;
case "Home":
next = 0;
break;
case "End":
next = count - 1;
break;
default:
return;
}
event.preventDefault();
setFocusIndex(next);
buttonRefs.current[context]?.[next]?.focus();
};
const runAction = (action: DockAction) => {
action.onSelect?.();
onAction?.(action.id, context);
};
const announcement = `${CONTEXT_NAMES[context]} — ${activeSet.length} ${
activeSet.length === 1 ? "action" : "actions"
}`;
return (
<div
className={`cd-dock${className ? ` ${className}` : ""}`}
data-placement={placement}
data-context={context}
>
<div className="cd-status">
<span className="cd-status-text" aria-live="polite">
{label ?? ""}
</span>
<span className="cd-sr" aria-live="polite">
{announcement}
</span>
</div>
<div
className="cd-sets"
role="toolbar"
aria-label="Selection actions"
aria-orientation="horizontal"
onKeyDown={handleToolbarKeyDown}
>
{CONTEXT_ORDER.map((setContext) => {
const actions = sets[setContext] ?? [];
const isActive = setContext === context;
return (
<div
key={setContext}
className="cd-set"
data-active={isActive ? "true" : undefined}
aria-hidden={!isActive}
>
{actions.map((action, index) => (
<button
key={action.id}
type="button"
className="cd-btn"
data-selected={isActive && index === safeIndex ? "true" : undefined}
tabIndex={isActive && index === safeIndex ? 0 : -1}
ref={(element) => {
const list = (buttonRefs.current[setContext] ??= []);
list[index] = element;
}}
onFocus={() => {
if (isActive) setFocusIndex(index);
}}
onClick={() => runAction(action)}
>
{action.icon ? (
<span className="cd-icon" aria-hidden="true">
{action.icon}
</span>
) : null}
<span className="cd-btn-label">{action.label}</span>
</button>
))}
</div>
);
})}
</div>
</div>
);
}/* ============================================================
CONTEXTUAL DOCK — self-contained styles.
Tokens are scoped on .cd-dock with fallbacks; nothing here
depends on the Lab’s stylesheet.
============================================================ */
.cd-dock {
--cd-ink: #141418;
--cd-muted: #5f5f68;
--cd-faint: #8c8c94;
--cd-line: rgba(20, 20, 24, 0.1);
--cd-line-soft: rgba(20, 20, 24, 0.06);
--cd-surface: #ffffff;
--cd-surface-soft: #f4f4f3;
--cd-accent: #4f27e0;
--cd-accent-soft: rgba(79, 39, 224, 0.09);
--cd-focus: #4f27e0;
box-sizing: border-box;
width: 100%;
max-width: 100%;
font-family: inherit;
color: var(--cd-ink);
background: var(--cd-surface);
}
.cd-dock *,
.cd-dock *::before,
.cd-dock *::after {
box-sizing: border-box;
}
/* Floating lifts off the page; inline sits flush in the flow.
Both keep the same internal height. */
.cd-dock[data-placement="floating"] {
border: 1px solid var(--cd-line);
border-radius: 12px;
box-shadow: 0 12px 32px -14px rgba(20, 20, 24, 0.28),
0 2px 6px -2px rgba(20, 20, 24, 0.1);
}
/* Inline keeps the same 1px frame so both placements share a height. */
.cd-dock[data-placement="inline"] {
border: 1px solid transparent;
border-top-color: var(--cd-line);
border-radius: 0;
box-shadow: none;
}
/* ---------- status line ---------- */
.cd-status {
display: flex;
align-items: center;
min-height: 22px;
padding: 8px 10px 0;
}
.cd-status-text {
font-size: 0.7188rem;
line-height: 1;
color: var(--cd-muted);
}
.cd-sr {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
/* ---------- stacked sets: one grid cell, widest set defines width ---------- */
.cd-sets {
display: grid;
grid-template-areas: "stack";
align-items: center;
overflow-x: auto;
padding: 8px 10px 10px;
scrollbar-width: none;
}
.cd-sets::-webkit-scrollbar {
display: none;
}
.cd-set {
grid-area: stack;
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
opacity: 0;
visibility: hidden;
transition: opacity 140ms ease, visibility 0s linear 140ms;
}
.cd-set[data-active="true"] {
opacity: 1;
visibility: visible;
transition: opacity 140ms ease, visibility 0s;
}
/* ---------- buttons ---------- */
.cd-btn {
display: inline-flex;
align-items: center;
gap: 6px;
flex: none;
padding: 6px 10px;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
font: inherit;
font-size: 0.7813rem;
font-weight: 500;
line-height: 1.2;
color: var(--cd-ink);
white-space: nowrap;
cursor: pointer;
}
.cd-btn:hover {
background: var(--cd-surface-soft);
}
.cd-btn:active {
background: var(--cd-accent-soft);
}
.cd-btn:focus-visible {
outline: 2px solid var(--cd-focus);
outline-offset: 1px;
}
.cd-btn[data-selected="true"] {
border-color: var(--cd-line);
background: var(--cd-surface-soft);
}
.cd-icon {
display: inline-flex;
flex: none;
color: var(--cd-muted);
}
.cd-icon svg {
display: block;
width: 14px;
height: 14px;
}
.cd-btn[data-selected="true"] .cd-icon {
color: var(--cd-accent);
}
.cd-btn-label {
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.cd-set,
.cd-set[data-active="true"] {
transition: none;
}
}