Created
February 20, 2026 08:13
-
-
Save cristicretu/b808942d39ec8178f9c9a8bdfd13bbb9 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { motion } from 'motion/react'; | |
| import NumberFlow from '@number-flow/react'; | |
| import { useEffect, useMemo, useState } from 'react'; | |
| const ACCENT_COLORS = ['#313de6', '#e9ee2a', '#24dd3f', '#14b8ff', '#ff5f6d']; | |
| const SYMBOLS = ['✣', '◈', '⌁', '⌗', '⌘', '⌂', '⌖', '⌇']; | |
| const FINAL_PERIOD_COLOR = '#23d53f'; | |
| const SPEED_MIN = 0.05; | |
| const SPEED_MAX = 2.5; | |
| const DEFAULT_TEXT = 'It works with your files.'; | |
| function randomFrom(list) { | |
| return list[Math.floor(Math.random() * list.length)]; | |
| } | |
| function useReducedMotion() { | |
| return useMemo(() => { | |
| if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { | |
| return false; | |
| } | |
| return window.matchMedia('(prefers-reduced-motion: reduce)').matches; | |
| }, []); | |
| } | |
| function buildBurst(count) { | |
| return Array.from({ length: Math.max(1, count) }, () => ({ | |
| color: randomFrom(ACCENT_COLORS), | |
| symbol: randomFrom(SYMBOLS) | |
| })); | |
| } | |
| function Frame({ step, burst, words, punctuation }) { | |
| const revealStages = Math.max(1, words.length); | |
| const current = burst[Math.min(Math.max(0, step - 1), burst.length - 1)] ?? burst[0]; | |
| const commonClass = 'inline-flex items-baseline whitespace-nowrap'; | |
| const symbolClass = 'font-mono leading-none'; | |
| const spacer = () => <span className="inline-block" style={{ width: '0.36em' }} aria-hidden="true" />; | |
| const symbolSlot = (word, symbolCount = 1) => ( | |
| <span className="relative inline-block align-baseline" style={{ color: current.color }}> | |
| <span className="invisible">{word}</span> | |
| <span className="absolute inset-0 inline-flex items-baseline"> | |
| <span className={symbolClass}>{current.symbol.repeat(symbolCount)}</span> | |
| </span> | |
| </span> | |
| ); | |
| const phrase = (accentIndex, dotColor = 'black') => ( | |
| <span className={commonClass}> | |
| {words.map((word, index) => ( | |
| <span key={`${word}-${index}`} className="inline-flex items-baseline whitespace-nowrap"> | |
| {index > 0 ? spacer() : null} | |
| <span style={{ color: accentIndex === index ? current.color : 'black' }}>{word}</span> | |
| </span> | |
| ))} | |
| {punctuation ? ( | |
| <span className="inline-block w-[0.7ch]" style={{ color: dotColor }}> | |
| {punctuation} | |
| </span> | |
| ) : null} | |
| </span> | |
| ); | |
| if (step === 0) { | |
| return ( | |
| <span className={`${commonClass} text-[62px]`} style={{ color: current.color }}> | |
| <span className={symbolClass}>{current.symbol}</span> | |
| </span> | |
| ); | |
| } | |
| if (step >= 1 && step < revealStages) { | |
| const visibleWords = words.slice(0, step); | |
| const nextWord = words[step]; | |
| const symbolCount = step === 1 + Math.floor((revealStages - 1) / 2) ? 2 : 1; | |
| return ( | |
| <span className={commonClass}> | |
| {visibleWords.map((word, index) => ( | |
| <span key={`${word}-${index}`} className="inline-flex items-baseline whitespace-nowrap"> | |
| {index > 0 ? spacer() : null} | |
| <span style={{ color: index === visibleWords.length - 1 ? current.color : 'black' }}>{word}</span> | |
| </span> | |
| ))} | |
| {visibleWords.length > 0 ? spacer() : null} | |
| {symbolSlot(nextWord, symbolCount)} | |
| </span> | |
| ); | |
| } | |
| if (step === revealStages) { | |
| return phrase(revealStages - 1, punctuation ? 'transparent' : 'black'); | |
| } | |
| if (step === revealStages + 1) { | |
| return phrase(null, FINAL_PERIOD_COLOR); | |
| } | |
| return phrase(null, 'black'); | |
| } | |
| function TextAnimationPage() { | |
| const [step, setStep] = useState(0); | |
| const [runId, setRunId] = useState(0); | |
| const [speed, setSpeed] = useState(1); | |
| const [text, setText] = useState(DEFAULT_TEXT); | |
| const [isDraggingSlider, setIsDraggingSlider] = useState(false); | |
| const reducedMotion = useReducedMotion(); | |
| const normalizedText = useMemo(() => { | |
| const value = text.trim().replace(/\s+/g, ' '); | |
| return value.length > 0 ? value : DEFAULT_TEXT; | |
| }, [text]); | |
| const hasTerminalPunctuation = /[.!?]$/.test(normalizedText); | |
| const punctuation = hasTerminalPunctuation ? normalizedText.slice(-1) : '.'; | |
| const bodyText = hasTerminalPunctuation ? normalizedText.slice(0, -1).trim() : normalizedText; | |
| const words = useMemo(() => bodyText.split(' ').filter(Boolean), [bodyText]); | |
| const revealStages = Math.max(1, words.length); | |
| const finalFrame = revealStages + 2; | |
| const burst = useMemo(() => buildBurst(revealStages), [runId, revealStages, normalizedText]); | |
| const progress = ((speed - SPEED_MIN) / (SPEED_MAX - SPEED_MIN)) * 100; | |
| useEffect(() => { | |
| if (reducedMotion) { | |
| setStep(finalFrame); | |
| return undefined; | |
| } | |
| setStep(0); | |
| const marks = Array.from({ length: finalFrame }, (_, index) => (index + 1) * 70); | |
| const scale = 1 / speed; | |
| const timers = marks.map((delay, index) => | |
| setTimeout(() => setStep(index + 1), Math.round(delay * scale)) | |
| ); | |
| return () => { | |
| timers.forEach((timer) => clearTimeout(timer)); | |
| }; | |
| }, [finalFrame, reducedMotion, runId, speed, normalizedText]); | |
| return ( | |
| <section className="flex min-h-[72vh] w-full items-center justify-center"> | |
| <div className="w-full max-w-4xl px-6 sm:px-12"> | |
| <div className="min-h-[92px] sm:min-h-[118px]"> | |
| <div className="relative inline-block"> | |
| <h1 | |
| className="pointer-events-none select-none whitespace-nowrap text-[36px] font-semibold leading-none tracking-[-0.05em] text-transparent sm:text-[60px]" | |
| style={{ fontFamily: '"Helvetica", "Arial", sans-serif' }} | |
| aria-hidden="true" | |
| > | |
| {words.join(' ')} | |
| {punctuation} | |
| </h1> | |
| <h1 | |
| className="absolute inset-0 whitespace-nowrap text-[36px] font-semibold leading-none tracking-[-0.05em] sm:text-[60px]" | |
| style={{ fontFamily: '"Helvetica", "Arial", sans-serif' }} | |
| > | |
| <span className="inline-block origin-left"> | |
| <Frame step={step} burst={burst} words={words} punctuation={punctuation} /> | |
| </span> | |
| </h1> | |
| </div> | |
| </div> | |
| <div className="mt-10 w-full max-w-[280px]"> | |
| <div className="mb-2.5 flex items-center gap-2.5"> | |
| <span className="font-mono text-[11px] uppercase tracking-[0.18em] text-black/55">Speed</span> | |
| <motion.span | |
| className="font-mono text-[11px] tabular-nums text-black/70" | |
| animate={{ y: isDraggingSlider ? -1 : 0, opacity: isDraggingSlider ? 1 : 0.8 }} | |
| transition={{ duration: 0.18, ease: [0.22, 1, 0.36, 1] }} | |
| > | |
| <NumberFlow | |
| value={speed} | |
| format={{ minimumFractionDigits: 2, maximumFractionDigits: 2 }} | |
| trend={false} | |
| willChange | |
| /> | |
| x | |
| </motion.span> | |
| </div> | |
| <div | |
| className="relative h-7 w-full" | |
| onPointerDown={() => setIsDraggingSlider(true)} | |
| onPointerUp={() => setIsDraggingSlider(false)} | |
| onPointerCancel={() => setIsDraggingSlider(false)} | |
| onPointerLeave={() => setIsDraggingSlider(false)} | |
| > | |
| <div className="absolute left-0 right-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-black/25" /> | |
| <motion.div | |
| className="absolute left-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-[#1d4ed8]" | |
| style={{ width: `${progress}%` }} | |
| animate={{ | |
| boxShadow: isDraggingSlider ? '0 0 0 2px rgba(29,78,216,0.16)' : '0 0 0 0 rgba(29,78,216,0)' | |
| }} | |
| transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }} | |
| /> | |
| <div className="absolute top-1/2 -translate-x-1/2 -translate-y-1/2" style={{ left: `${progress}%` }}> | |
| <motion.div | |
| className="h-4 w-4 rounded-full border border-black/15 bg-white" | |
| animate={{ | |
| scale: isDraggingSlider ? 1.12 : 1, | |
| boxShadow: isDraggingSlider | |
| ? '0 5px 14px -8px rgba(29,78,216,0.45)' | |
| : '0 3px 9px -7px rgba(0,0,0,0.45)' | |
| }} | |
| transition={{ duration: 0.16, ease: [0.22, 1, 0.36, 1] }} | |
| /> | |
| </div> | |
| <input | |
| type="range" | |
| min={SPEED_MIN} | |
| max={SPEED_MAX} | |
| step="0.05" | |
| value={speed} | |
| onChange={(event) => setSpeed(Number(event.target.value))} | |
| className="absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0" | |
| aria-label="Animation speed" | |
| /> | |
| </div> | |
| </div> | |
| <div className="mt-5 w-full max-w-[420px]"> | |
| <label className="space-y-2"> | |
| <span className="block font-mono text-[11px] uppercase tracking-[0.18em] text-black/55">Text</span> | |
| <input | |
| type="text" | |
| value={text} | |
| onChange={(event) => setText(event.target.value)} | |
| className="w-full rounded-full border border-black/15 bg-white/70 px-3 py-2 text-sm text-black outline-none transition focus:border-black/40" | |
| placeholder="Type any sentence..." | |
| /> | |
| </label> | |
| </div> | |
| </div> | |
| </section> | |
| ); | |
| } | |
| export default TextAnimationPage; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment