AI Patterns

Quick Chat Dock

A hidden bottom-edge handle that wakes into an action dock on hover and summons a floating prompt bar.

JobAsk
Seen in

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.