| 1 | "use client"; |
| 2 | |
| 3 | import { useEffect, useMemo, useState } from "react"; |
| 4 | |
| 5 | const INTERVAL = 2200; |
| 6 | const DURATION = 340; |
| 7 | |
| 8 | interface WordCyclerProps { |
| 9 | words: string[]; |
| 10 | className?: string; |
| 11 | offset?: number; // delay before starting, so two cyclers can sync |
| 12 | } |
| 13 | |
| 14 | export function WordCycler({ words, className, offset = 0 }: WordCyclerProps) { |
| 15 | const shuffled = useMemo(() => { |
| 16 | const arr = [...words]; |
| 17 | for (let i = arr.length - 1; i > 0; i--) { |
| 18 | const j = Math.floor(Math.random() * (i + 1)); |
| 19 | [arr[i], arr[j]] = [arr[j], arr[i]]; |
| 20 | } |
| 21 | return arr; |
| 22 | }, [words]); |
| 23 | |
| 24 | const [index, setIndex] = useState(0); |
| 25 | const [phase, setPhase] = useState<"visible" | "exit" | "enter">("visible"); |
| 26 | |
| 27 | useEffect(() => { |
| 28 | const start = setTimeout(() => { |
| 29 | const timer = setInterval(() => { |
| 30 | setPhase("exit"); |
| 31 | |
| 32 | setTimeout(() => { |
| 33 | setIndex((i) => (i + 1) % shuffled.length); |
| 34 | setPhase("enter"); |
| 35 | |
| 36 | requestAnimationFrame(() => { |
| 37 | requestAnimationFrame(() => { |
| 38 | setPhase("visible"); |
| 39 | }); |
| 40 | }); |
| 41 | }, DURATION); |
| 42 | }, INTERVAL); |
| 43 | |
| 44 | return () => clearInterval(timer); |
| 45 | }, offset); |
| 46 | |
| 47 | return () => clearTimeout(start); |
| 48 | }, [words, offset]); |
| 49 | |
| 50 | return ( |
| 51 | <span |
| 52 | style={{ |
| 53 | display: "inline-block", |
| 54 | overflow: "hidden", |
| 55 | verticalAlign: "bottom", |
| 56 | lineHeight: "inherit", |
| 57 | }} |
| 58 | > |
| 59 | <span |
| 60 | className={className} |
| 61 | style={{ |
| 62 | display: "inline-block", |
| 63 | transition: |
| 64 | phase === "enter" |
| 65 | ? "none" |
| 66 | : `transform ${DURATION}ms cubic-bezier(0.4,0,0.2,1), opacity ${DURATION}ms ease`, |
| 67 | transform: |
| 68 | phase === "visible" |
| 69 | ? "translateY(0)" |
| 70 | : phase === "exit" |
| 71 | ? "translateY(-110%)" |
| 72 | : "translateY(110%)", |
| 73 | opacity: phase === "visible" ? 1 : 0, |
| 74 | }} |
| 75 | > |
| 76 | {shuffled[index]} |
| 77 | </span> |
| 78 | </span> |
| 79 | ); |
| 80 | } |
| 81 | |
| 82 | const SERVICE_WORDS = ["Deploy", "Auth", "Mail", "GresIQ"]; |
| 83 | const FRAMEWORK_WORDS = ["Rust", "Tauri", "Next.js", "Ruby on Rails", "Vapor"]; |
| 84 | |
| 85 | export function ServiceWordCycler() { |
| 86 | return ( |
| 87 | <WordCycler |
| 88 | words={SERVICE_WORDS} |
| 89 | className="text-indigo-600 dark:text-indigo-400" |
| 90 | /> |
| 91 | ); |
| 92 | } |
| 93 | |
| 94 | export function FrameworkWordCycler() { |
| 95 | return ( |
| 96 | <WordCycler |
| 97 | words={FRAMEWORK_WORDS} |
| 98 | className="text-indigo-600 dark:text-indigo-400" |
| 99 | /> |
| 100 | ); |
| 101 | } |