AI Response
Assistant response text, with a streaming caret and a thinking indicator.
Installation
pnpm dlx @dowel-ui/cli add ai-responseInstalls ai-sources as well, because this component imports it.
No npm packages are needed beyond what Dowel already requires.
Accessibility
Deliberately not a live region: announcing streamed text as it arrives interrupts a screen reader user on every token. Announce state through ConversationStatus and let them read the response when it settles. The caret is decorative; ThinkingIndicator carries a label because it is the only thing on screen while waiting for the first token, and it slows rather than stops under reduced motion because a frozen one says the app has hung. ResponseText blurs in only newly arrived words, adds no live region, and leaves the text content unchanged; its [n] markers are InlineCitations named by their source title.
Props
Response
| Prop | Type | Default |
|---|---|---|
streamingShows the caret. Purely visual — it carries no announcement. | boolean | — |
Plus every attribute of <div>.
ResponseCaret
Plus every attribute of <span>.
ThinkingIndicator
| Prop | Type | Default |
|---|---|---|
label | string | "Thinking" |
Plus every attribute of <div>.
Quality
8/8 checks, measured from the source and its tests
- Tested — passes
- axe assertion — passes
- Keyboard tested — does not apply
- Storybook examples — passes
- Accessibility documented — passes
- Semantic tokens only — passes
- Motion from tokens — passes
- className merged — passes
- Visible focus — does not apply
- No fixed widths — passes
Used in
Whole screens assembled from this component. Installing one brings this and everything else it needs with it.
Source
This is exactly what dowel add ai-response writes into your project, with imports rewritten to your own path alias.
"use client";
// Motion from SmoothUI AI Response (MIT, © 2024 Eduardo Calvo). See THIRD_PARTY_NOTICES.md.
import type { ComponentPropsWithRef } from "react";
import { cn } from "@/lib/utils";
/**
* The text of an assistant response.
*
* Renders plain text with paragraph whitespace preserved. Markdown is
* deliberately not parsed here: a Markdown renderer is a dependency, a security
* decision about raw HTML, and a styling surface all at once, and every product
* makes those differently. Pass already-rendered content as children when you
* need it — the prose styling below applies either way.
*
* Not a live region, and not by omission. See the note on `Conversation`:
* announcing streamed text token by token is unusable with a screen reader.
*/
export interface ResponseProps extends ComponentPropsWithRef<"div"> {
/** Shows the caret. Purely visual — it carries no announcement. */
streaming?: boolean;
}
export function Response({ className, streaming, children, ...props }: ResponseProps) {
return (
<div
data-slot="response"
data-streaming={streaming || undefined}
className={cn(
"text-sm leading-relaxed whitespace-pre-wrap text-foreground",
// Minimal prose styling for consumers who pass rendered Markdown.
"[&_a]:text-primary [&_a]:underline [&_a]:underline-offset-4",
"[&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.9em]",
"[&_ol]:my-2 [&_ol]:list-decimal [&_ol]:ps-5 [&_ul]:my-2 [&_ul]:list-disc [&_ul]:ps-5",
"[&_p+p]:mt-3",
className,
)}
{...props}
>
{children}
{streaming ? <ResponseCaret /> : null}
</div>
);
}
/** The trailing cursor. Decorative — the streaming state is announced, if at
* all, through ConversationStatus. */
export function ResponseCaret({ className, ...props }: ComponentPropsWithRef<"span">) {
return (
<span
data-slot="response-caret"
aria-hidden="true"
data-motion="indicator"
className={cn(
"ms-0.5 inline-block h-[1em] w-[2px] translate-y-[0.15em] animate-caret bg-current align-baseline",
className,
)}
{...props}
/>
);
}
/**
* Shown while waiting for the first token.
*
* A distinct state from streaming: nothing has arrived yet, so there is no text
* to show a caret after. Labelled for assistive technology because, unlike the
* caret, this is the only thing on screen.
*
* An indicator in the ADR 0012 sense: it is the only sign that anything is
* happening, and a frozen one says the app has hung. So under reduced motion
* the dots slow (via --motion-scale-indicator) rather than stop.
*/
export interface ThinkingIndicatorProps extends ComponentPropsWithRef<"div"> {
label?: string;
}
export function ThinkingIndicator({
className,
label = "Thinking",
...props
}: ThinkingIndicatorProps) {
return (
<div
data-slot="thinking-indicator"
className={cn("flex items-center gap-1.5 text-sm text-muted-foreground", className)}
{...props}
>
<span className="sr-only">{label}</span>
{[0, 1, 2].map((index) => (
<span
key={index}
aria-hidden="true"
data-motion="indicator"
className="size-1.5 animate-pulse-soft rounded-full bg-current"
style={{
animationDuration: "calc(1.8s * var(--motion-scale-indicator, 1))",
animationDelay: `calc(${String(index * 160)}ms * var(--motion-scale-indicator, 1))`,
}}
/>
))}
</div>
);
}
"use client";
// Motion from SmoothUI AI Response (MIT, © 2024 Eduardo Calvo). See THIRD_PARTY_NOTICES.md.
import { useState, type ComponentPropsWithRef, type ReactNode } from "react";
import { InlineCitation } from "@/components/ai-sources";
import { cn } from "@/lib/utils";
/**
* Streamed plain text whose words blur in as they arrive.
*
* Only words that arrive after the first render animate. Keys are positional,
* so a word that already exists keeps its DOM node — and a node that is not
* remounted does not replay its entrance. That is "animate only what arrived"
* without reading a ref during render. There is no stagger: token arrival is
* the stagger.
*
* Whitespace and bare punctuation stay text nodes, so text extraction is
* unchanged and a line never breaks between a word and its comma. `[n]`
* markers matching a citation become `InlineCitation`s, which carry the
* source title in their accessible name.
*
* Like `Response`, this is not a live region: announcing a stream token by
* token is unusable with a screen reader (ADR 0009).
*/
export interface ResponseCitation {
/** Matches the `[n]` marker in the text. */
index: number;
/** Becomes the citation's accessible name. */
title: string;
/** Without one the marker is text, not a dead link. */
href?: string;
}
export interface ResponseTextProps extends Omit<ComponentPropsWithRef<"span">, "children"> {
/** The plain-text stream so far. Re-render it as it grows. */
text: string;
/** Sources for `[n]` markers. Unmatched markers stay literal text. */
citations?: ResponseCitation[];
/**
* Animate the words present on first render. Off by default, so a replayed
* transcript does not blur in its entire history.
*/
animateInitial?: boolean;
}
const PREFIX = "dowel-ai-response";
const STYLES = `
@keyframes ${PREFIX}-word{from{opacity:0;filter:blur(4px);translate:0 2px}}
@keyframes ${PREFIX}-pop{from{opacity:0;scale:.6}}
.${PREFIX}-word{display:inline-block;animation:${PREFIX}-word calc(220ms * var(--motion-scale,1)) var(--ease-out-quint) both}
.${PREFIX}-pop{display:inline-block;animation:${PREFIX}-pop calc(250ms * var(--motion-scale,1)) var(--ease-overshoot) both}
`;
const TOKEN_SPLIT = /(\s+|\[\d+\])/;
const CITATION_MARKER = /^\[(\d+)\]$/;
const HAS_WORD_CHARACTER = /[\p{L}\p{N}]/u;
const WHITESPACE = /^\s+$/;
function tokenize(text: string): string[] {
return text.split(TOKEN_SPLIT).filter((token) => token !== "");
}
export function ResponseText({
className,
text,
citations,
animateInitial = false,
...props
}: ResponseTextProps) {
const tokens = tokenize(text);
// Everything below this index was on screen at first render.
const [baseline] = useState(() => (animateInitial ? 0 : tokens.length));
const rendered: ReactNode[] = tokens.map((token, position) => {
const arrived = position >= baseline;
if (WHITESPACE.test(token)) return token;
const marker = CITATION_MARKER.exec(token);
if (marker) {
const index = Number(marker[1]);
const citation = citations?.find((candidate) => candidate.index === index);
if (!citation) return token;
return (
<span
key={position}
data-slot="response-citation"
className={arrived ? `${PREFIX}-pop` : undefined}
>
<InlineCitation index={index} title={citation.title} href={citation.href} />
</span>
);
}
if (!HAS_WORD_CHARACTER.test(token)) return token;
return (
<span
key={position}
data-slot="response-word"
className={arrived ? `${PREFIX}-word` : undefined}
>
{token}
</span>
);
});
return (
<span data-slot="response-text" className={cn(className)} {...props}>
<style href={PREFIX} precedence="dowel">
{STYLES}
</style>
{rendered}
</span>
);
}