Skip to content

Commit 52c0983

Browse files
amide-initclaude
andcommitted
feat: add typing animation to hacker template hero section
Commands type out character by character (48ms/char), output appears after a short pause, then the next command starts — mimicking a real terminal session. Ends with a blinking idle cursor once all blocks are rendered. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 917ca3e commit 52c0983

1 file changed

Lines changed: 194 additions & 76 deletions

File tree

src/templates/hacker/HeroSection.tsx

Lines changed: 194 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { useState, useEffect, useRef, type ReactNode } from 'react'
2+
13
type Hero = {
24
eyebrow: string
35
title: string
@@ -20,38 +22,198 @@ type Hero = {
2022
type Snapshot = { title: string; items: string[]; subtitle?: string | null }
2123
type HeroSectionProps = { hero: Hero; snapshot: Snapshot; theme: 'dark' | 'light' }
2224

23-
function Prompt({ cmd }: { cmd: string }) {
25+
type Block = { cmd: string; output: ReactNode }
26+
27+
const TYPING_SPEED = 48 // ms per character
28+
const OUTPUT_DELAY = 220 // ms after cmd finishes before output appears
29+
const NEXT_DELAY = 550 // ms after output before next cmd starts
30+
31+
function PromptLine({ cmd, partial }: { cmd: string; partial?: boolean }) {
2432
return (
2533
<p className="leading-6">
2634
<span className="select-none text-green-700">~/profile </span>
2735
<span className="select-none text-green-500"></span>
2836
<span className="text-green-300">{cmd}</span>
37+
{partial && (
38+
<span className="ml-px inline-block h-[14px] w-2 translate-y-[2px] animate-pulse bg-green-400" />
39+
)}
2940
</p>
3041
)
3142
}
3243

33-
function Output({ children }: { children: React.ReactNode }) {
44+
function Output({ children }: { children: ReactNode }) {
3445
return <div className="pl-4 text-green-500">{children}</div>
3546
}
3647

3748
export default function HeroSection({ hero, snapshot }: HeroSectionProps) {
3849
const desc = hero.minorInfo?.trim() || hero.description
39-
const websiteRaw = hero.contact?.website ?? null
40-
let websiteDisplay = websiteRaw
41-
if (websiteRaw) {
50+
51+
let websiteDisplay = hero.contact?.website ?? null
52+
if (websiteDisplay) {
4253
try {
4354
websiteDisplay = new URL(
44-
websiteRaw.startsWith('http') ? websiteRaw : `https://${websiteRaw}`,
55+
websiteDisplay.startsWith('http') ? websiteDisplay : `https://${websiteDisplay}`,
4556
).hostname
4657
} catch {
47-
websiteDisplay = websiteRaw
58+
/* keep raw */
59+
}
60+
}
61+
62+
// Build blocks once — data is static after mount
63+
const blocksRef = useRef<Block[] | null>(null)
64+
if (!blocksRef.current) {
65+
const b: Block[] = []
66+
67+
b.push({
68+
cmd: 'whoami',
69+
output: (
70+
<Output>
71+
<p id="hero-title" className="text-lg font-bold text-green-200">{hero.title}</p>
72+
{hero.eyebrow && <p className="mt-0.5 text-xs text-green-700">{hero.eyebrow}</p>}
73+
</Output>
74+
),
75+
})
76+
77+
if (desc) {
78+
b.push({
79+
cmd: 'cat README.md',
80+
output: (
81+
<Output>
82+
<p className="max-w-xl leading-relaxed text-green-500">{desc}</p>
83+
</Output>
84+
),
85+
})
86+
}
87+
88+
b.push({
89+
cmd: `cat ${snapshot.title.toLowerCase().replace(/\s+/g, '-')}.txt`,
90+
output: (
91+
<Output>
92+
<div className="flex flex-wrap gap-x-4 gap-y-1">
93+
{snapshot.items.map((item) => (
94+
<span key={item}>
95+
<span className="text-green-800"></span>
96+
<span className="text-green-400">{item}</span>
97+
</span>
98+
))}
99+
</div>
100+
</Output>
101+
),
102+
})
103+
104+
const hasContact =
105+
hero.contact?.email ||
106+
hero.contact?.location ||
107+
hero.contact?.company ||
108+
websiteDisplay
109+
110+
if (hasContact) {
111+
b.push({
112+
cmd: 'env | grep USER_INFO',
113+
output: (
114+
<Output>
115+
<div className="space-y-0.5">
116+
{hero.contact?.company && (
117+
<p>
118+
<span className="text-green-800">COMPANY</span>
119+
<span className="text-green-700">=</span>
120+
<span className="text-green-400">{hero.contact.company}</span>
121+
</p>
122+
)}
123+
{hero.contact?.location && (
124+
<p>
125+
<span className="text-green-800">LOCATION</span>
126+
<span className="text-green-700">=</span>
127+
<span className="text-green-400">{hero.contact.location}</span>
128+
</p>
129+
)}
130+
{websiteDisplay && (
131+
<p>
132+
<span className="text-green-800">WEBSITE</span>
133+
<span className="text-green-700">=</span>
134+
<span className="text-green-400">{websiteDisplay}</span>
135+
</p>
136+
)}
137+
{hero.contact?.email && (
138+
<p>
139+
<span className="text-green-800">EMAIL</span>
140+
<span className="text-green-700">=</span>
141+
<span className="text-green-400">{hero.contact.email}</span>
142+
</p>
143+
)}
144+
</div>
145+
</Output>
146+
),
147+
})
48148
}
149+
150+
b.push({
151+
cmd: 'open --url github',
152+
output: (
153+
<Output>
154+
<a
155+
href={hero.primaryCtaHref}
156+
target="_blank"
157+
rel="noreferrer"
158+
className="text-green-300 underline underline-offset-2 transition hover:text-white"
159+
>
160+
{hero.primaryCtaHref}
161+
</a>
162+
{hero.caption && (
163+
<span className="ml-3 text-xs text-green-800"># {hero.caption}</span>
164+
)}
165+
</Output>
166+
),
167+
})
168+
169+
blocksRef.current = b
49170
}
171+
const blocks = blocksRef.current
172+
173+
// Animation state
174+
const [doneCount, setDoneCount] = useState(0) // fully completed blocks
175+
const [typedChars, setTypedChars] = useState(0) // chars typed for current block
176+
const [showOutput, setShowOutput] = useState(false)
177+
const cancelRef = useRef(false)
178+
179+
useEffect(() => {
180+
if (doneCount >= blocks.length) return
181+
182+
cancelRef.current = false
183+
setTypedChars(0)
184+
setShowOutput(false)
185+
186+
const cmd = blocks[doneCount].cmd
187+
let i = 0
188+
189+
const tick = setInterval(() => {
190+
if (cancelRef.current) { clearInterval(tick); return }
191+
i++
192+
setTypedChars(i)
193+
if (i >= cmd.length) {
194+
clearInterval(tick)
195+
setTimeout(() => {
196+
if (cancelRef.current) return
197+
setShowOutput(true)
198+
setTimeout(() => {
199+
if (cancelRef.current) return
200+
setDoneCount((c) => c + 1)
201+
}, NEXT_DELAY)
202+
}, OUTPUT_DELAY)
203+
}
204+
}, TYPING_SPEED)
205+
206+
return () => {
207+
cancelRef.current = true
208+
clearInterval(tick)
209+
}
210+
}, [doneCount, blocks])
211+
212+
const allDone = doneCount >= blocks.length
50213

51214
return (
52215
<section id="hero" className="bg-[#030d03] py-10 font-mono" aria-labelledby="hero-title">
53216
<div className="mx-auto max-w-4xl px-6">
54-
{/* Terminal chrome */}
55217
<div className="overflow-hidden rounded-none border border-green-900/70 shadow-[0_0_30px_rgba(0,255,65,0.04)]">
56218
{/* Title bar */}
57219
<div className="flex items-center gap-2 border-b border-green-900/70 bg-black px-4 py-2">
@@ -65,81 +227,37 @@ export default function HeroSection({ hero, snapshot }: HeroSectionProps) {
65227

66228
{/* Terminal body */}
67229
<div className="space-y-3 bg-black p-6 text-sm leading-relaxed">
68-
{/* Last login line */}
69-
<p className="text-green-800 text-xs">
230+
<p className="text-xs text-green-800">
70231
Last login: {new Date().toDateString()} on ttys000
71232
</p>
72233

73-
<Prompt cmd="whoami" />
74-
<Output>
75-
<p id="hero-title" className="text-lg font-bold text-green-200">{hero.title}</p>
76-
{hero.eyebrow && <p className="text-green-700 text-xs mt-0.5">{hero.eyebrow}</p>}
77-
</Output>
78-
79-
{desc && (
80-
<>
81-
<Prompt cmd="cat README.md" />
82-
<Output>
83-
<p className="max-w-xl text-green-500 leading-relaxed">{desc}</p>
84-
</Output>
85-
</>
86-
)}
234+
{/* Completed blocks */}
235+
{blocks.slice(0, doneCount).map((block, i) => (
236+
<div key={i}>
237+
<PromptLine cmd={block.cmd} />
238+
{block.output}
239+
</div>
240+
))}
87241

88-
<Prompt cmd={`cat ${snapshot.title.toLowerCase().replace(/\s+/g, '-')}.txt`} />
89-
<Output>
90-
<div className="flex flex-wrap gap-x-4 gap-y-1">
91-
{snapshot.items.map((item) => (
92-
<span key={item}>
93-
<span className="text-green-800"></span>
94-
<span className="text-green-400">{item}</span>
95-
</span>
96-
))}
242+
{/* Currently typing block */}
243+
{!allDone && (
244+
<div>
245+
<PromptLine
246+
cmd={blocks[doneCount].cmd.slice(0, typedChars)}
247+
partial
248+
/>
249+
{showOutput && blocks[doneCount].output}
97250
</div>
98-
</Output>
99-
100-
{(hero.contact?.email || hero.contact?.location || hero.contact?.company || websiteDisplay) && (
101-
<>
102-
<Prompt cmd="env | grep USER_INFO" />
103-
<Output>
104-
<div className="space-y-0.5">
105-
{hero.contact?.company && (
106-
<p><span className="text-green-800">COMPANY</span><span className="text-green-700">=</span><span className="text-green-400">{hero.contact.company}</span></p>
107-
)}
108-
{hero.contact?.location && (
109-
<p><span className="text-green-800">LOCATION</span><span className="text-green-700">=</span><span className="text-green-400">{hero.contact.location}</span></p>
110-
)}
111-
{websiteDisplay && (
112-
<p><span className="text-green-800">WEBSITE</span><span className="text-green-700">=</span><span className="text-green-400">{websiteDisplay}</span></p>
113-
)}
114-
{hero.contact?.email && (
115-
<p><span className="text-green-800">EMAIL</span><span className="text-green-700">=</span><span className="text-green-400">{hero.contact.email}</span></p>
116-
)}
117-
</div>
118-
</Output>
119-
</>
120251
)}
121252

122-
<Prompt cmd="open --url github" />
123-
<Output>
124-
<a
125-
href={hero.primaryCtaHref}
126-
target="_blank"
127-
rel="noreferrer"
128-
className="text-green-300 underline underline-offset-2 transition hover:text-white"
129-
>
130-
{hero.primaryCtaHref}
131-
</a>
132-
{hero.caption && (
133-
<span className="ml-3 text-green-800 text-xs"># {hero.caption}</span>
134-
)}
135-
</Output>
136-
137-
{/* Blinking cursor */}
138-
<div className="flex items-center gap-1 pt-1">
139-
<span className="select-none text-green-700">~/profile </span>
140-
<span className="select-none text-green-500"></span>
141-
<span className="inline-block h-4 w-2 animate-pulse bg-green-400" />
142-
</div>
253+
{/* Final idle cursor */}
254+
{allDone && (
255+
<div className="flex items-center gap-1 pt-1">
256+
<span className="select-none text-green-700">~/profile </span>
257+
<span className="select-none text-green-500"></span>
258+
<span className="inline-block h-4 w-2 animate-pulse bg-green-400" />
259+
</div>
260+
)}
143261
</div>
144262
</div>
145263
</div>

0 commit comments

Comments
 (0)