Streaming Response
Wraps a response with the actions people expect once it settles: copy, replay, share, a thumbs up or down, and a list of suggested follow-up prompts. No card or border, so the answer reads as part of the page.
"use client";
import { useReducedMotion } from "motion/react";
import { useEffect, useState } from "react";
import { StreamingResponse } from "@/components/motion/streaming-response";
import { StreamingText } from "@/components/motion/streaming-text";
const RESPONSE =
"The migration ran clean end to end: three tables backfilled, no locks held longer than a second, and the old columns are now safe to drop.";
const FOLLOW_UPS = ["Which tables were backfilled?", "Show me the rollback plan"];
/** The first run cuts off partway through, so Replay has something to fix. */
const FAILS_ON_ATTEMPT = 0;
/** Characters per second. A continuous rate reads far smoother than stepping word by word. */
const SPEED = 60;
export function StreamingResponsePreview() {
const reduce = useReducedMotion();
const [attempt, setAttempt] = useState(0);
const [count, setCount] = useState(reduce ? RESPONSE.length : 0);
const [shared, setShared] = useState(false);
const failed = attempt === FAILS_ON_ATTEMPT;
const target = failed ? Math.ceil(RESPONSE.length / 2) : RESPONSE.length;
const streaming = count < target;
const status = streaming ? "streaming" : failed ? "error" : "complete";
// attempt is never read in the body; bumping it on Replay is what restarts this effect.
// biome-ignore lint/correctness/useExhaustiveDependencies: attempt 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(target, Math.floor(((now - startedAt) / 1000) * SPEED));
setCount(next);
if (next < target) frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [attempt, target, reduce]);
return (
<div className="flex w-full max-w-sm flex-col gap-2">
<StreamingResponse
status={status}
copyText={RESPONSE}
onRetry={() => setAttempt((a) => a + 1)}
onShare={() => {
setShared(true);
window.setTimeout(() => setShared(false), 1500);
}}
showFeedback
followUps={status === "complete" ? FOLLOW_UPS : undefined}
>
<StreamingText text={RESPONSE.slice(0, count)} streaming={streaming} />
{status === "error" ? (
<p className="mt-2 text-xs text-destructive">The connection dropped partway through.</p>
) : null}
</StreamingResponse>
{shared ? <p className="text-xs text-muted-foreground">Link copied.</p> : null}
</div>
);
}
"use client";
// easeui.dev/components/agents/streaming-response
import { ArrowUpRight, RotateCcw, Share2, ThumbsDown, ThumbsUp } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import { type ReactNode, useState } from "react";
import { CopyButton } from "@/components/motion/copy-button";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type StreamingResponseStatus = "streaming" | "complete" | "error";
export type StreamingResponseFeedback = "up" | "down" | null;
export interface StreamingResponseProps {
/** The response itself, such as a StreamingText or rendered markdown. Left unstyled: no card, no border. */
children: ReactNode;
/** Default "streaming". The action row fades in once this leaves "streaming". */
status?: StreamingResponseStatus;
/** Text the copy action writes to the clipboard. Omit to hide that action. */
copyText?: string;
/** Shows a replay action. Omit to hide it. */
onRetry?: () => void;
/** Shows a share action. Omit to hide it. */
onShare?: () => void;
/** Shows a thumbs up / down toggle. Default false. */
showFeedback?: boolean;
/** Controlled feedback value. */
feedback?: StreamingResponseFeedback;
/** Starting feedback when uncontrolled. Default null. */
defaultFeedback?: StreamingResponseFeedback;
onFeedbackChange?: (feedback: StreamingResponseFeedback) => void;
/** Suggested next prompts, listed below the actions once settled. Omit to hide the list. */
followUps?: string[];
onFollowUp?: (text: string, index: number) => void;
className?: string;
}
const ICON_BUTTON = cn(
"relative inline-flex h-8 w-8 shrink-0 touch-manipulation items-center justify-center rounded-full text-muted-foreground outline-none after:absolute after:-inset-1.5",
"transition-colors duration-150 ease-out hover:bg-muted hover:text-foreground",
"focus-visible:ring-2 focus-visible:ring-foreground/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background",
);
/**
* Wraps a response with the actions people expect once it settles: copy,
* replay, share, a thumbs up or down. No card or border, so the answer
* reads as part of the page. Each action is opt-in: pass a handler to show it.
*/
export function StreamingResponse({
children,
status = "streaming",
copyText,
onRetry,
onShare,
showFeedback = false,
feedback,
defaultFeedback = null,
onFeedbackChange,
followUps,
onFollowUp,
className,
}: StreamingResponseProps) {
const reduce = useReducedMotion();
const [uncontrolled, setUncontrolled] = useState(defaultFeedback);
const isControlled = feedback !== undefined;
const current = isControlled ? feedback : uncontrolled;
const setFeedback = (next: StreamingResponseFeedback) => {
if (!isControlled) setUncontrolled(next);
onFeedbackChange?.(next);
};
const settled = status !== "streaming";
const hasActions =
settled && (copyText !== undefined || Boolean(onRetry) || Boolean(onShare) || showFeedback);
return (
<div className={cn("flex flex-col gap-2", className)}>
<div aria-busy={!settled}>{children}</div>
{hasActions ? (
<motion.div
initial={reduce ? false : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, ease: EASE_OUT }}
className="-ml-2 flex items-center gap-0.5"
>
{copyText !== undefined ? (
<CopyButton
value={copyText}
className={cn(ICON_BUTTON, "bg-transparent shadow-none hover:bg-muted")}
/>
) : null}
{onRetry ? (
<button type="button" aria-label="Replay" onClick={onRetry} className={ICON_BUTTON}>
<RotateCcw aria-hidden="true" className="h-4 w-4" />
</button>
) : null}
{onShare ? (
<button type="button" aria-label="Share" onClick={onShare} className={ICON_BUTTON}>
<Share2 aria-hidden="true" className="h-4 w-4" />
</button>
) : null}
{showFeedback ? (
<>
<button
type="button"
aria-label="Good response"
aria-pressed={current === "up"}
onClick={() => setFeedback(current === "up" ? null : "up")}
className={cn(ICON_BUTTON, current === "up" && "bg-muted text-success")}
>
<ThumbsUp aria-hidden="true" className="h-4 w-4" />
</button>
<button
type="button"
aria-label="Bad response"
aria-pressed={current === "down"}
onClick={() => setFeedback(current === "down" ? null : "down")}
className={cn(ICON_BUTTON, current === "down" && "bg-muted text-destructive")}
>
<ThumbsDown aria-hidden="true" className="h-4 w-4" />
</button>
</>
) : null}
</motion.div>
) : null}
{settled && followUps?.length ? (
<div className="flex flex-col">
{followUps.map((text, index) => (
<motion.button
// biome-ignore lint/suspicious/noArrayIndexKey: the list is static once passed in and never reorders.
key={index}
type="button"
onClick={() => onFollowUp?.(text, index)}
initial={reduce ? false : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, ease: EASE_OUT, delay: reduce ? 0 : index * 0.05 }}
className="group flex items-center gap-2 border-b border-border py-2 text-left text-sm text-foreground transition-colors duration-150 last:border-b-0 hover:text-accent"
>
<span className="flex-1">{text}</span>
<ArrowUpRight
aria-hidden="true"
className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform duration-150 group-hover:-translate-y-0.5 group-hover:translate-x-0.5 group-hover:text-accent motion-reduce:transition-none"
/>
</motion.button>
))}
</div>
) : null}
</div>
);
}
Installation
$ bunx --bun shadcn add @easeui/streaming-response
- 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 lucide-react motion tailwind-merge - 3
Add the source files
components/motion/streaming-response.tsx "use client"; // easeui.dev/components/agents/streaming-response import { ArrowUpRight, RotateCcw, Share2, ThumbsDown, ThumbsUp } from "lucide-react"; import { motion, useReducedMotion } from "motion/react"; import { type ReactNode, useState } from "react"; import { CopyButton } from "@/components/motion/copy-button"; import { EASE_OUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; export type StreamingResponseStatus = "streaming" | "complete" | "error"; export type StreamingResponseFeedback = "up" | "down" | null; export interface StreamingResponseProps { /** The response itself, such as a StreamingText or rendered markdown. Left unstyled: no card, no border. */ children: ReactNode; /** Default "streaming". The action row fades in once this leaves "streaming". */ status?: StreamingResponseStatus; /** Text the copy action writes to the clipboard. Omit to hide that action. */ copyText?: string; /** Shows a replay action. Omit to hide it. */ onRetry?: () => void; /** Shows a share action. Omit to hide it. */ onShare?: () => void; /** Shows a thumbs up / down toggle. Default false. */ showFeedback?: boolean; /** Controlled feedback value. */ feedback?: StreamingResponseFeedback; /** Starting feedback when uncontrolled. Default null. */ defaultFeedback?: StreamingResponseFeedback; onFeedbackChange?: (feedback: StreamingResponseFeedback) => void; /** Suggested next prompts, listed below the actions once settled. Omit to hide the list. */ followUps?: string[]; onFollowUp?: (text: string, index: number) => void; className?: string; } const ICON_BUTTON = cn( "relative inline-flex h-8 w-8 shrink-0 touch-manipulation items-center justify-center rounded-full text-muted-foreground outline-none after:absolute after:-inset-1.5", "transition-colors duration-150 ease-out hover:bg-muted hover:text-foreground", "focus-visible:ring-2 focus-visible:ring-foreground/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background", ); /** * Wraps a response with the actions people expect once it settles: copy, * replay, share, a thumbs up or down. No card or border, so the answer * reads as part of the page. Each action is opt-in: pass a handler to show it. */ export function StreamingResponse({ children, status = "streaming", copyText, onRetry, onShare, showFeedback = false, feedback, defaultFeedback = null, onFeedbackChange, followUps, onFollowUp, className, }: StreamingResponseProps) { const reduce = useReducedMotion(); const [uncontrolled, setUncontrolled] = useState(defaultFeedback); const isControlled = feedback !== undefined; const current = isControlled ? feedback : uncontrolled; const setFeedback = (next: StreamingResponseFeedback) => { if (!isControlled) setUncontrolled(next); onFeedbackChange?.(next); }; const settled = status !== "streaming"; const hasActions = settled && (copyText !== undefined || Boolean(onRetry) || Boolean(onShare) || showFeedback); return ( <div className={cn("flex flex-col gap-2", className)}> <div aria-busy={!settled}>{children}</div> {hasActions ? ( <motion.div initial={reduce ? false : { opacity: 0, y: 4 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.2, ease: EASE_OUT }} className="-ml-2 flex items-center gap-0.5" > {copyText !== undefined ? ( <CopyButton value={copyText} className={cn(ICON_BUTTON, "bg-transparent shadow-none hover:bg-muted")} /> ) : null} {onRetry ? ( <button type="button" aria-label="Replay" onClick={onRetry} className={ICON_BUTTON}> <RotateCcw aria-hidden="true" className="h-4 w-4" /> </button> ) : null} {onShare ? ( <button type="button" aria-label="Share" onClick={onShare} className={ICON_BUTTON}> <Share2 aria-hidden="true" className="h-4 w-4" /> </button> ) : null} {showFeedback ? ( <> <button type="button" aria-label="Good response" aria-pressed={current === "up"} onClick={() => setFeedback(current === "up" ? null : "up")} className={cn(ICON_BUTTON, current === "up" && "bg-muted text-success")} > <ThumbsUp aria-hidden="true" className="h-4 w-4" /> </button> <button type="button" aria-label="Bad response" aria-pressed={current === "down"} onClick={() => setFeedback(current === "down" ? null : "down")} className={cn(ICON_BUTTON, current === "down" && "bg-muted text-destructive")} > <ThumbsDown aria-hidden="true" className="h-4 w-4" /> </button> </> ) : null} </motion.div> ) : null} {settled && followUps?.length ? ( <div className="flex flex-col"> {followUps.map((text, index) => ( <motion.button // biome-ignore lint/suspicious/noArrayIndexKey: the list is static once passed in and never reorders. key={index} type="button" onClick={() => onFollowUp?.(text, index)} initial={reduce ? false : { opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.2, ease: EASE_OUT, delay: reduce ? 0 : index * 0.05 }} className="group flex items-center gap-2 border-b border-border py-2 text-left text-sm text-foreground transition-colors duration-150 last:border-b-0 hover:text-accent" > <span className="flex-1">{text}</span> <ArrowUpRight aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform duration-150 group-hover:-translate-y-0.5 group-hover:translate-x-0.5 group-hover:text-accent motion-reduce:transition-none" /> </motion.button> ))} </div> ) : null} </div> ); }components/motion/copy-button.tsx "use client"; import { Check, Copy } from "lucide-react"; import { type ButtonHTMLAttributes, forwardRef, useEffect, useRef, useState } from "react"; import { cn } from "@/lib/utils"; type CopyState = "idle" | "copied" | "failed"; export interface CopyButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children" | "value" | "onCopy"> { /** Text written to the clipboard. */ value: string; /** Visible text. Leave it out for an icon only button. */ label?: string; /** Visible text after copying. Default "Copied". */ copiedLabel?: string; /** How long the copied state stays, in ms. Default 1500. */ timeout?: number; /** Called after the text reaches the clipboard. */ onCopied?: (value: string) => void; } // Both icons, and both labels, share a grid cell and trade places with a fade and a small // scale. Sharing the cell keeps the button the same width in either state. const SWAP = "col-start-1 row-start-1 transition-[opacity,scale] duration-200 ease-out motion-reduce:transition-none"; const SHOWN = "scale-100 opacity-100"; const ICON_HIDDEN = "scale-50 opacity-0"; const TEXT_HIDDEN = "scale-[0.97] opacity-0"; export const CopyButton = forwardRef<HTMLButtonElement, CopyButtonProps>(function CopyButton( { value, label, copiedLabel = "Copied", timeout = 1500, onCopied, className, onClick, ...props }, ref, ) { const [state, setState] = useState<CopyState>("idle"); const timer = useRef<ReturnType<typeof setTimeout> | null>(null); useEffect( () => () => { if (timer.current) clearTimeout(timer.current); }, [], ); const copy = async () => { try { await navigator.clipboard.writeText(value); setState("copied"); onCopied?.(value); } catch { // Clipboard access can be blocked, for example in an insecure context. setState("failed"); } if (timer.current) clearTimeout(timer.current); timer.current = setTimeout(() => setState("idle"), timeout); }; const copied = state === "copied"; const status = copied ? "Copied to clipboard" : state === "failed" ? "Could not copy" : ""; return ( <button ref={ref} type="button" aria-label={label ?? "Copy"} data-state={state} onClick={(event) => { onClick?.(event); if (!event.defaultPrevented) void copy(); }} className={cn( "relative inline-flex h-9 shrink-0 touch-manipulation select-none items-center justify-center gap-2 rounded-full bg-card text-sm font-medium text-foreground outline-none", "shadow-[0_0_0_1px_var(--border)] transition-[background-color,scale] duration-150 ease-out hover:bg-muted active:scale-[0.97] motion-reduce:active:scale-100", "focus-visible:ring-2 focus-visible:ring-foreground/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background", label ? "px-3.5" : "w-9 after:absolute after:-inset-1", className, )} {...props} > <span aria-hidden="true" className="grid place-items-center"> <Copy className={cn(SWAP, "h-4 w-4", copied ? ICON_HIDDEN : SHOWN)} /> <Check className={cn(SWAP, "h-4 w-4", copied ? SHOWN : ICON_HIDDEN)} /> </span> {label ? ( <span aria-hidden="true" className="grid whitespace-nowrap"> <span className={cn(SWAP, copied ? TEXT_HIDDEN : SHOWN)}>{label}</span> <span className={cn(SWAP, copied ? SHOWN : TEXT_HIDDEN)}>{copiedLabel}</span> </span> ) : null} <span aria-live="polite" className="sr-only"> {status} </span> </button> ); });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/streaming-text.tsx "use client"; 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> ); }
API reference
| Prop | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | - | The response itself, such as a StreamingText or rendered markdown. Left unstyled: no card, no border. |
| status? | "streaming" | "complete" | "error" | streaming | Default "streaming". The action row fades in once this leaves "streaming". |
| copyText? | string | - | Text the copy action writes to the clipboard. Omit to hide that action. |
| onRetry? | (() => void) | - | Shows a replay action. Omit to hide it. |
| onShare? | (() => void) | - | Shows a share action. Omit to hide it. |
| showFeedback? | boolean | false | Shows a thumbs up / down toggle. Default false. |
| feedback? | StreamingResponseFeedback | - | Controlled feedback value. |
| defaultFeedback? | StreamingResponseFeedback | null | Starting feedback when uncontrolled. Default null. |
| onFeedbackChange? | ((feedback: StreamingResponseFeedback) => void) | - | - |
| followUps? | {} | - | Suggested next prompts, listed below the actions once settled. Omit to hide the list. |
| onFollowUp? | ((text: string, index: number) => void) | - | - |
| className? | string | - | - |
Updated