feat(ui): add AnimatedTextCycle component and framer-motion

- add src/components/ui/animated-text-cycle.tsx (animated word cycle using framer-motion)
- add framer-motion dependency to package.json and update bun.lock (includes motion-dom / motion-utils)
- update About link to use YouTube playlist URL
- remove unused background images (public/bg.avif, public/bg.png) and update public/char.avif
This commit is contained in:
암냥 2025-10-23 21:29:11 +09:00
commit ea5262f7ed
7 changed files with 121 additions and 1 deletions

View file

@ -36,6 +36,7 @@
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"framer-motion": "^12.23.24",
"input-otp": "^1.4.2",
"lucide-react": "^0.544.0",
"next": "16.0.0",
@ -417,6 +418,8 @@
"fast-equals": ["fast-equals@5.3.2", "", {}, "sha512-6rxyATwPCkaFIL3JLqw8qXqMpIZ942pTX/tbQFkRsDGblS8tNGtlUauA/+mt6RUfqn/4MoEr+WDkYoIQbibWuQ=="],
"framer-motion": ["framer-motion@12.23.24", "", { "dependencies": { "motion-dom": "^12.23.23", "motion-utils": "^12.23.6", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w=="],
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
@ -461,6 +464,10 @@
"magic-string": ["magic-string@0.30.19", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw=="],
"motion-dom": ["motion-dom@12.23.23", "", { "dependencies": { "motion-utils": "^12.23.6" } }, "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA=="],
"motion-utils": ["motion-utils@12.23.6", "", {}, "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"next": ["next@16.0.0", "", { "dependencies": { "@next/env": "16.0.0", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.0.0", "@next/swc-darwin-x64": "16.0.0", "@next/swc-linux-arm64-gnu": "16.0.0", "@next/swc-linux-arm64-musl": "16.0.0", "@next/swc-linux-x64-gnu": "16.0.0", "@next/swc-linux-x64-musl": "16.0.0", "@next/swc-win32-arm64-msvc": "16.0.0", "@next/swc-win32-x64-msvc": "16.0.0", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-nYohiNdxGu4OmBzggxy9rczmjIGI+TpR5vbKTsE1HqYwNm1B+YSiugSrFguX6omMOKnDHAmBPY4+8TNJk0Idyg=="],

View file

@ -42,6 +42,7 @@
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"framer-motion": "^12.23.24",
"input-otp": "^1.4.2",
"lucide-react": "^0.544.0",
"next": "16.0.0",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 321 KiB

After

Width:  |  Height:  |  Size: 251 KiB

Before After
Before After

View file

@ -15,7 +15,7 @@ export default function Home() {
<p> .</p>
<br />
<p> .</p>
<a href="https://www.youtube.com/watch?v=XTdqpdTMZbw&list=PLZeYZotn5_IOJDek6e35NKzUtJm09yxZD">Effect Playing Contest 2025 Broadcast Develop</a><br />
<a href="https://www.youtube.com/playlist?list=PLZeYZotn5_IOJDek6e35NKzUtJm09yxZD">Effect Playing Contest 2025 Broadcast Develop</a><br />
<a href="https://github.com/imnyang/today.isangjeong">today.isangjeong</a>
</div>

View file

@ -0,0 +1,112 @@
import * as React from "react";
import { useState, useEffect, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
interface AnimatedTextCycleProps {
words: string[];
interval?: number;
className?: string;
}
export default function AnimatedTextCycle({
words,
interval = 5000,
className = "",
}: AnimatedTextCycleProps) {
const [currentIndex, setCurrentIndex] = useState(0);
const [width, setWidth] = useState("auto");
const measureRef = useRef<HTMLDivElement>(null);
// Get the width of the current word
useEffect(() => {
if (measureRef.current) {
const elements = measureRef.current.children;
if (elements.length > currentIndex) {
// Add a small buffer (10px) to prevent text wrapping
const newWidth = elements[currentIndex].getBoundingClientRect().width;
setWidth(`${newWidth}px`);
}
}
}, [currentIndex]);
useEffect(() => {
const timer = setInterval(() => {
setCurrentIndex((prevIndex) => (prevIndex + 1) % words.length);
}, interval);
return () => clearInterval(timer);
}, [interval, words.length]);
// Container animation for the whole word
const containerVariants = {
hidden: {
y: -20,
opacity: 0,
filter: "blur(8px)"
},
visible: {
y: 0,
opacity: 1,
filter: "blur(0px)",
transition: {
duration: 0.4,
ease: "easeOut"
}
},
exit: {
y: 20,
opacity: 0,
filter: "blur(8px)",
transition: {
duration: 0.3,
ease: "easeIn"
}
},
};
return (
<>
{/* Hidden measurement div with all words rendered */}
<div
ref={measureRef}
aria-hidden="true"
className="absolute opacity-0 pointer-events-none"
style={{ visibility: "hidden" }}
>
{words.map((word, i) => (
<span key={i} className={`font-bold ${className}`}>
{word}
</span>
))}
</div>
{/* Visible animated word */}
<motion.span
className="relative inline-block"
animate={{
width,
transition: {
type: "spring",
stiffness: 150,
damping: 15,
mass: 1.2,
}
}}
>
<AnimatePresence mode="wait" initial={false}>
<motion.span
key={currentIndex}
className={`inline-block font-bold ${className}`}
variants={containerVariants}
initial="hidden"
animate="visible"
exit="exit"
style={{ whiteSpace: "nowrap" }}
>
{words[currentIndex]}
</motion.span>
</AnimatePresence>
</motion.span>
</>
);
}