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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chat-window.jsonPrompt
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: fixedresolves against anytransform/filter/containancestor you mount it under. - Refcounted scroll lock — the lock counter and the pre-lock inline styles live on
document.bodydataset 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, notinnerWidth - clientWidth. On a page withscrollbar-gutter: stablethe 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 withmessage-listandchat-composer, or with your own.
Chat Sidebar
A conversation-history sidebar — calendar-day groups cut in the display time zone, in-place rename with rollback, delete confirmed by title, pinning, local search with its own no-match state, and real loading/empty/error branches.
Streaming Text
An incremental Markdown renderer for LLM output — frozen blocks, speculative closing of half-written markup, an inline cursor and one screen-reader announcement per answer.