shadimage
paceui-gsap
uiai-response-writersource: ok · refreshed

Response Writer

An auto-scrolling text display that smoothly keeps the latest response always visible

npx shadcn add "https://gsap.paceui.com/r/ai-response-writer.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · medium
animationgsap

Files2 · source cached 10/8/2026, 4:07:38 AM

components/gsap/ai-response-writer.tsx
"use client";

import { useEffect, useRef } from "react";

import { ScrollArea } from "@/components/ui/scroll-area";

type ResponseWriterProps = {
    text: string;
};

export const AiResponseWriter = ({ text, ...props }: ResponseWriterProps) => {
    const scrollAreaRef = useRef<HTMLDivElement>(null);

    useEffect(() => {
        if (scrollAreaRef.current) {
            const viewport = scrollAreaRef.current?.querySelector("div:first-child");
            if (viewport) {
                viewport.scrollTo({
                    top: viewport.scrollHeight,
                    behavior: "smooth",
                });
            }
        }
    }, [text]);

    return (
        <ScrollArea ref={scrollAreaRef} {...props}>
            <div className="pr-4">
                <p className="text-foreground/80 text-sm whitespace-pre-line">{text}</p>
            </div>
        </ScrollArea>
    );
};
hooks/gsap/use-writer.ts
"use client";

import { useEffect, useRef, useState } from "react";

type WriterMode = "character" | "word";

interface UseWriterOptions {
    speed?: number;
    reverseSpeed?: number;
    mode?: WriterMode;
    skipEmptyIntermediate?: boolean;
    onDone?: () => void;
}

export function useWriter(text: string, options: UseWriterOptions = {}) {
    const { speed = 10, reverseSpeed = 20, mode = "character", skipEmptyIntermediate = false, onDone } = options;

    const [displayText, setDisplayText] = useState("");
    const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

    const clearTimer = () => {
        if (timerRef.current) {
            clearTimeout(timerRef.current);
            timerRef.current = null;
        }
    };

    useEffect(() => {
        let isMounted = true;

        const getUnits = (str: string) => (mode === "word" ? str.split(" ") : str.split(""));

        const currentUnits = getUnits(displayText);
        const targetUnits = getUnits(text);

        let commonLength = 0;
        for (let i = 0; i < Math.min(currentUnits.length, targetUnits.length); i++) {
            if (currentUnits[i] === targetUnits[i]) {
                commonLength++;
            } else {
                break;
            }
        }

        const removeStep = () => {
            if (!isMounted) return;

            if (currentUnits.length > commonLength) {
                currentUnits.pop();

                if (skipEmptyIntermediate && currentUnits.length === 0) {
                    typeStep();
                    return;
                }

                setDisplayText(mode === "word" ? currentUnits.join(" ") : currentUnits.join(""));

                timerRef.current = setTimeout(removeStep, 1000 / reverseSpeed);
            } else {
                typeStep();
            }
        };

        const typeStep = () => {
            if (!isMounted) return;

            if (currentUnits.length < targetUnits.length) {
                currentUnits.push(targetUnits[currentUnits.length]);
                setDisplayText(mode === "word" ? currentUnits.join(" ") : currentUnits.join(""));
                timerRef.current = setTimeout(typeStep, 1000 / speed);
            } else {
                onDone?.();
            }
        };

        clearTimer();
        removeStep();

        return () => {
            isMounted = false;
            clearTimer();
        };
    }, [text, speed, reverseSpeed, mode, skipEmptyIntermediate]);

    return displayText;
}

More from paceui-gsap

Browse all components in this library →