# AI Chat Interface

> An AI chat interface: replies stream in word by word behind a caret, sent messages fly into their bubble, with stop and scroll anchoring.

Canonical: https://gsapvault.com/ui-elements/ai-chat-interface
Live demo: https://gsapvault.com/demos/ai-chat-interface/index.html

| Property | Value |
|----------|-------|
| Type | ui-element |
| Tier | paid |
| Price | £5 |
| Difficulty | intermediate |
| Plugins | Core GSAP only |
| Techniques | timed-stream, stagger, state-transition, focus-management, micro-interaction |
| Uses Lenis | No |

## Overview

The chat panel an assistant feature needs: a message thread above a composer, with no model, vendor or SDK baked in. You pass one async generator that yields text, and the panel does the rest.

A sent message travels from the composer into its bubble while the composer shrinks back to one line. A quiet thinking indicator hands over to a reply that streams in word by word, each chunk rising in behind a violet caret, rendered as paragraphs, bullet lists and inline code from DOM nodes, never innerHTML. The thread stays pinned to the bottom while a reply streams unless the reader scrolls up, when a Jump to latest pill springs in. While streaming, the send button morphs into Stop, which aborts the request and keeps the partial text. Finished replies get Copy and Regenerate. Light and dark themes, every colour a token.

## Features

- AIChat.mount(root, { respond }) with send(), stop(), clear(), destroy(), plus aichat:send, aichat:done and aichat:stop events
- respond is an async generator that receives the conversation and an AbortSignal, so any streaming endpoint plugs in
- Replies stream in word chunks that fade and rise in behind an accent caret
- Paragraphs, bullet lists and inline code, built with DOM nodes and textContent, never innerHTML
- The sent text travels from the composer into its bubble; the composer clears and shrinks back
- Composer auto-grows with a height tween up to six lines, then scrolls; Enter sends, Shift+Enter adds a line
- Send morphs into Stop while a reply streams; Stop or Escape aborts it and leaves the partial text marked Stopped
- Scroll anchoring: pinned to the latest text unless you scroll up, then a Jump to latest pill scrolls back smoothly
- Empty state with suggestion chips that send on click and leave with a short stagger
- Copy and Regenerate on finished replies
- role="log" thread with aria-busy while streaming; the finished reply is announced once through a polite live region, never token by token
- Multiple instances per page, no ids, and destroy() reverts every tween and listener
- Reduced motion shows text as it arrives with no travel or rise
- Light and dark themes driven entirely by custom properties

## Use Cases

- Assistant and copilot panels inside SaaS products
- Support and help-centre chat backed by your own model endpoint
- Writing assistants in editors, CMSs and admin tools
- Internal tools that front an LLM API through your own server

## Vibe-Code Ready Setup

This UI element includes `START-HERE-AI.md`, a product-specific copy-paste setup prompt for Cursor, Claude Code, ChatGPT, GitHub Copilot, Windsurf, and other coding assistants. It tells the assistant to inspect the existing stack, integrate the supplied files, preserve the design, scope selectors, retain accessibility and responsive behaviour, add framework-appropriate GSAP cleanup, and report what it tested.

