feat(homepage): add typewriter effect to subtitle section

Implement a dynamic typewriter animation that cycles through multiple role descriptions. The effect includes customizable typing and deleting speeds, delays, and looping behavior to enhance user engagement on the homepage.
This commit is contained in:
joyzhao
2025-06-20 10:27:41 +08:00
parent fc6a1d32fd
commit 3909db0ceb
3 changed files with 201 additions and 6 deletions

View File

@@ -0,0 +1,155 @@
import React, { useState, useEffect, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
interface TypewriterEffectProps {
/**
* Text or array of texts to display in typewriter effect
*/
text: string | string[];
/**
* Typing speed in milliseconds per character
*/
typingSpeed?: number;
/**
* Deleting speed in milliseconds per character
*/
deletingSpeed?: number;
/**
* Delay before starting to delete text in milliseconds
*/
delayBeforeDelete?: number;
/**
* Delay before typing the next text in milliseconds
*/
delayBeforeTyping?: number;
/**
* Whether to loop through the texts indefinitely
*/
loop?: boolean;
/**
* CSS class name for the cursor
*/
cursorClassName?: string;
/**
* CSS class name for the text container
*/
className?: string;
/**
* Whether to show the cursor
*/
showCursor?: boolean;
}
/**
* TypewriterEffect component that creates a typewriter animation effect
* for a single text or cycles through an array of texts
*/
const TypewriterEffect: React.FC<TypewriterEffectProps> = ({
text,
typingSpeed = 100,
deletingSpeed = 50,
delayBeforeDelete = 2000,
delayBeforeTyping = 700,
loop = true,
cursorClassName = 'text-purple-500',
className = '',
showCursor = true,
}) => {
// Convert single string to array for consistent handling
const textArray = Array.isArray(text) ? text : [text];
const [currentTextIndex, setCurrentTextIndex] = useState(0);
const [displayedText, setDisplayedText] = useState('');
const [isTyping, setIsTyping] = useState(true);
const [isDeleting, setIsDeleting] = useState(false);
const [isPaused, setIsPaused] = useState(false);
// Use a ref to track if component is mounted
const isMounted = useRef(true);
useEffect(() => {
// Cleanup function to prevent state updates after unmount
return () => {
isMounted.current = false;
};
}, []);
useEffect(() => {
if (!isMounted.current) return;
const currentFullText = textArray[currentTextIndex];
// Handle typing state
if (isTyping && !isPaused) {
if (displayedText.length < currentFullText.length) {
// Continue typing
const timeoutId = setTimeout(() => {
if (isMounted.current) {
setDisplayedText(currentFullText.substring(0, displayedText.length + 1));
}
}, typingSpeed);
return () => clearTimeout(timeoutId);
} else {
// Finished typing, pause before deleting
setIsTyping(false);
setIsPaused(true);
const timeoutId = setTimeout(() => {
if (isMounted.current) {
setIsPaused(false);
setIsDeleting(true);
}
}, delayBeforeDelete);
return () => clearTimeout(timeoutId);
}
}
// Handle deleting state
if (isDeleting && !isPaused) {
if (displayedText.length > 0) {
// Continue deleting
const timeoutId = setTimeout(() => {
if (isMounted.current) {
setDisplayedText(displayedText.substring(0, displayedText.length - 1));
}
}, deletingSpeed);
return () => clearTimeout(timeoutId);
} else {
// Finished deleting, move to next text
setIsDeleting(false);
setIsPaused(true);
const timeoutId = setTimeout(() => {
if (isMounted.current) {
// 无条件进入下一个文本,确保循环总是继续
const nextIndex = (currentTextIndex + 1) % textArray.length;
setCurrentTextIndex(nextIndex);
setIsPaused(false);
setIsTyping(true);
}
}, delayBeforeTyping);
return () => clearTimeout(timeoutId);
}
}
}, [displayedText, isTyping, isDeleting, isPaused, currentTextIndex, textArray, typingSpeed, deletingSpeed, delayBeforeDelete, delayBeforeTyping]);
return (
<span className={`${className} relative`}>
{/* 直接显示文本,不使用 AnimatePresence 包装整个文本 */}
<span className="font-medium">
{displayedText}
</span>
{showCursor && (
<motion.span
className={`${cursorClassName} font-bold text-xl ml-1`}
animate={{ opacity: [1, 0, 1] }}
transition={{
opacity: { duration: 0.8, repeat: Infinity, repeatType: 'loop' }
}}
>
|
</motion.span>
)}
</span>
);
};
export default TypewriterEffect;

View File

@@ -2,6 +2,7 @@
import Layout from "@/layouts/Layout.astro"; import Layout from "@/layouts/Layout.astro";
import GlassHeader from "@/components/GlassHeader"; import GlassHeader from "@/components/GlassHeader";
import SkillsMarquee from "@/components/SkillsMarquee"; import SkillsMarquee from "@/components/SkillsMarquee";
import TypewriterEffect from "@/components/TypewriterEffect";
import Footer from "@/components/Footer"; import Footer from "@/components/Footer";
import Container from "@/components/ui/Container"; import Container from "@/components/ui/Container";
import { useTranslations, type Lang } from "@/i18n/utils"; import { useTranslations, type Lang } from "@/i18n/utils";
@@ -39,9 +40,28 @@ const pageTitle = t('site.title');
{personalInfo.name} {personalInfo.name}
</h1> </h1>
<!-- Subtitle --> <!-- Subtitle with Typewriter Effect -->
<p class="text-2xl md:text-3xl text-muted-foreground mb-8 font-light"> <p class="text-2xl md:text-3xl text-muted-foreground mb-8 font-light flex items-center justify-center">
{personalInfo.position.en} 👨‍💻 <span class="inline-flex items-center">
<TypewriterEffect
client:load
text={[
personalInfo.position.en,
"Frontend Developer",
"TypeScript Enthusiast",
"React Specialist",
"Full-stack Engineer"
]}
typingSpeed={100}
deletingSpeed={50}
delayBeforeDelete={3500}
delayBeforeTyping={1800}
loop={true}
cursorClassName="text-purple-600 dark:text-purple-400"
className="mr-2 font-medium text-muted-foreground"
/>
<span>👨‍💻</span>
</span>
</p> </p>
<!-- Description --> <!-- Description -->

View File

@@ -2,6 +2,7 @@
import Layout from "@/layouts/Layout.astro"; import Layout from "@/layouts/Layout.astro";
import GlassHeader from "@/components/GlassHeader"; import GlassHeader from "@/components/GlassHeader";
import SkillsMarquee from "@/components/SkillsMarquee"; import SkillsMarquee from "@/components/SkillsMarquee";
import TypewriterEffect from "@/components/TypewriterEffect";
import Footer from "@/components/Footer"; import Footer from "@/components/Footer";
import Container from "@/components/ui/Container"; import Container from "@/components/ui/Container";
import { useTranslations, type Lang } from "@/i18n/utils"; import { useTranslations, type Lang } from "@/i18n/utils";
@@ -39,9 +40,28 @@ const pageTitle = t('site.title');
{personalInfo.name} {personalInfo.name}
</h1> </h1>
<!-- Subtitle --> <!-- Subtitle with Typewriter Effect -->
<p class="text-2xl md:text-3xl text-muted-foreground mb-8 font-light"> <p class="text-2xl md:text-3xl text-muted-foreground mb-8 font-light flex items-center justify-center">
{personalInfo.position.zh} 👨‍💻 <span class="inline-flex items-center">
<TypewriterEffect
client:load
text={[
personalInfo.position.zh,
"前端开发工程师",
"TypeScript 爱好者",
"React 专家",
"全栈工程师"
]}
typingSpeed={100}
deletingSpeed={50}
delayBeforeDelete={3500}
delayBeforeTyping={1800}
loop={true}
cursorClassName="text-purple-600 dark:text-purple-400"
className="mr-2 font-medium text-muted-foreground"
/>
<span>👨‍💻</span>
</span>
</p> </p>
<!-- Description --> <!-- Description -->