Gooey Words

Text that smoothly changes between different words with a liquid-like effect. Each word flows into the next one.

Install dependencies

npm i framer-motion

Component

Create a file gooey-words.tsx in your components folder and paste this code

'use client'; import { animate, AnimatePresence, motion } from 'framer-motion'; import React, { memo, RefObject, useEffect, useRef, useState } from 'react'; const alphabetPaths = { A: 'M15,90 L50,10 L85,90 M25,60 H75', B: 'M20,10 V90 H60 Q85,90 85,70 Q85,60 70,50 Q85,40 85,30 Q85,10 60,10 H20', C: 'M85,25 Q65,10 40,10 Q15,10 15,50 Q15,90 40,90 Q65,90 85,75', D: 'M20,10 V90 H60 Q90,90 90,50 Q90,10 60,10 H20', E: 'M80,10 H20 V90 H80 M20,50 H65', F: 'M20,10 V90 M20,10 H80 M20,50 H65', G: 'M85,25 Q65,10 40,10 Q15,10 15,50 Q15,90 40,90 Q65,90 85,75 V55 H55', H: 'M20,10 V90 M80,10 V90 M20,50 H80', I: 'M30,10 H70 M50,10 V90 M30,90 H70', J: 'M70,10 H30 M50,10 V75 Q50,90 30,85', K: 'M20,10 V90 M80,10 L20,50 L80,90', L: 'M20,10 V90 H80', M: 'M15,90 V15 L50,50 L85,15 V90', N: 'M20,90 V15 L80,90 V15', O: 'M50,10 Q80,10 85,50 Q80,90 50,90 Q20,90 15,50 Q20,10 50,10', P: 'M20,90 V10 H65 Q85,10 85,35 Q85,60 65,60 H20', Q: 'M50,10 Q80,10 85,50 Q80,90 50,90 Q20,90 15,50 Q20,10 50,10 M60,70 L85,90', R: 'M20,90 V10 H65 Q85,10 85,35 Q85,60 65,60 H20 M50,60 L85,90', S: 'M80,25 Q60,10 35,10 Q15,15 15,35 Q15,50 50,55 Q85,60 85,75 Q85,90 50,90 Q25,90 15,75', T: 'M15,10 H85 M50,10 V90', U: 'M15,10 V65 Q15,90 50,90 Q85,90 85,65 V10', V: 'M15,10 L50,90 L85,10', W: 'M10,10 L30,90 L50,40 L70,90 L90,10', X: 'M15,10 L85,90 M15,90 L85,10', Y: 'M15,10 L50,50 L85,10 M50,50 V90', Z: 'M15,10 H85 L15,90 H85' }; const Letter = memo( ({ letterIndex, wordPathsRef, index, path, circles, circlesRef, circlesPositionsRef, radius }: { letterIndex: number; index: number; path: string; radius: number; wordPathsRef: RefObject<Record<number, SVGPathElement[]>>; circles: number; circlesRef: RefObject<Record<number, SVGCircleElement[][]>>; circlesPositionsRef: RefObject<{ x: number; y: number }[][]>; }) => { return ( <div className="mx-1"> <svg width="50" height="70" viewBox="0 0 100 100" style={{ filter: 'url(#gooey)' }}> <path ref={(el) => { if (el) { if (!wordPathsRef.current[index]) { wordPathsRef.current[index] = []; } wordPathsRef.current[index][letterIndex] = el; } }} d={path} fill="none" strokeLinecap="round" strokeLinejoin="round" /> <g> {Array.from({ length: circles }).map((_, circleIndex) => ( <circle ref={(el) => { if (el) { if (!circlesRef.current[index]) { circlesRef.current[index] = []; } if (!circlesRef.current[index][letterIndex]) { circlesRef.current[index][letterIndex] = []; } if (!circlesPositionsRef.current[letterIndex]) { circlesPositionsRef.current[letterIndex] = []; } if (!circlesPositionsRef.current[letterIndex][circleIndex]) { circlesPositionsRef.current[letterIndex][circleIndex] = { x: 50, y: 50 }; } circlesRef.current[index][letterIndex][circleIndex] = el; } }} r={radius} key={`circle-${letterIndex}-${circleIndex}-${index}`} cx={circlesPositionsRef.current?.[letterIndex]?.[circleIndex].x || 50} cy={circlesPositionsRef.current?.[letterIndex]?.[circleIndex].y || 50} fill="white" /> ))} </g> </svg> </div> ); } ); const GooeyWords = ({ words, speed = 2 }: { words: string[]; speed?: number }) => { const [index, setIndex] = useState(0); const wordPathsRef = useRef<Record<number, SVGPathElement[]>>({}); const circlesRef = useRef<Record<number, SVGCircleElement[][]>>({}); const circlesPositionsRef = useRef<{ x: number; y: number }[][]>([]); const circles = 25; const radius = 5; useEffect(() => { const interval = setInterval(() => { setIndex((i) => (i + 1) % words.length); }, speed * 1000); return () => clearInterval(interval); }, [words.length, speed, index]); useEffect(() => { if (!wordPathsRef.current[index] || wordPathsRef.current[index].length === 0) { return; } const currentPaths = wordPathsRef.current[index]; const currentCirclesGroups = circlesRef.current[index] || []; currentPaths.forEach((path, letterIndex) => { if (!path) return; const length = path.getTotalLength(); if (!length) return; const step = length / circles; const circlesToAnimate = currentCirclesGroups[letterIndex] || []; circlesToAnimate.forEach((circle, circleIndex) => { if (!circle) return; const { x, y } = path.getPointAtLength(step * circleIndex) || { x: 0, y: 0 }; circlesPositionsRef.current[letterIndex][circleIndex] = { x, y }; animate( circle, { cx: x, cy: y }, { duration: 0.8, delay: 0.02 * circleIndex + letterIndex * 0.04, ease: 'easeOut' } ); }); }); }, [index, circles]); return ( <div className="relative"> <svg className="absolute w-0 h-0"> <defs> <filter id="gooey"> <feGaussianBlur in="SourceGraphic" stdDeviation="2" result="blur" /> <feColorMatrix in="blur" type="matrix" values=" 1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 12 -2" result="gooey" /> </filter> </defs> </svg> <AnimatePresence mode="popLayout"> <motion.div className="flex items-center justify-center p-8 rounded-lg" style={{ minHeight: '100px' }} > {words[index].split('').map((letter, letterIndex) => { const path = alphabetPaths[letter.toUpperCase() as keyof typeof alphabetPaths]; if (!path) { return <div key={`${letter}-${letterIndex}-${index}`} className="w-8" />; } return ( <Letter key={`${letter}-${letterIndex}-${index}`} letterIndex={letterIndex} index={index} path={path} radius={radius} wordPathsRef={wordPathsRef} circles={circles} circlesRef={circlesRef} circlesPositionsRef={circlesPositionsRef} /> ); })} </motion.div> </AnimatePresence> </div> ); }; export default GooeyWords;

Usage

<GooeyWords words={words} />