LAB/ Tanishk Interface Lab
NavigationstableReactARIA toolbar

Contextual Dock

A toolbar that reconfigures around the selection — with no layout jump.

Live preview

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.

No action run yet

Text selectionText selection — 4 actions
SelectionPlacementSelection label

Or select text / click blocks in the mock document

01What it does

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.

02Install

Registry install

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.json

No npm packages are installed — the component is self-contained. Prefer manual copying? Every file is below under Source.

  • components/lab/contextual-dock/contextual-dock.tsxcomponent
  • components/lab/contextual-dock/contextual-dock.cssstyles

Machine-readable item: /r/contextual-dock.json ↗

03Usage

contextual-dock.usage.tsx
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} />

04Props

Props accepted by Contextual Dock
PropTypeDefaultNotes
context"none" | "text" | "object" | "multi"—The host’s current selection kind.
setsRecord<Context, DockAction[]>—Actions per context; the dock cross-fades between sets and reserves the widest.
labelstring—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.

05Accessibility

  • A real toolbar: arrow keys move between items, Home/End jump, and only one item is tabbable at a time.
  • The context change is announced (“Text selection — 6 actions”).
  • Actions are buttons with labels; icon-only actions keep their accessible names.
  • The dock reserves width, so a context change never moves the page or the pointer’s target.

06Source

2 files, 346 lines. Nothing private, no configuration required.

components/lab/contextual-dock/contextual-dock.tsx— press enter to show or hide the source
tsx
"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>
);
}
components/lab/contextual-dock/contextual-dock.css— press enter to show or hide the source
css
/* ============================================================
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;
}
}