shadimage
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 · medium
iconslucide-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))
}

More from easeui

Browse all components in this library →