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 · mediumanimationgsap
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;
}