Skip to content

Commit a29f91f

Browse files
Kosthiclaude
andcommitted
feat: add typewriter effect to hero title
Split hero title into two lines (brand "RushDB" + motto) with a character-by-character typewriter animation. Brand types at 80ms/char, motto at 100ms/char, with a blinking cursor via ::after pseudo-element that fades out after one blink cycle. - Use position:absolute overlay to avoid affecting parent grid layout - Hidden placeholder reserves final text dimensions to prevent reflow - Subtitle and actions fade in after typing completes via JS transitions - Respect prefers-reduced-motion: show full text immediately - Support all three languages (zh/en/ja) with auto-trimmed separators Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 380081c commit a29f91f

File tree

3 files changed

+154
-50
lines changed

3 files changed

+154
-50
lines changed

src/components/astro/HeroSection.astro

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ interface Props {
88
99
const { lang } = Astro.props;
1010
const { t } = getI18n(lang);
11+
12+
const fullTitle = t('hero.title');
13+
const brand = 'RushDB';
14+
const rawMotto = fullTitle.startsWith(brand) ? fullTitle.slice(brand.length) : fullTitle;
15+
const mottoText = rawMotto.replace(/^[\s,、,]+/, '');
1116
---
1217

1318
<section class="hero">
@@ -19,8 +24,12 @@ const { t } = getI18n(lang);
1924
<Image src="/RushDB.png" alt="RushDB Logo" width={697} height={697} priority />
2025
</div>
2126
</div>
22-
<h1 class="hero-title">
23-
{t('hero.title')}
27+
<h1 class="hero-title" data-brand={brand} data-motto={mottoText}>
28+
<span class="hero-placeholder" aria-hidden="true">{brand}<br />{mottoText}</span>
29+
<span class="hero-typed">
30+
<span class="hero-line"><span class="hero-brand"></span></span>
31+
<span class="hero-line"><span class="hero-motto"></span></span>
32+
</span>
2433
</h1>
2534
<p class="hero-subtitle">
2635
{t('hero.subtitle')}
@@ -73,3 +82,75 @@ const { t } = getI18n(lang);
7382
</div>
7483
</div>
7584
</section>
85+
86+
<script>
87+
function initTypewriter() {
88+
const title = document.querySelector<HTMLElement>('.hero-title');
89+
if (!title) return;
90+
91+
const placeholder = title.querySelector<HTMLElement>('.hero-placeholder');
92+
const brandEl = title.querySelector<HTMLElement>('.hero-brand');
93+
const mottoEl = title.querySelector<HTMLElement>('.hero-motto');
94+
if (!brandEl || !mottoEl) return;
95+
96+
const brand = title.dataset.brand ?? '';
97+
const motto = title.dataset.motto ?? '';
98+
99+
// Respect prefers-reduced-motion: show full text immediately
100+
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
101+
brandEl.textContent = brand;
102+
mottoEl.textContent = motto;
103+
if (placeholder) placeholder.style.display = 'none';
104+
revealFollowUp();
105+
return;
106+
}
107+
108+
let i = 0;
109+
let phase: 'brand' | 'motto' = 'brand';
110+
brandEl.classList.add('typing');
111+
112+
function type() {
113+
if (phase === 'brand') {
114+
if (i < brand.length) {
115+
brandEl!.textContent += brand[i];
116+
i++;
117+
setTimeout(type, 80);
118+
} else {
119+
brandEl!.classList.remove('typing');
120+
mottoEl!.classList.add('typing');
121+
phase = 'motto';
122+
i = 0;
123+
setTimeout(type, 100);
124+
}
125+
} else {
126+
if (i < motto.length) {
127+
mottoEl!.textContent += motto[i];
128+
i++;
129+
setTimeout(type, 100);
130+
} else {
131+
// Typing done — blink cursor then fade out
132+
mottoEl!.classList.remove('typing');
133+
mottoEl!.classList.add('typing-done');
134+
setTimeout(() => mottoEl!.classList.remove('typing-done'), 1500);
135+
revealFollowUp();
136+
}
137+
}
138+
}
139+
140+
// Start typing after a brief delay
141+
setTimeout(type, 400);
142+
}
143+
144+
function revealFollowUp() {
145+
const subtitle = document.querySelector<HTMLElement>('.hero-subtitle');
146+
const actions = document.querySelector<HTMLElement>('.hero-actions');
147+
if (subtitle) subtitle.classList.add('hero-revealed');
148+
if (actions) {
149+
setTimeout(() => actions.classList.add('hero-revealed'), 150);
150+
}
151+
}
152+
153+
// Run on initial load and on Astro page transitions
154+
initTypewriter();
155+
document.addEventListener('astro:after-swap', initTypewriter);
156+
</script>

