AI

Chat Window

A chat shell in three presentations — inline, docked and modal — where switching never re-creates the panel, so the draft, caret and reading position survive.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Plus, X } from "lucide-react"
import { cn } from "@/lib/utils"

/**
 * Enter animation ships with the component: React 19 hoists <style href> into
 * <head> and dedupes by href, so several chat windows on a page still emit one
 * rule set. Only the modal presentation animates — inline and docked are part
 * of the page layout, they don't "arrive".
 */
const KEYFRAMES = `@keyframes zg-chat-window-in{from{opacity:0;transform:translateY(8px) scale(0.985)}to{opacity:1;transform:none}}
@keyframes zg-chat-window-scrim-in{from{opacity:0}to{opacity:1}}`

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chat-window.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "ChatWindow" shell (lucide-react for icons).
It is a composition layer: it renders a title bar and owns focus, scroll and
layout semantics, but it renders neither messages nor the input — both are slots.

Contract
- forwardRef<HTMLElement, ChatWindowProps> pointing at the panel element (the
  one that carries role/aria), spreading the remaining native props onto it and
  merging className into it with cn().
- presentation: "inline" | "docked" | "modal" (default "inline"). This is the
  only prop that changes semantics; the consumer owns it as state.
- title?: ReactNode (default "Chat") and model?: string — rendered together in
  the title bar and wrapped in one element referenced by aria-labelledby, so the
  accessible name is "title + model".
- children = message slot (the scrolling transcript), composer?: ReactNode =
  input slot pinned under it, headerActions?: ReactNode = extra title-bar
  controls (this is where an overflow "more" menu goes).
- onNewChat?: () => void and onClose?: () => void. Each renders its icon button
  ONLY when provided — never paint a control that does nothing. Esc is inert
  without onClose.
- Docked sizing: side ("left" | "right", default "right"), defaultWidth (384),
  minWidth (288), maxWidth (640), resizable (true), onWidthChange(width).

Behavior
- inline: height comes from the container; the title bar is sticky; only the
  transcript scrolls (min-h-0 + flex-1 + overflow-y-auto).
- docked: an in-flow flex item — sticky top-0, self-start, h-full, max-h-dvh.
  That combination behaves in both layouts a consumer will try: inside a bounded
  app shell it simply fills the row, and on a page that scrolls it pins to the
  viewport and caps its own height so the transcript scrolls inside the panel
  instead of stretching the document (which is what would otherwise give the
  main column a second scrollbar). The panel is allowed to shrink down to
  minWidth (or to the row width, whichever is smaller) rather than push the page
  into a horizontal scrollbar on a narrow viewport.
- docked resize handle: role="separator", aria-orientation="vertical",
  aria-valuemin/max/now in px, tabIndex 0. Pointer drag uses the delta from the
  press point (never the absolute pointer position) with setPointerCapture, a
  pointerId guard, and a buttons === 0 bail-out so a reused pointerId cannot
  keep resizing after the press was released off-target. Keyboard: the arrow
  towards the page grows the panel (16px, 64px with Shift), Home/End jump to the
  announced min/max. Bounds are derived at render from minWidth/maxWidth
  intersected with the parent width measured by a ResizeObserver — never stored,
  so a container resize corrects itself on the next paint.
