Command Dock
A small button that opens into a field, with the answer arriving under it a row at a time.
'use client';
import { FileTextIcon, SearchIcon } from 'lucide-react';
import { useRef, useState } from 'react';
import { CommandDock } from '@/components/panelui/command-dock';
/** What a search of some meeting notes might turn up. */
const NOTES = [
{ title: 'Pricing review', where: 'Notes from 3 March' },
{ title: 'What we decided about trials', where: 'Notes from 19 February' },
{ title: 'Questions for finance', where: 'Notes from 11 February' },
{ title: 'Draft of the pricing page', where: 'Shared by Imani' },
];
export default function CommandDockFind() {
const [found, setFound] = useState<typeof NOTES>([]);
const [busy, setBusy] = useState(false);
const timers = useRef<number[]>([]);
// Stands in for a search that answers a row at a time.
const find = () => {
timers.current.forEach((timer) => window.clearTimeout(timer));
setFound([]);
setBusy(true);
timers.current = NOTES.map((note, index) =>
window.setTimeout(() => {
setFound((current) => [...current, note]);
if (index === NOTES.length - 1) setBusy(false);
}, 500 + index * 450)
);
};
return (
<CommandDock onSubmit={find} busy={busy} closedWidth="10.5rem" className="min-h-72 items-start">
<CommandDock.Trigger>
<SearchIcon />
Search the notes
</CommandDock.Trigger>
<CommandDock.Panel>
<CommandDock.Input placeholder="Try: pricing" aria-label="Search the notes" />
<CommandDock.Results label="Notes found">
{found.map((note) => (
<CommandDock.Row key={note.title}>
<FileTextIcon />
<span className="min-w-0 flex-1 truncate font-medium">{note.title}</span>
<span className="shrink-0 text-muted-foreground">{note.where}</span>
</CommandDock.Row>
))}
{busy ? <CommandDock.Status>Reading the notes</CommandDock.Status> : null}
</CommandDock.Results>
</CommandDock.Panel>
</CommandDock>
);
}Use it for one question a reader can ask from wherever they are: search these notes, find an action, ask an assistant. It is a small button that opens into a field, and the answer arrives under the field a row at a time.
It runs nothing itself. onSubmit is handed what was typed and the rows are whatever you render, so it works the same over a search, a model or a list you already have.
It holds one field and a short list. For a conversation that goes back and forth, use a chat interface, and for a list long enough to scroll, a full search page.
Installation
npx shadcn@latest add https://panelui.dev/r/web/command-dock.jsonpnpm dlx shadcn@latest add https://panelui.dev/r/web/command-dock.jsonyarn dlx shadcn@latest add https://panelui.dev/r/web/command-dock.jsonbunx shadcn@latest add https://panelui.dev/r/web/command-dock.jsonThe source is copied into components/panelui/, where you can edit it.
npm install panelui-webpnpm add panelui-webyarn add panelui-webbun add panelui-webImport from panelui-web/command-dock instead of @/components/panelui/command-dock. Tailwind needs one line to read the package's classes, which is on the installation page.
Install the packages it uses:
npm install tailwind-variantspnpm add tailwind-variantsyarn add tailwind-variantsbun add tailwind-variantsCopy the source into components/panelui/command-dock.tsx:
Usage
import { CommandDock } from "@/components/panelui/command-dock"<CommandDock onSubmit={find} busy={busy}>
<CommandDock.Trigger>Search the notes</CommandDock.Trigger>
<CommandDock.Panel>
<CommandDock.Input placeholder="Try: pricing" />
<CommandDock.Results>
{found.map((note) => (
<CommandDock.Row key={note.id}>{note.title}</CommandDock.Row>
))}
</CommandDock.Results>
</CommandDock.Panel>
</CommandDock>Composition
<CommandDock>
<CommandDock.Trigger />
<CommandDock.Panel>
<CommandDock.Input />
<CommandDock.Results>
<CommandDock.Row />
<CommandDock.Status />
</CommandDock.Results>
</CommandDock.Panel>
</CommandDock>CommandDock.Trigger— The closed form: a button with your label that opens the dock.CommandDock.Panel— The open form. It stays in the page while closed, folded away and inert, so opening and closing can both be seen.CommandDock.Input— The field, with a send button. Enter sends too.CommandDock.Results— Where the rows go. It takes no room until there is a row or the dock is busy.CommandDock.Row— One row of the answer. It slides in when it is added, andonSelectmakes it a button.CommandDock.Status— Three dots and a few words, for while an answer is on its way.
Examples
Rows that can be chosen
onSelect makes a row a button. Here the dock is held open from outside with open, the list is narrowed as you type through value, and choosing a row closes it.
'use client';
import { useState } from 'react';
import { CommandDock } from '@/components/panelui/command-dock';
const ACTIONS = ['Invite someone', 'Start a new notebook', 'Export this page', 'Open settings'];
export default function CommandDockChoose() {
const [query, setQuery] = useState('');
const [chosen, setChosen] = useState<string | null>(null);
const [open, setOpen] = useState(true);
const matches = ACTIONS.filter((action) => action.toLowerCase().includes(query.toLowerCase()));
return (
<div className="flex min-h-72 w-full flex-col items-center gap-3">
<CommandDock open={open} onOpenChange={setOpen} value={query} onValueChange={setQuery}>
<CommandDock.Trigger>Do something</CommandDock.Trigger>
<CommandDock.Panel>
<CommandDock.Input placeholder="Type to narrow the list" aria-label="Find an action" />
<CommandDock.Results label="Actions">
{matches.map((action) => (
<CommandDock.Row
key={action}
onSelect={() => {
setChosen(action);
setOpen(false);
}}
>
{action}
</CommandDock.Row>
))}
</CommandDock.Results>
</CommandDock.Panel>
</CommandDock>
<p className="text-sm text-muted-foreground" aria-live="polite">
{chosen ? `Chosen: ${chosen}` : 'Nothing chosen yet.'}
</p>
</div>
);
}API Reference
CommandDock
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | — | Whether it is open, when the caller holds that. |
defaultOpen | boolean | false | Whether it starts open, when the caller does not. |
onOpenChange | (open: boolean) => void | — | Called when it opens or closes. |
value | string | — | What is typed in the field, when the caller holds it. |
onValueChange | (value: string) => void | — | Called with what is typed, each time it changes. |
onSubmit | (value: string) => void | — | Called with what was typed when it is sent. |
busy | boolean | false | While true, the field cannot be sent again and CommandDock.Status is shown. |
closedWidth | string | '9rem' | How wide it is while closed, as a CSS length. |
CommandDock.Input
| Prop | Type | Default | Description |
|---|---|---|---|
submitLabel | string | 'Send' | What a screen reader calls the send button. |
CommandDock.Results
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | — | What a screen reader calls the list of rows. |
CommandDock.Row
| Prop | Type | Default | Description |
|---|---|---|---|
onSelect | () => void | — | Called when the row is pressed. Passing this makes the row a button. |
Every part also takes the props of the element it renders, and a className.
Notes
Sending
onSubmit is called with the text, trimmed, when Enter is pressed or the send button is used. It is not called for an empty field or while busy is true.
Busy
Set busy while an answer is on its way. The send button is disabled and the results area opens, so a CommandDock.Status in it has somewhere to show. Render the status yourself where you want it among the rows; the first example puts it after them.
Rows arriving
A row slides in when it is added to the page. Add rows one at a time, as they come, and they read as an answer being written. Rows rendered together arrive together. Give each row a stable key, or a row that was already there is drawn as new.
Size
Closed, the dock is closedWidth wide, 9rem unless you set it. It has to be a real length for the opening to be animated, so set it to fit your label. Open, it is as wide as its container up to 28rem.
The dock grows downwards as rows arrive and pushes what is under it. Give its container a min-h-* if that should not move the page.
Opening and closing
Opening, the surface widens, the label fades into the field, and the rows follow. Closing runs the other way: the rows fold up first and the surface narrows after them. With reduced motion on, both happen at once.
Escape closes the dock and returns focus to its button. A press outside it closes it too. What was typed, and the rows, are kept.
Screen readers
The field is a search landmark and takes aria-label. The results are a list that is announced politely as rows arrive, so they do not cut in on typing.
Server Components
CommandDock is a client component. In a file that starts with "use client", write its parts with a dot, as on this page. A Server Component cannot read CommandDock.Trigger off the import, so each part is also exported by name: CommandDockTrigger is CommandDock.Trigger, and so on for the others.
import { CommandDock, CommandDockTrigger, CommandDockPanel, CommandDockInput, CommandDockResults, CommandDockRow, CommandDockStatus } from "@/components/panelui/command-dock"