StreamingCaret

A blinking caret marking the message an assistant is still writing.

import { StreamingCaret } from '@elirobinson/react/components/ai/StreamingCaret';

Styles: @elirobinson/react/styles/ai/StreamingCaret.css — already included when you import @elirobinson/react/styles.css.

Assistant11:12 AM
Here's the short version: the renewal is cheaper than the two repairs you'd expect without it
Show code
import { useState } from 'react';

import { ChatMessage } from '@elirobinson/react/components/ai/ChatMessage';
import { StreamingCaret } from '@elirobinson/react/components/ai/StreamingCaret';
import { Button } from '@elirobinson/react/components/atoms/Button';

export default function Basic() {
  const [streaming, setStreaming] = useState(true);

  return (
    <div className="demo-col">
      <ChatMessage avatar="M" name="Assistant" timestamp="11:12 AM">
        Here's the short version: the renewal is cheaper than the two repairs you'd expect without
        it
        <StreamingCaret active={streaming} label="Still writing" />
      </ChatMessage>
      <Button variant="secondary" size="sm" onClick={() => setStreaming(!streaming)}>
        {streaming ? 'Finish the turn' : 'Start writing again'}
      </Button>
    </div>
  );
}

When to use it

StreamingCaret is the blinking mark at the end of a message an assistant is still writing. Put it inline, after the last of the text, inside the turn that's growing. It is a state graphic and nothing else: it holds no timer, watches no stream, and knows nothing about your transport.

It cannot outlive the stream

When active is false the component returns null — no element, no wrapper, nothing in the DOM. That's the whole design. A caret that could be left mounted on a finished message is a caret that eventually is, and a finished answer that still looks like it's writing is worse than no affordance at all. Because the falsy case renders nothing, the only way to show a stale caret is to keep telling the component the stream is live.

Props

PropTypeDefaultDescription
activebooleantrueDefault true. False renders nothing at all, so it cannot outlive the stream.
labelstringAccessible name. Present promotes the caret to role="status"; absent hides it.

Also accepts all HTMLAttributes<HTMLSpanElement> props.

Accessibility

  • Pass label and the caret becomes role="status" with that accessible name — a polite announcement that the assistant is still writing, which is the right call when the content it precedes takes a while to arrive.
  • Omit label and the caret is aria-hidden. That's the right call when the turn already announces its own progress through the thread's live region, and a second announcement would just be noise.
  • Under prefers-reduced-motion: reduce the blink stops and the caret goes solid — never invisible. Removing the animation must not remove the state, because the caret is the only signal that a message is still being written.
  • The fill is --accent-press rather than --accent: as a non-text state graphic it owes 3:1 against its surface under SC 1.4.11, and --accent measures 2.53:1.
  • Keyboard: none — it renders a single non-interactive <span>.
  • The ref forwards to that <span>, when one exists.

Do

  • Drive active from the same state that decides whether the stream is still open.
  • Pass label when the caret is the only sign that a slow answer is coming.
  • Place it inline, immediately after the last rendered text of the turn.
  • Let the reduced-motion rule stand — a solid caret still says "writing."

Don't

  • Leave it mounted on a settled message and rely on CSS to hide it.
  • Give every caret on a busy thread a label; the announcements stack up.
  • Restyle it to a lighter accent — it would drop under the 3:1 graphics threshold.
  • Use it as a generic loading spinner; that is Spinner, or Skeleton.

Product theming

The caret fill reads --product-signal with a fallback to the system's --accent-press, so a product can substitute its own hue for the same job. The convention — one level of fallback, always landing on a system token — is documented in docs/agents/product-token-layer.md, and the fallback is what keeps the layer optional.