Conversation and messages
The scrolling log, and the turns inside it. Conversation wraps
use-stick-to-bottom, which is where the
follow-the-newest-turn behaviour comes from; the component tracks whether you are at the
bottom rather than forcing you there, and ConversationScrollButton renders only while you
are not.
Every demo on this page is the same fixture the accessibility audit measures, with motion frozen so the visual suite can photograph it.
No messages
Ask something to begin.
The demo is the empty state — ConversationEmptyState, which is what the log renders before
the first turn arrives. Its default title and description are English strings baked into the
component; pass your own.
Import
import {
Conversation,
ConversationContent,
ConversationEmptyState,
ConversationScrollButton,
} from '@elirobinson/ai-elements/components/conversation';
import { Message, MessageContent } from '@elirobinson/ai-elements/components/message';There is no barrel. A bare @elirobinson/ai-elements import does not resolve.
Messages
Message takes from and turns it into a class — is-user or is-assistant — which is
what moves a user turn to the right and gives it a filled bubble. It sets no role and no
aria-*: who is speaking is carried by layout and colour only, so if the distinction
matters to a reader who cannot see it, say it in the content.
MessageResponserenders its children as markdown through Streamdown.MessageContentdoes not — it renders what you give it.MessageActionis the icon affordance in the toolbar. It putslabel || tooltipin ansr-onlyspan, so aMessageActionwith neither is a button with no name.MessageBranchPreviousandMessageBranchNextcarry their ownaria-labeland disable themselves when there is only one branch.MessageBranchPagerenders "1 of 2" as text.
Accessibility
Conversationrendersrole="log". It does not setaria-live— nothing in the vendored tree setsaria-liveanywhere — and{...props}is spread last, so state it yourself:aria-live="polite"witharia-relevant="additions text"for a live thread,aria-live="off"for one that is closed or being replayed.- Scrolling is instant in this system, not animated. Upstream's default is a smooth scroll
on first paint and on every content resize, which is motion inside a live region with
nothing in the tree reading
prefers-reduced-motionbehind it. The vendored copy setsinitial="instant"andresize="instant"instead;{...props}is spread last there too, so<Conversation initial="smooth">puts the animation back. The patch isconversation-initial-instantandconversation-resize-instantinscripts/ai-elements-patches/motion.mjs, and it is a default, not an API change. ConversationScrollButtonandConversationDownloadare icon-only and set no accessible name of their own. Both render a real<button type="button">and both reach the 44×44 floor throughbutton-floorbelow, but neither has anaria-labeland neither hassr-onlytext. Pass one.ConversationDownloadbuilds the markdown in the browser from themessagesyou hand it and downloads it through an object URL. Nothing leaves the page.
What this system changed
| Patch | What changed, and why |
|---|---|
button-floor | primary — ui/button.tsx — every <Button> in the tree. Measured default 36px, sm 32px, icon 36x36, icon-sm 32x32, icon-lg 40x40. The floor every vendored button gets unless it is classified dense elsewhere in this list. shadcn sizes at 32-40px, which clears AA and misses this system’s AAA default; the `not-data-[touch-target=dense]` guard is what keeps that a default rather than a blanket, and it cannot be undone by a consumer’s className the way a merged utility could. |
message-action-dense | dense — message.tsx — MessageAction (copy, regenerate, rate). Measured 32x32, clears 24x24. The clearest case in this file: the message is the subject, the action is an affordance attached to it, and the row of them reads as one cluster at a single scale. |
Read from vendoredElementTargets in @elirobinson/ai-patterns/contracts, which the browser audit writes.
Replacing ChatThread
ChatThread and ChatMessage from @elirobinson/react are deprecated in favour of these.
use-stick-to-bottom already implements the follow-the-bottom behaviour ChatThread was
written for. The one thing that does not carry across is the announce prop — write
aria-live directly, as above.