Skip to content

Instantly share code, notes, and snippets.

@cristicretu
Created February 20, 2026 08:13
Show Gist options
  • Select an option

  • Save cristicretu/b808942d39ec8178f9c9a8bdfd13bbb9 to your computer and use it in GitHub Desktop.

Select an option

Save cristicretu/b808942d39ec8178f9c9a8bdfd13bbb9 to your computer and use it in GitHub Desktop.
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