AI Response

Assistant response text, with a streaming caret and a thinking indicator.

Streaming works one token at a time. The caret marks where the text stops, and nothing is announced as it arrives — a live region firing on every token is unusable with a screen reader.

Installation

Terminal
pnpm dlx @dowel-ui/cli add ai-response

Installs 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

PropTypeDefault
streaming

Shows the caret. Purely visual — it carries no announcement.

boolean

Plus every attribute of <div>.

ResponseCaret

Plus every attribute of <span>.

ThinkingIndicator

PropTypeDefault
labelstring"Thinking"

Plus every attribute of <div>.

Quality

8/8 checks, measured from the source and its tests

  • Testedpasses
  • axe assertionpasses
  • Keyboard testeddoes not apply
  • Storybook examplespasses
  • Accessibility documentedpasses
  • Semantic tokens onlypasses
  • Motion from tokenspasses
  • className mergedpasses
  • Visible focusdoes not apply
  • No fixed widthspasses

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.

ui/ai-response.tsx
"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>
  );
}
ui/response-text.tsx
"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>
  );
}