easeui
componentstreaming-responsesource: ok · hit
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.
npx shadcn add "https://easeui.dev/r/streaming-response.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumiconslucide-react
animationmotion
Files4 · source cached 10/8/2026, 6:51:08 PM
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))
}