src/styles/global.css

Lines changed: 60 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -381,9 +381,23 @@
381381
}
382382

383383
.hero-title {
384+
position: relative;
384385
font-size: clamp(3rem, 8vw, 6rem);
385386
font-weight: 700;
386387
margin-bottom: 1.5rem;
388+
line-height: 1.3;
389+
}
390+
391+
.hero-placeholder {
392+
visibility: hidden;
393+
display: block;
394+
}
395+
396+
.hero-typed {
397+
position: absolute;
398+
top: 0;
399+
left: 0;
400+
width: 100%;
387401
background: linear-gradient(
388402
135deg,
389403
var(--text-primary) 0%,
@@ -392,32 +406,40 @@
392406
-webkit-background-clip: text;
393407
-webkit-text-fill-color: transparent;
394408
background-clip: text;
395-
line-height: 1.3;
396409
}
397410

398-
.hero-title .lang-content.lang-en {
399-
font-size: clamp(2.2rem, 6.5vw, 5rem);
400-
overflow-wrap: anywhere;
401-
word-break: normal;
402-
hyphens: auto;
411+
.hero-line {
412+
display: block;
403413
}
404414

405-
@media (max-width: 1200px) {
406-
.hero-title .lang-content.lang-en {
407-
font-size: clamp(2rem, 6vw, 4.5rem);
408-
}
415+
.hero-brand.typing::after,
416+
.hero-motto.typing::after {
417+
content: '|';
418+
display: inline-block;
419+
width: 0;
420+
overflow: visible;
421+
-webkit-text-fill-color: var(--accent-secondary);
422+
font-weight: 300;
423+
animation: blink 1s step-end infinite;
409424
}
410425

411-
@media (max-width: 768px) {
412-
.hero-title .lang-content.lang-en {
413-
font-size: clamp(1.8rem, 5.5vw, 3.5rem);
414-
}
426+
.hero-motto.typing-done::after {
427+
content: '|';
428+
display: inline-block;
429+
width: 0;
430+
overflow: visible;
431+
-webkit-text-fill-color: var(--accent-secondary);
432+
font-weight: 300;
433+
animation: blink 0.6s step-end 1, cursorFadeOut 0.3s ease 0.6s forwards;
415434
}
416435

417-
@media (max-width: 480px) {
418-
.hero-title .lang-content.lang-en {
419-
font-size: clamp(1.5rem, 5vw, 2.8rem);
420-
}
436+
@keyframes blink {
437+
0%, 100% { opacity: 1; }
438+
50% { opacity: 0; }
439+
}
440+
441+
@keyframes cursorFadeOut {
442+
to { opacity: 0; }
421443
}
422444

423445
.hero-subtitle {
@@ -2717,34 +2739,12 @@
27172739
letter-spacing: -0.045em;
27182740
line-height: 1.06;
27192741
margin: 0 auto 14px;
2720-
max-width: 18ch;
2721-
text-wrap: balance;
27222742
}
27232743

27242744
html[lang="en-US"] .hero-title {
27252745
line-height: 1.16;
27262746
}
27272747

2728-
.hero-title .lang-content {
2729-
background: linear-gradient(
2730-
180deg,
2731-
rgba(240, 246, 255, 0.98),
2732-
rgba(240, 246, 255, 0.72)
2733-
);
2734-
-webkit-background-clip: text;
2735-
-webkit-text-fill-color: transparent;
2736-
background-clip: text;
2737-
text-shadow: 0 0 28px rgba(34, 211, 238, 0.18);
2738-
font-size: inherit;
2739-
white-space: normal;
2740-
}
2741-
2742-
.hero-title .lang-content.lang-en {
2743-
overflow-wrap: anywhere;
2744-
word-break: normal;
2745-
hyphens: auto;
2746-
}
2747-
27482748
.hero-subtitle {
27492749
color: var(--text-2);
27502750
max-width: 48ch;
@@ -3289,17 +3289,28 @@
32893289

32903290
/* Staggered text reveal animation */
32913291
.hero-title {
3292-
animation: textReveal 1s var(--ease-out-expo) forwards;
3292+
animation: none;
32933293
}
32943294

32953295
.hero-subtitle {
3296-
animation: textReveal 1s var(--ease-out-expo) 0.15s forwards;
32973296
opacity: 0;
3297+
transform: translateY(20px);
3298+
filter: blur(8px);
3299+
transition: opacity 0.8s var(--ease-out-expo), transform 0.8s var(--ease-out-expo), filter 0.8s var(--ease-out-expo);
32983300
}
32993301

33003302
.hero-actions {
3301-
animation: textReveal 1s var(--ease-out-expo) 0.3s forwards;
33023303
opacity: 0;
3304+
transform: translateY(20px);
3305+
filter: blur(8px);
3306+
transition: opacity 0.8s var(--ease-out-expo), transform 0.8s var(--ease-out-expo), filter 0.8s var(--ease-out-expo);
3307+
}
3308+
3309+
.hero-subtitle.hero-revealed,
3310+
.hero-actions.hero-revealed {
3311+
opacity: 1;
3312+
transform: translateY(0);
3313+
filter: blur(0);
33033314
}
33043315

33053316
@keyframes textReveal {
@@ -3668,6 +3679,12 @@
36683679
opacity: 1 !important;
36693680
transform: none !important;
36703681
filter: none !important;
3682+
transition: none !important;
3683+
}
3684+
3685+
.hero-brand::after,
3686+
.hero-motto::after {
3687+
display: none !important;
36713688
}
36723689

36733690
.member-card,

src/styles/theme-tokens.css

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -530,18 +530,24 @@ html:not(.theme-preload) .timeline-event {
530530
border-color: rgba(14, 116, 144, 0.22);
531531
}
532532

533-
/* Hero title text gradient */
534-
:root[data-theme="light"] .hero-title .lang-content {
533+
/* Hero title gradient - light mode */
534+
:root[data-theme="light"] .hero-typed {
535535
background: linear-gradient(
536-
180deg,
537-
rgba(41, 37, 28, 0.96),
538-
rgba(41, 37, 28, 0.78)
536+
135deg,
537+
rgba(41, 37, 28, 0.96) 0%,
538+
var(--theme-accent-2) 100%
539539
);
540540
-webkit-background-clip: text;
541541
-webkit-text-fill-color: transparent;
542542
background-clip: text;
543543
}
544544

545+
:root[data-theme="light"] .hero-brand.typing::after,
546+
:root[data-theme="light"] .hero-motto.typing::after,
547+
:root[data-theme="light"] .hero-motto.typing-done::after {
548+
-webkit-text-fill-color: var(--theme-accent-2);
549+
}
550+
545551
/* Card hover states (desktop enhanced) */
546552
:root[data-theme="light"] .achievement-item:hover,
547553
:root[data-theme="light"] .project-item:hover,

0 commit comments

Comments
 (0)