text-streaming.tsx
styles.css
prompt.md
Collapse
Reset
Copy Code
Download
Run
1
"use client";
2
3
import React, { useState, useEffect, useRef } from "react";
4
import { Copy, RotateCcw } from "lucide-react";
5
import { cn } from "@/lib/utils";
6
7
const GithubIcon = ({ className }: { className?: string }) => (
8
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
9
<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"></path>
10
<path d="M9 18c-4.51 2-5-2-7-2"></path>
11
</svg>
12
);
13
14
export interface TextStreamingProps {
15
className?: string;
16
}
17
18
type Item =
19
| { type: 'h2', text: string }
20
| { type: 'p', text: string, pill?: string }
21
| { type: 'bullet', text: string }
22
| { type: 'terminal', text: string };
23
24
const data: Item[] = [
25
{ type: 'h2', text: 'Why a typo runs a train' },
26
{ type: 'p', text: 'Miss the keys on ls and sl sends a steam locomotive across the terminal instead, at its own unhurried pace.', pill: 'GitHub +1' },
27
{ type: 'bullet', text: 'Slow on purpose: the train takes its time, and Ctrl-C will not stop it.' },
28
{ type: 'bullet', text: 'It has options: -l for a smaller one, -F to make it fly, -a for passengers.' },
29
{ type: 'bullet', text: 'Filed under games: Debian and Homebrew both ship it, which is how it spread.' },
30
{ type: 'terminal', text: 'brew install sl && sl -Fal' }
31
];
32
33
let currentIndex = 0;
34
const parsedData = data.map(item => {
35
const start = currentIndex;
36
currentIndex += item.text.length + 20; // 20 chars artificial pause between blocks
37
return { ...item, startIndex: start, length: item.text.length };
38
});
39
const maxProgress = currentIndex + 10;
40
41
const StreamNode = ({ text, globalProgress, startIndex, hideGradient }: { text: string, globalProgress: number, startIndex: number, hideGradient?: boolean }) => {
42
if (globalProgress < startIndex) return null;
43
44
const revealedChars = Math.min(text.length, globalProgress - startIndex);
45
const localGradientStart = hideGradient ? revealedChars : Math.max(0, globalProgress - 50 - startIndex);
46
47
const normalText = text.slice(0, localGradientStart);
48
const gradientText = text.slice(localGradientStart, revealedChars);
49
50
return (
51
<>
52
<span className="text-zinc-800 dark:text-zinc-200">{normalText}</span>
53
{gradientText && (
54
<span className="text-transparent bg-clip-text bg-gradient-to-r from-pink-400 to-indigo-400 dark:from-pink-400 dark:to-indigo-400">
55
{gradientText}
56
</span>
57
)}
58
</>
59
);
60
};
61
62
export function TextStreaming({ className }: TextStreamingProps) {
63
const [progress, setProgress] = useState(0);
64
const [isPlaying, setIsPlaying] = useState(true);
65
const rafRef = useRef<number>(0);
66
67
const play = () => {
68
setIsPlaying(true);
69
setProgress(0);
70
const start = Date.now();
71
72
const animate = () => {
73
const now = Date.now();
74
const elapsed = now - start;
75
const charsPerSecond = 120; // Typewriter speed
76
const newProgress = Math.floor((elapsed / 1000) * charsPerSecond);
77
78
if (newProgress >= maxProgress) {
79
setProgress(maxProgress);
80
setIsPlaying(false);
81
} else {
82
setProgress(newProgress);
83
rafRef.current = requestAnimationFrame(animate);
84
}
85
};
86
rafRef.current = requestAnimationFrame(animate);
87
};
88
89
useEffect(() => {
90
play();
91
return () => {
92
if (rafRef.current) cancelAnimationFrame(rafRef.current);
93
};
94
}, []);
95
96
return (
97
<div className={cn("w-full max-w-[560px]", className)}>
98
99
{parsedData.map((item, i) => {
100
if (progress < item.startIndex) return null;
101
102
if (item.type === 'h2') {
103
return (
104
<h2 key={i} className="text-xl font-bold tracking-tight mb-4 text-zinc-900 dark:text-white">
105
<StreamNode text={item.text} globalProgress={progress} startIndex={item.startIndex} />
106
</h2>
107
);
108
}
109
110
if (item.type === 'p') {
111
return (
112
<p key={i} className="text-[15px] leading-relaxed mb-6 text-zinc-800 dark:text-zinc-200">
113
<StreamNode text={item.text} globalProgress={progress} startIndex={item.startIndex} />
114
{item.pill && progress >= item.startIndex + item.length && (
115
<span className="inline-flex items-center gap-1.5 rounded-full bg-zinc-100 dark:bg-white/[0.08] px-2 py-[3px] text-[11px] font-medium ml-2 align-baseline border border-zinc-200 dark:border-white/10 text-zinc-600 dark:text-zinc-400 opacity-0 animate-in fade-in zoom-in-95 duration-300">
116
<GithubIcon className="w-3.5 h-3.5"/>
117
{item.pill}
118
</span>
119
)}
120
</p>
121
);
122
}
123
124
if (item.type === 'bullet') {
125
return (
126
<div key={i} className="flex gap-3 mb-2.5 text-[15px] leading-relaxed text-zinc-800 dark:text-zinc-200">
127
<span className="text-zinc-400 dark:text-zinc-600 select-none flex-shrink-0 mt-[1px]">•</span>
128
<span>
129
<StreamNode text={item.text} globalProgress={progress} startIndex={item.startIndex} />
130
</span>
131
</div>
132
);
133
}
134
135
if (item.type === 'terminal') {
136
return (
137
<div key={i} className="mt-6 rounded-xl border border-zinc-200 dark:border-white/10 overflow-hidden bg-zinc-50 dark:bg-[#111111]">
138
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-200 dark:border-white/10 bg-white/50 dark:bg-transparent">
139
<div className="flex items-center gap-2.5 text-xs font-mono text-zinc-500 dark:text-zinc-400">
140
<span className="font-bold text-zinc-400 dark:text-zinc-500">>_</span>
141
<span>Terminal</span>
142
</div>
143
<Copy className="w-3.5 h-3.5 text-zinc-400 dark:text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 transition-colors cursor-pointer" />
144
</div>
145
<div className="px-4 py-4 font-mono text-[13px] text-zinc-800 dark:text-zinc-200">
146
<StreamNode text={item.text} globalProgress={progress} startIndex={item.startIndex} hideGradient />
147
</div>
148
</div>
149
);
150
}
151
152
return null;
153
})}
154
155
<div className={cn("mt-6 flex items-center transition-all duration-500", !isPlaying && progress >= maxProgress ? "opacity-100 translate-y-0" : "opacity-0 translate-y-2 pointer-events-none")}>
156
<button
157
onClick={play}
158
className="flex items-center gap-2 text-[13px] font-medium text-zinc-500 hover:text-zinc-800 dark:hover:text-zinc-300 transition-colors"
159
>
160
<RotateCcw className="w-3.5 h-3.5" />
161
Replay
162
</button>
163
</div>
164
165
</div>
166
);
167
}
168

