ui-057

AI Chat Interface

£5

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
AI Chat Interface - UI component preview

This element reads better at your own screen size than in the frame below:

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.

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

Paid UI element

Purchase to unlock the code.

Buying AI Chat Interface opens the HTML, CSS and JavaScript source, the full documentation, an AI setup prompt for your editor, and every update we ship to it. Standard license: unlimited personal and commercial projects.

£5 Standard license, unlimited projects

Browse UI elements

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

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

Worked examples, the events and programmatic API, and the class reference ship with the download, alongside the full source.

Your cart

Your cart is empty

The Vault £99

The Vault library, plus future additions to the library.