[How AI-assisted setup works](https://gsapvault.com/vibe-coding)

## How It Works

### One generator, any backend

mount() takes a respond function that is an async generator: it receives a copy of the conversation and an AbortSignal and yields text chunks. The panel reads it with a loop that races each step against the abort, so Stop takes effect at once even if the source is slow to yield, then calls the generator's return() so it can close its request. Nothing in the component knows about models, keys or vendors; the README shows a fetch and ReadableStream version against your own endpoint.

### Streaming without innerHTML

A small renderer consumes the text a character at a time and builds paragraphs, bullet lists and inline code as nodes, holding a leading dash until it knows whether it starts a list item. Each new word lands in a span, and the spans from one chunk rise in with a short stagger while the caret moves to the end. When the reply finishes the spans are unwrapped back into plain text, so screen readers and selection see ordinary paragraphs.

### Scroll anchoring and the travel

While the reader is within a few pixels of the bottom the thread follows new text; an upward wheel or scroll unpins it and springs in the Jump to latest pill, whose smooth scroll re-reads the target every frame because the reply is still growing. The sent message is animated by a ghost positioned over the panel, tweened from the composer's text box to the bubble's measured position, so the thread's overflow never clips it mid-flight.

## Documentation

How this UI element works and how it goes into a page. The reference you use once you own the files (worked examples, events, the programmatic API, the class list) ships with the download.

### What's Included

- `index.html`: the demo page and the component markup to copy
- `assets/style.css`: component styles and both themes, every colour a custom property
- `assets/script.js`: readable, commented source exposing `window.AIChat`
- `assets/script.min.js`: minified production script

What it does:

- Streams replies in word chunks that fade and rise in, with an accent caret at the end
- Renders paragraphs, `- ` bullet lists and `` `inline code` `` by building DOM nodes with `textContent`; model text is never parsed as HTML
- Animates the sent text from the composer into its bubble while the composer clears and shrinks back to one line
- Grows the composer with a height tween up to six lines, then scrolls inside it
- Morphs Send into Stop while a reply streams; Stop (or Escape) aborts the request and keeps the partial text with a "Stopped" note
- Follows new text while you are at the bottom; scroll up and a "Jump to latest" pill springs in
- Shows an empty state with suggestion chips that send on click
- Adds Copy and Regenerate under finished replies
- Supports several panels on one page, with no ids and no globals besides `AIChat`

### Quick Start

**1. Add to your `<head>`:**

_Code snippet omitted: it ships with the download._

**2. Add before the closing `</body>` tag:**

_Code snippet omitted: it ships with the download._

**3. Copy the component markup** (between `<!-- Component starts here -->` and `<!-- Component ends here -->` in `index.html`). Its shape:

_Code snippet omitted: it ships with the download._

The script finds everything by class inside the root you mount, so rename nothing unless you rename it in `assets/script.js` too. The empty state, the chips, the jump pill and the hint line are optional: delete them and the panel still works. A chip sends its `data-prompt` attribute if it has one, otherwise its text. Use whichever heading level fits your page for the empty-state title.

**4. Mount it with your `respond` function:**

_Code snippet omitted: it ships with the download._

The toolbar strip, the centring `.stage`, the scripted demo responder and the Google Font link in `index.html` are demo furniture, not part of the component. Leave them behind.

### The API

#### `AIChat.mount(root, options)`

Returns a controller, or the existing one if `root` is already mounted.

| Option | What it is |
|--------|-----------|
| `respond(messages, { signal })` | An async generator (or any function returning an async iterable) that yields text chunks. `messages` is a copy of the conversation so far, `[{ role: 'user' \| 'assistant', content }]`, ending with the new user message. `signal` is an `AbortSignal` that fires when the reader stops the reply |
| `history` | Optional. An array of `{ role, content }` rendered settled on mount, for restoring a saved conversation |

#### The controller

_Code snippet omitted: it ships with the download._

`send()` returns `false` and does nothing while a reply is streaming or when the text is empty.

#### Events

Dispatched on the root and bubbling:

| Event | `detail` |
|-------|----------|
| `aichat:send` | `{ content, messages }`, the text just sent |
| `aichat:done` | `{ content, messages }`, the finished reply |
| `aichat:stop` | `{ content, messages }`, the partial reply that was stopped |

_Code snippet omitted: it ships with the download._

#### Streaming from your own server

Keep API keys on the server. Have an endpoint of yours call the model and stream plain text back, then read that stream in `respond`:

_Code snippet omitted: it ships with the download._

If your endpoint sends server-sent events or JSON lines instead of plain text, parse each line inside the loop and `yield` only the text. If `respond` throws, the reply shows "Something went wrong. Try regenerating." and the error is logged.

### Keyboard & Accessibility

| Key | What it does |
|-----|--------------|
| `Enter` | Sends the message |
| `Shift+Enter` | Adds a new line |
| `Escape` | Stops a streaming reply (anywhere inside the panel) |
| `Tab` | Moves through the chips, the thread, the reply actions and the composer |

- **Roles:** the thread is `role="log"` with `aria-live="off"`, so the reply is not read token by token while it streams. It carries `aria-busy="true"` while a reply is streaming.
- **Announcements:** the finished reply is announced once through the polite `.ai-chat__announcer` region; a stopped reply announces "Response stopped." and Copy announces "Copied to clipboard."
- **Labels:** the textarea has an `aria-label`; the send button's label switches between "Send message" and "Stop response" (set your own with `aria-label` and `data-stop-label`); Copy and Regenerate are labelled buttons.
- **Focus:** stays in the composer after sending, including from a chip or the jump pill, and after pressing Stop.
- **The thread is focusable** (`tabindex="0"`) so it can be scrolled with the keyboard.
- **`prefers-reduced-motion`:** text appears as it arrives with no rise, the sent message appears in its bubble without travelling, and the pill, icons and empty state change without tweens. The preference is read at the moment of each animation, so changing it while the page is open takes effect straight away.

### How It Works

**One generator, any backend.** The panel calls `respond` with a copy of the conversation and an `AbortSignal`, then reads it in a loop that races each step against the abort. Stop therefore takes effect immediately even when the source is between chunks, and the generator's `return()` is called so a `finally` block can release its reader. If `clear()` runs mid-stream, the late result is dropped instead of being written into the emptied thread.

**Streaming without innerHTML.** A small renderer consumes text a character at a time. A leading `- ` or `* ` opens a list item, a blank line opens a new paragraph, backticks open and close `<code>`, and every new word goes into a span. The spans from one chunk rise in with `gsap.fromTo` and a 25ms stagger while the caret moves to the end. When the reply finishes, the spans are unwrapped back into text nodes, so the finished message is ordinary paragraphs for screen readers, selection and copy.

**Scroll anchoring.** While the thread is within 32px of the bottom it follows new text. An upward wheel unpins it straight away, and any scroll away from the bottom springs in the pill with a damped `back.out`. Pressing it tweens a progress value and re-reads the bottom every frame, because a streaming reply keeps growing during the scroll.

**The travel.** On send, the bubble is laid out in the thread and measured, and a ghost copy is placed over the panel at that spot, then tweened from the composer's text position with its fill fading in. The ghost lives on the panel root rather than in the thread, so the thread's overflow cannot clip it. The fill colour is read from the bubble with `getComputedStyle` at that moment, which is why the travel is right in either theme.

**The composer.** On input it measures the textarea's natural height, caps it at six lines and tweens `height` to it; past the cap it switches `overflow-y` to `auto`. The send button's two icons cross-fade with a quarter turn when streaming starts and ends.

**Teardown.** Every tween, including those started from events and after `await`, is created inside the instance's `gsap.context`, so `destroy()` reverts them all, removes its listeners and generated nodes, and restores the empty state and the composer.

### Themes

Two themes, `light` (the default) and `dark`, set with one attribute on `<body>`, or on the `.ai-chat` root itself:

_Code snippet omitted: it ships with the download._

They are the same design at two token values. Nothing in `assets/script.js` reads the attribute. The demo's toolbar and the `?variant=dark` URL parameter only set it.

**Re-value for your brand** in both blocks at the top of `assets/style.css`:

- Surfaces: `--ground`, `--ground-2`, `--raised`, `--hover`
- Ink: `--ink`, `--ink-2`, `--ink-3`
- Hairlines: `--line`, `--line-strong`
- Accent (send button, focus ring, caret): `--accent`, `--accent-ink`, `--accent-wash`
- Messages: `--bubble`, `--bubble-ink`, `--code-bg`, `--code-ink`
- Disabled send: `--disabled-bg`, `--disabled-ink`
- `--shadow`, `--shadow-panel`, `--font`, `--font-mono`, `--radius`, `--radius-sm`, `--radius-xs`
- Size: `--chat-width` (720px) and `--chat-height` (640px); under 560px wide the panel fills the screen

Keep text colours solid rather than translucent, so contrast can be measured in both themes.

### Customisation

- **Chunk animation:** the `gsap.fromTo(fresh, ...)` call in `place` inside `runReply`. Lower `duration` or `stagger` for fast models.
- **Growth limit:** `MAX_LINES` at the top of `assets/script.js`.
- **Anchoring tolerance:** `SCROLL_THRESHOLD` (px from the bottom that still counts as the bottom).
- **Bubble shape:** `.ai-chat__bubble` border radius and `max-width`.
- Do not put a CSS `transform` on `.ai-chat__bubble`, `.ai-chat__jump`, the send icons or the streamed words: GSAP owns those transforms.

### Requirements

- GSAP 3.12+ (the demo uses 3.15.0; core only, no plugins)
- A browser with async generators and `AbortController` (every current browser)
- No build step, no framework

## What You Get

- `index.html`: working demo page
- `assets/script.js`: commented, readable source
- `assets/style.css`: effect styles
- `README.md`: full documentation with examples and framework integration notes
- `START-HERE-AI.md`: product-specific copy-paste prompt for AI-assisted setup
- `LICENSE.txt`: standard license terms
- Lifetime updates: re-download anytime from your library

## Get the Code

This is a premium UI element. The standard license costs £5 one-time and covers unlimited personal and commercial projects with no attribution required for our code. Bundled third-party assets retain their own licences and attribution requirements. The only restrictions: no redistribution of the code itself and no competing effect libraries.

- [Buy AI Chat Interface](https://gsapvault.com/ui-elements/ai-chat-interface)
- [The Vault (£99 one-time, best value): every collection in the Vault library, plus future items added to those collections](https://gsapvault.com/effects)

---

From [GSAP Vault](https://gsapvault.com): production-ready GSAP animation effects. Full catalog for agents: https://gsapvault.com/llms-full.txt