- modal: a fixed inset-0 overlay with a scrim, role="dialog", aria-modal="true".
  It gets a real focus trap (Tab and Shift+Tab cycle inside the panel, handled on
  the panel's own onKeyDown so a nested menu keeps its own keys), Esc via the
  same handler with stopPropagation, focus moved to the first focusable inside
  the composer slot (falling back to the panel, which has tabIndex -1), focus
  restored to the element that opened it when the presentation leaves modal or
  the component unmounts, and a body scroll lock. The scrim calls onClose on
  pointerdown and preventDefaults it, so a press on it can never blur the panel
  and strand focus on <body> outside the trap.
- Scroll lock: refcount and the original inline styles live in document.body
  dataset keys (zyScrollLocks / zyScrollLockOverflow / zyScrollLockPadding), not
  in a module variable — two independently installed overlays on one page must
  cooperate, and a module counter makes the later unlock restore the earlier
  snapshot and freeze the page for good. Compensate the scrollbar by MEASURING:
  read documentElement.clientWidth, set overflow hidden, read it again, and add
  the difference to paddingRight. Do not compute innerWidth - clientWidth: on a
  page with scrollbar-gutter: stable the gutter never disappears and that
  formula pads a width that was never lost, shifting the page left.
- Switching presentation must not re-create anything. Render one element tree at
  one position for all three presentations (conditional children keep their
  slots, so the panel is always the same child index) and do NOT portal the
  modal: a portal remounts the subtree and takes the half-typed draft, the caret,
  the IME composition and the transcript scroll offset with it. The cost is that
  position: fixed resolves against the nearest ancestor with a transform /
  filter / contain — mount the window outside such ancestors.
- prefers-reduced-motion: the enter animation and colour transitions are off;
  every behaviour above still works.

Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground for the panel, border for
  the seams, bg-background/70 + backdrop-blur-sm for the scrim, bg-muted +
  text-muted-foreground for the model chip, hover:bg-accent for icon buttons,
  focus-visible:ring-2 ring-ring everywhere, bg-primary for the active resize
  grip. No hardcoded colours or radii.
- Ship the two @keyframes in a React 19 hoisted <style href precedence> tag so
  duplicate windows dedupe to one rule set.
- The title truncates and the model chip truncates; the icon buttons never
  shrink, so a long conversation name cannot push them off a 375px screen.
- inline / docked use role="region"; only modal gets aria-modal. Adding
  aria-modal to a non-modal surface lies to a screen reader about the rest of
  the page being unreachable.

Customization levers
- Presentation set: drop "docked" (delete the width/resize block) or "modal"
  (delete the scrim, trap, lock) if your product only needs one — the slots and
  the title bar are unchanged.
- Title bar contents: model chip → a model switcher, add a connection dot, move
  onNewChat into headerActions; keep the accessible name wrapper intact.
- Density: header px-3 py-2 and composer p-3 are the two knobs; the transcript's
  padding belongs to the slot you pass in.
- Modal size: max-w-2xl / max-h-[44rem] on the panel, p-4 sm:p-6 on the overlay.
- Docked defaults: defaultWidth / minWidth / maxWidth, side="left" for an
  inspector layout, resizable={false} for a fixed rail; persist onWidthChange to
  localStorage if the width should survive reloads.
- Small screens: keep one instance and swap presentation to "modal" below your
  breakpoint — that is the whole reason the switch preserves state.

Concepts

  • Presentation as semantics — the three values are not three skins: they select the focus model (trap or not), the scroll model (locked page or nested scroller) and the ARIA role. Everything else — title bar, slots, tokens — is identical, which is why moving between them is a prop change rather than a component swap.
  • No-remount switch — all three presentations render the same element at the same tree position, and the modal is deliberately not portalled. React therefore keeps every DOM node alive across the switch, so a half-typed message, the caret inside it, an open IME composition and the transcript's scroll offset all survive; the trade is that position: fixed resolves against any transform / filter / contain ancestor you mount it under.
  • Refcounted scroll lock — the lock counter and the pre-lock inline styles live on document.body dataset keys, so two independently installed overlays interoperate. A module-level counter would let the second unlock restore the first one's snapshot and leave the page permanently frozen.
  • Measured scrollbar compensation — the padding added while the page is locked is the observed change in documentElement.clientWidth, not innerWidth - clientWidth. On a page with scrollbar-gutter: stable the gutter never goes away, so the computed formula would pad width that was never lost and shift the layout sideways.
  • Focus lifecycle — opening moves focus into the composer slot, Tab cycles inside the panel, Esc and the scrim call onClose, and leaving modal (or unmounting) returns focus to whatever opened it, but only if focus is still inside the panel or on <body>.
  • Slot composition — messages and input arrive as children / composer, so the shell never imports a transcript or an editor: pair it with message-list and chat-composer, or with your own.

On This Page