Quick Chat Dock
A hidden bottom-edge handle that wakes into an action dock on hover and summons a floating prompt bar.
Hover the bottom edge, or press ⇧ Enter.
"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ArrowUp, PanelRight, Sparkle } from "lucide-react";
import { cn } from "@/lib/utils";
export interface QuickChatDockProps {
placeholder?: string;
/** Called with the question when the floating prompt bar is submitted. */
onSubmit?: (value: string) => void;
/** Called when the user asks to open the full agent panel (with any draft typed so far). */
onOpenAgent?: (draft: string) => void;
/** Listen for ⇧Enter (quick chat) and ⇧A (open agent) anywhere on the page. */
shortcuts?: boolean;
/** Positioning is up to the host: `absolute` inside a relative canvas, or `fixed` app-wide. */
className?: string;
}
const spring = { type: "spring", stiffness: 520, damping: 38 } as const;
function isEditable(target: EventTarget | null) {
return (
target instanceof HTMLElement &&
(target.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName))
);
}
export function QuickChatDock({
placeholder = "Search or ask anything...",
onSubmit,
onOpenAgent,
shortcuts = true,
className,
}: QuickChatDockProps) {
const [hovered, setHovered] = React.useState(false);
const [focused, setFocused] = React.useState(false);
const [open, setOpen] = React.useState(false);
const [value, setValue] = React.useState("");
const inputRef = React.useRef<HTMLInputElement>(null);
const rootRef = React.useRef<HTMLDivElement>(null);
const leaveTimer = React.useRef<number | undefined>(undefined);
const reduceMotion = useReducedMotion();
const expanded = !open && (hovered || focused);
const openChat = React.useCallback(() => {
setOpen(true);
setHovered(false);
setFocused(false);
}, []);
const closeChat = React.useCallback(() => {
setOpen(false);
setValue("");
}, []);
const openAgent = React.useCallback(
(draft = "") => {
onOpenAgent?.(draft);
closeChat();
setFocused(false);
},
[onOpenAgent, closeChat]
);
React.useEffect(() => {
if (open) inputRef.current?.focus();
}, [open]);
React.useEffect(() => {
if (!shortcuts) return;
function onKey(e: KeyboardEvent) {
if (!e.shiftKey || e.metaKey || e.ctrlKey || e.altKey || isEditable(e.target)) return;
if (e.key === "Enter") {
e.preventDefault();
openChat();
} else if (e.key.toLowerCase() === "a") {
e.preventDefault();
openAgent();
}
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [shortcuts, openChat, openAgent]);
React.useEffect(() => {
if (!open) return;
function onDown(e: PointerEvent) {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) closeChat();
}
document.addEventListener("pointerdown", onDown);
return () => document.removeEventListener("pointerdown", onDown);
}, [open, closeChat]);
React.useEffect(() => () => window.clearTimeout(leaveTimer.current), []);
function handleEnter() {
window.clearTimeout(leaveTimer.current);
setHovered(true);
}
function handleLeave() {
// A short grace period so the dock doesn't flicker shut when the pointer grazes its edge.
leaveTimer.current = window.setTimeout(() => setHovered(false), 180);
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const text = value.trim();
if (!text) return;
onSubmit?.(text);
closeChat();
}
return (
<div
ref={rootRef}
className={cn("pointer-events-none absolute inset-x-0 bottom-0 z-30 flex justify-center", className)}
>
<AnimatePresence mode="popLayout" initial={false}>
{open ? (
<motion.form
key="bar"
onSubmit={handleSubmit}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
closeChat();
}
}}
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 12, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 12, scale: 0.96 }}
transition={spring}
className="pointer-events-auto mx-3 mb-3 flex w-full max-w-md items-center gap-1 rounded-2xl border bg-card p-2 pl-3.5 shadow-md"
>
<input
ref={inputRef}
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={placeholder}
aria-label={placeholder}
className="min-w-0 flex-1 bg-transparent py-1.5 text-sm outline-none placeholder:text-muted-foreground"
/>
<button
type="button"
onClick={() => openAgent(value.trim())}
aria-label="Continue in agent panel"
title="Continue in agent panel"
className="flex size-8 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<PanelRight className="size-4" />
</button>
<button
type="submit"
disabled={!value.trim()}
aria-label="Ask"
className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground transition-opacity disabled:opacity-40"
>
<ArrowUp className="size-4" />
</button>
</motion.form>
) : (
<motion.div
key="dock"
role="toolbar"
aria-label="Assistant"
onPointerEnter={handleEnter}
onPointerLeave={handleLeave}
onFocus={() => setFocused(true)}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) setFocused(false);
}}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.12 }}
// The invisible hot zone: a generous strip along the bottom edge that wakes the dock.
className="pointer-events-auto flex h-16 w-64 max-w-full items-end justify-center pb-3"
>
<motion.div
layout={!reduceMotion}
transition={spring}
style={{ borderRadius: 999 }}
className={cn(
"flex items-center overflow-visible",
expanded
? "gap-0.5 border bg-popover p-1 shadow-md"
: "h-1.5 w-10 bg-muted-foreground/40 hover:bg-muted-foreground/60"
)}
>
{expanded ? (
<>
<DockButton label="Quick chat" keys={["⇧", "Enter"]} onClick={openChat} autoFocus={focused}>
<Sparkle className="size-4" />
</DockButton>
<DockButton label="Open Agent" keys={["⇧", "A"]} onClick={() => openAgent()}>
<PanelRight className="size-4" />
</DockButton>
</>
) : (
// Keeps the collapsed handle reachable by keyboard; focusing it expands the dock.
<button type="button" aria-label="Show assistant actions" className="size-full rounded-[inherit] outline-none" />
)}
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
function DockButton({
label,
keys,
onClick,
autoFocus,
children,
}: {
label: string;
keys: string[];
onClick: () => void;
autoFocus?: boolean;
children: React.ReactNode;
}) {
const [tip, setTip] = React.useState(false);
return (
<div className="relative">
<motion.button
type="button"
onClick={onClick}
onPointerEnter={() => setTip(true)}
onPointerLeave={() => setTip(false)}
onFocus={() => setTip(true)}
onBlur={() => setTip(false)}
aria-label={label}
autoFocus={autoFocus}
aria-keyshortcuts={keys.join("+").replace("⇧", "Shift")}
initial={{ opacity: 0, scale: 0.6 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ ...spring, delay: 0.04 }}
className="flex size-8 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:outline-none"
>
{children}
</motion.button>
<AnimatePresence>
{tip && (
<motion.div
role="tooltip"
initial={{ opacity: 0, y: 4, x: "-50%" }}
animate={{ opacity: 1, y: 0, x: "-50%" }}
exit={{ opacity: 0, y: 4, x: "-50%" }}
transition={{ duration: 0.12 }}
className="pointer-events-none absolute bottom-full left-1/2 mb-2.5 flex items-center gap-2 rounded-xl border bg-popover py-1.5 pr-1.5 pl-2.5 text-xs font-medium whitespace-nowrap text-foreground shadow-md"
>
{label}
<span className="flex gap-1">
{keys.map((k) => (
<kbd
key={k}
className="flex h-5 min-w-5 items-center justify-center rounded border bg-muted px-1 font-sans text-[10px] font-medium text-muted-foreground"
>
{k}
</kbd>
))}
</span>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
A UX spec for this pattern — written for agents implementing or reusing it, not the code.
# Quick Chat Dock
## Summary
A near-invisible handle pinned to the bottom edge of a workspace that wakes into a small action dock on hover. Its main action summons a floating prompt bar for a quick question without leaving the current screen; a second action opens the full agent panel. AI stays one gesture away while taking almost no space.
## When to use
- Canvas-heavy or content-dense apps (editors, boards, dashboards) where a permanent composer or sidebar would steal room, but users still want to ask the assistant something mid-task.
- Products that offer both a lightweight "ask once" flow and a full, persistent agent conversation.
## When not to use
- Chat-first products where the composer is the primary surface — use a docked Prompt Bar instead.
- Touch-first layouts with no hover: the collapsed handle is too subtle to discover. Show the expanded dock permanently or use a floating action button.
- When the bottom edge is already busy (toolbars, status bars, timelines) — the hot zone will fight with them.
## Anatomy
- Collapsed handle: a small, low-contrast pill centered on the bottom edge.
- Hot zone: an invisible strip around the handle, larger than the handle, that wakes the dock.
- Expanded dock: two icon buttons — Quick chat (primary) and Open agent.
- Tooltips: label plus keyboard shortcut for each action.
- Floating prompt bar: single-line input, "continue in agent panel" button, send button.
## Behavior
- Hovering the hot zone morphs the handle into the dock with a quick spring; leaving it collapses the dock after a short grace delay (~150–200ms) so grazing the edge doesn't flicker.
- Hovering or focusing a dock button shows its tooltip with the shortcut.
- Quick chat replaces the dock with the floating prompt bar and focuses its input. Enter sends; Escape or a click outside closes it and discards the draft.
- Open agent opens the full agent panel. From the prompt bar, the panel button carries the current draft into the agent so nothing typed is lost.
- Global shortcuts (e.g. ⇧Enter for quick chat, ⇧A for the agent) work from anywhere except while typing in an editable field.
- After sending, the bar closes and the answer appears where it belongs (inline, a toast, or the agent panel) — the bar itself is not a conversation.
## Content guidelines
- Placeholder invites both search and questions in a few words: "Search or ask anything…".
- Tooltip labels are short verbs or nouns ("Quick chat", "Open agent"); shortcuts are shown as keycaps, not prose.
## Accessibility
- The collapsed handle is a real, focusable button; focusing it expands the dock and moves focus to the first action, so keyboard users never depend on hover.
- The dock is a `toolbar`; icon buttons have `aria-label`s and expose shortcuts via `aria-keyshortcuts`.
- Opening the prompt bar moves focus to its input; closing it should return focus sensibly (to the page, not a removed element).
- Tooltips also appear on focus, not only hover.
- Respect `prefers-reduced-motion`: crossfade instead of morphing and sliding.
## Related patterns
- **Prompt Bar** — Use it instead on chat-first surfaces where the composer is the main thing on the page.
- **Command Palette** — Use it instead when the input is a keyboard-first launcher for search and commands rather than a docked chat.
- **Selection Actions** — Use it instead when the toolbar should be anchored to selected content rather than the screen edge.
Do’s and don’ts
Do use it for
- Canvas-heavy or content-dense apps (editors, boards, dashboards) where a permanent composer or sidebar would steal room, but users still want to ask the assistant something mid-task.
- Products that offer both a lightweight "ask once" flow and a full, persistent agent conversation.
Don’t use it for
- Chat-first products where the composer is the primary surface — use a docked Prompt Bar instead.
- Touch-first layouts with no hover: the collapsed handle is too subtle to discover. Show the expanded dock permanently or use a floating action button.
- When the bottom edge is already busy (toolbars, status bars, timelines) — the hot zone will fight with them.
Related patterns
Prompt Bar
Use it instead on chat-first surfaces where the composer is the main thing on the page.
Command Palette
Use it instead when the input is a keyboard-first launcher for search and commands rather than a docked chat.
Selection Actions
Use it instead when the toolbar should be anchored to selected content rather than the screen edge.