Streaming Text
Reveals a model response as it streams in, fading each newly arrived word in on its own without replaying what's already on screen.
"use client";
import { useReducedMotion } from "motion/react";
import { useEffect, useState } from "react";
import { Button } from "@/components/motion/button";
import { MessageBubble } from "@/components/motion/message-bubble";
import { StreamingText } from "@/components/motion/streaming-text";
const RESPONSE =
"Sure, here's a quick summary: the build passed on the first try, two dependencies were flagged as outdated, and the deploy finished in just under three minutes.";
/** Characters per second. A continuous rate reads far smoother than stepping word by word. */
const SPEED = 60;
export function StreamingTextPreview() {
const reduce = useReducedMotion();
const [run, setRun] = useState(0);
const [count, setCount] = useState(reduce ? RESPONSE.length : 0);
const streaming = count < RESPONSE.length;
// run is never read in the body; bumping it on Replay is what restarts this effect.
// biome-ignore lint/correctness/useExhaustiveDependencies: run is intentional, see comment above.
useEffect(() => {
if (reduce) return;
setCount(0);
const startedAt = performance.now();
let frame: number;
const tick = (now: number) => {
const next = Math.min(RESPONSE.length, Math.floor(((now - startedAt) / 1000) * SPEED));
setCount(next);
if (next < RESPONSE.length) frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [run, reduce]);
return (
<div className="flex w-full max-w-sm flex-col items-center gap-3">
<MessageBubble align="start">
<StreamingText text={RESPONSE.slice(0, count)} streaming={streaming} />
</MessageBubble>
<Button variant="secondary" size="sm" onClick={() => setRun((r) => r + 1)}>
Replay
</Button>
</div>
);
}
"use client";
// easeui.dev/components/agents/streaming-text
import { motion, useReducedMotion } from "motion/react";
import { useEffect, useMemo, useRef } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface StreamingTextProps {
/** The text so far. Append to it as more arrives; characters already shown never replay. */
text: string;
/** Shows a blinking cursor at the end, for while more is still on its way. Default false. */
streaming?: boolean;
className?: string;
}
/** Splits into individual characters, so each one can fade in on its own as it arrives. */
function tokenize(text: string): string[] {
return Array.from(text);
}
/**
* Reveals text as it streams in. Feed it a growing string and each new
* character fades in fast enough to read as one smooth ribbon, not single
* letters popping in. Characters already shown never replay.
*/
export function StreamingText({ text, streaming = false, className }: StreamingTextProps) {
const reduce = useReducedMotion();
const tokens = useMemo(() => tokenize(text), [text]);
// Tokens shown as of the last render. A shorter text (a new message) counts as fully settled.
const shown = useRef(0);
const settled = Math.min(tokens.length, shown.current);
useEffect(() => {
shown.current = tokens.length;
}, [tokens.length]);
return (
<p className={cn("text-sm leading-6 text-foreground", className)}>
{tokens.map((token, index) => {
const isGlyph = /\S/.test(token);
if (!isGlyph || index < settled || reduce) {
// biome-ignore lint/suspicious/noArrayIndexKey: streaming only appends, so a token's index never changes once shown.
return <span key={index}>{token}</span>;
}
return (
<motion.span
// biome-ignore lint/suspicious/noArrayIndexKey: streaming only appends, so a token's index never changes once shown.
key={index}
initial={{ opacity: 0, y: 3 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.12, ease: EASE_OUT }}
>
{token}
</motion.span>
);
})}
{streaming ? (
<span
aria-hidden="true"
className="ml-0.5 inline-block h-3.5 w-0.5 translate-y-[2px] animate-pulse bg-foreground align-middle motion-reduce:animate-none"
/>
) : null}
</p>
);
}
Installation
$ bunx --bun shadcn add @easeui/streaming-text
- 1
Set up the theme tokens
Do this once per project. Follow the theme setup or skip it if you already ran shadcn init.
- 2
Install the dependencies
terminal npm install clsx motion tailwind-merge - 3
Add the source files
components/motion/streaming-text.tsx "use client"; // easeui.dev/components/agents/streaming-text import { motion, useReducedMotion } from "motion/react"; import { useEffect, useMemo, useRef } from "react"; import { EASE_OUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; export interface StreamingTextProps { /** The text so far. Append to it as more arrives; characters already shown never replay. */ text: string; /** Shows a blinking cursor at the end, for while more is still on its way. Default false. */ streaming?: boolean; className?: string; } /** Splits into individual characters, so each one can fade in on its own as it arrives. */ function tokenize(text: string): string[] { return Array.from(text); } /** * Reveals text as it streams in. Feed it a growing string and each new * character fades in fast enough to read as one smooth ribbon, not single * letters popping in. Characters already shown never replay. */ export function StreamingText({ text, streaming = false, className }: StreamingTextProps) { const reduce = useReducedMotion(); const tokens = useMemo(() => tokenize(text), [text]); // Tokens shown as of the last render. A shorter text (a new message) counts as fully settled. const shown = useRef(0); const settled = Math.min(tokens.length, shown.current); useEffect(() => { shown.current = tokens.length; }, [tokens.length]); return ( <p className={cn("text-sm leading-6 text-foreground", className)}> {tokens.map((token, index) => { const isGlyph = /\S/.test(token); if (!isGlyph || index < settled || reduce) { // biome-ignore lint/suspicious/noArrayIndexKey: streaming only appends, so a token's index never changes once shown. return <span key={index}>{token}</span>; } return ( <motion.span // biome-ignore lint/suspicious/noArrayIndexKey: streaming only appends, so a token's index never changes once shown. key={index} initial={{ opacity: 0, y: 3 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.12, ease: EASE_OUT }} > {token} </motion.span> ); })} {streaming ? ( <span aria-hidden="true" className="ml-0.5 inline-block h-3.5 w-0.5 translate-y-[2px] animate-pulse bg-foreground align-middle motion-reduce:animate-none" /> ) : null} </p> ); }lib/ease.ts // Shared motion tokens. Micro-interactions run 100 to 150ms, standard UI // 150 to 250ms, and panels up to 300ms. // Easing curves mirror the CSS custom properties in globals.css. /** ease-out-quint. Fast start that settles quickly. Entrances, exits, feedback. */ export const EASE_OUT = [0.23, 1, 0.32, 1] as const; /** ease-in-out-cubic. Elements already on screen moving to a new spot. */ export const EASE_IN_OUT = [0.645, 0.045, 0.355, 1] as const; /** Sheet and drawer glide. */ export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const; /** CSS string form of EASE_OUT for inline style transitions. */ export const EASE_OUT_CSS = "cubic-bezier(0.23, 1, 0.32, 1)"; // Springs are described by duration and bounce, which is easier to reason about // than stiffness and damping. Bounce stays at zero for product UI. /** Press feedback on buttons and other tappable surfaces. */ export const SPRING_PRESS = { type: "spring", duration: 0.15, bounce: 0, } as const; /** Content swaps, label and icon slots trading places inside a control. */ export const SPRING_SWAP = { type: "spring", duration: 0.2, bounce: 0, } as const; /** Overlay panel entrances, modals and sheets summoned by pointer. */ export const SPRING_PANEL = { type: "spring", duration: 0.25, bounce: 0, } as const; /** Shared-layout glides, pills and indicators moving between positions. */ export const SPRING_LAYOUT = { type: "spring", duration: 0.22, bounce: 0, } as const; /** Cursor-follow physics for decorative mouse tracking (magnetic, tilt). */ export const SPRING_MOUSE = { stiffness: 320, damping: 26, mass: 0.3, } as const; /** Dragged handles and fills (sliders). Critically damped `useSpring` config, * so the value follows the pointer closely and never rebounds off an end. */ export const SPRING_GLIDE = { stiffness: 700, damping: 50, mass: 0.5, } as const;lib/utils.ts import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) }components/motion/button.tsx "use client"; import { type HTMLMotionProps, motion, useReducedMotion } from "motion/react"; import { forwardRef, type ReactNode } from "react"; import { cn } from "@/lib/utils"; export type ButtonVariant = "primary" | "secondary" | "outline" | "ghost"; export type ButtonSize = "sm" | "md" | "lg" | "icon"; export interface ButtonProps extends Omit<HTMLMotionProps<"button">, "children"> { /** Visual style. Default "primary". */ variant?: ButtonVariant; /** Height and padding. "icon" is a square button for a single icon. Default "md". */ size?: ButtonSize; children?: ReactNode; } // A quick press confirms the tap before the action finishes. const PRESS = { scale: 0.97 }; const NO_PRESS = { scale: 1 }; const PRESS_TRANSITION = { duration: 0.15, ease: [0.23, 1, 0.32, 1] } as const; // Hairline rings are drawn with box-shadow so they blend with any background. const VARIANT_CLASS: Record<ButtonVariant, string> = { primary: "bg-foreground text-background hover:bg-foreground/90", secondary: "bg-card text-foreground shadow-[0_0_0_1px_var(--border)] hover:bg-muted", outline: "text-foreground shadow-[0_0_0_1px_var(--border-strong)] hover:bg-foreground/5", ghost: "text-muted-foreground hover:bg-foreground/5 hover:text-foreground", }; // Small sizes grow an invisible hit area so the tap target stays around 44px. const SIZE_CLASS: Record<ButtonSize, string> = { sm: "h-8 gap-1.5 px-3 text-xs after:absolute after:-inset-1.5", md: "h-10 gap-2 px-4 text-sm", lg: "h-12 gap-2 px-5 text-base", icon: "h-9 w-9 after:absolute after:-inset-1", }; export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button( { variant = "primary", size = "md", type = "button", className, children, ...props }, ref, ) { const reduce = useReducedMotion(); return ( <motion.button ref={ref} type={type} // Always pass a gesture so the server and client render the same attributes. whileTap={reduce ? NO_PRESS : PRESS} transition={PRESS_TRANSITION} className={cn( "group relative inline-flex shrink-0 touch-manipulation select-none items-center justify-center rounded-full font-medium outline-none will-change-transform", "transition-[background-color,color,box-shadow] duration-150 ease-out", "focus-visible:ring-2 focus-visible:ring-foreground/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background", "disabled:pointer-events-none disabled:opacity-50", VARIANT_CLASS[variant], SIZE_CLASS[size], className, )} {...props} > {children} </motion.button> ); });components/motion/message-bubble.tsx "use client"; import { motion, useReducedMotion } from "motion/react"; import type { ReactNode } from "react"; import { cn } from "@/lib/utils"; export type MessageBubbleTone = "neutral" | "accent"; export type MessageBubbleAlign = "start" | "end"; export interface MessageBubbleProps { /** Which side the bubble sits on, matching who sent it. Default "start". */ align?: MessageBubbleAlign; /** Visual treatment. Default "neutral". */ tone?: MessageBubbleTone; children: ReactNode; className?: string; } const TONE_CLASS: Record<MessageBubbleTone, string> = { neutral: "bg-card text-foreground shadow-[0_0_0_1px_var(--border)]", accent: "bg-accent text-accent-foreground", }; const EASE = [0.23, 1, 0.32, 1] as const; /** A chat message surface with a speech-bubble tail that pops in from the side it belongs to. */ export function MessageBubble({ align = "start", tone = "neutral", children, className, }: MessageBubbleProps) { const reduce = useReducedMotion(); const fromX = align === "end" ? 10 : -10; return ( <motion.div initial={reduce ? { opacity: 0 } : { opacity: 0, x: fromX, scale: 0.97 }} animate={{ opacity: 1, x: 0, scale: 1 }} transition={{ duration: reduce ? 0.1 : 0.2, ease: EASE }} className={cn( "max-w-[85%] text-pretty rounded-2xl px-3.5 py-2.5 text-sm leading-6", align === "end" ? "ml-auto rounded-br-sm" : "mr-auto rounded-bl-sm", TONE_CLASS[tone], className, )} > {children} </motion.div> ); }
API reference
| Prop | Type | Default | Description |
|---|---|---|---|
| text | string | - | The text so far. Append to it as more arrives; characters already shown never replay. |
| streaming? | boolean | false | Shows a blinking cursor at the end, for while more is still on its way. Default false. |
| className? | string | - | - |
Updated