ui-057

AI Chat Interface

£7
or the whole Vault, £99
Open in new tab

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

intermediate
5 more details
timed-streamstaggerstate-transitionfocus-managementmicro-interaction

Released

About this component

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.

Read the full effect overview

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.

AI Chat Interface - UI component preview

What's included

14 items
  • 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

Perfect for

4 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

How it works

3 sections

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.

Difficulty Intermediate
Includes HTML + JS + CSS source, documentation, AI setup prompt, lifetime updates

The Vault opens this source and the whole library.

Each product gives you the HTML, CSS and JavaScript source, the full documentation and an AI setup prompt, licensed for unlimited personal and commercial projects. Every paid product is tested for desktop and mobile layouts, reduced-motion handling, animation cleanup and keyboard controls wherever there is something to operate.

One payment, over 300 products, and future additions to the library.

Documentation

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>:

In your downloadThe working code for this step, in index.html.

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

In your downloadThe working code for this step, in index.html.

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

In your downloadThe working code for this step, in index.html.

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:

In your downloadThe working code for this step, in script.js.

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

In your downloadThe working code for this step, in script.js.

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

In your downloadThe working code for this step, in script.js.

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:

In your downloadThe working code for this step, in script.js.

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:

In your downloadThe working code for this step, in index.html.

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

Also in your download: the full source, worked examples, the events and programmatic API, and the class reference.

£7