Word Well
A rotating word set inside a recessed well that springs to each word's measured width while words roll through with a blur crossfade.
Ship faster with Liten
The changing word lives in a recessed well carved into the line. Words always exit upward and enter from below, so the motion reads as one rolling mechanism, and the well's width springs to fit each incoming word. Hover or focus it to pause. Built with Motion.
Installation
Complete the shared Setup first, then add Motion.
bun add motionCopy the component into components/ui/word-well.tsx.
'use client';import * as React from 'react';import { AnimatePresence, motion, useReducedMotion } from 'motion/react';import { cn } from '@/lib/cn';const EASE_OUT = [0.23, 1, 0.32, 1] as const;const SPRING = { type: 'spring', duration: 0.5, bounce: 0.16 } as const;const FOCUS = 'focus-visible:outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:[outline-color:var(--well-accent)]';export type WordWellProps = { words: string[]; interval?: number; accent?: string; className?: string;};export function WordWell({ words, interval = 2600, accent = '#f0883e', className,}: WordWellProps) { const reduce = useReducedMotion(); const [index, setIndex] = React.useState(0); const [paused, setPaused] = React.useState(false); const [width, setWidth] = React.useState<number | null>(null); const word = words[index % words.length] ?? ''; const strutRef = React.useRef<HTMLSpanElement>(null); React.useLayoutEffect(() => { const el = strutRef.current; if (!el) return; const measure = () => setWidth(Math.ceil(el.getBoundingClientRect().width)); measure(); const ro = new ResizeObserver(measure); ro.observe(el); return () => ro.disconnect(); }, [index]); React.useEffect(() => { if (paused || words.length < 2) return; const id = setInterval( () => setIndex((i) => (i + 1) % words.length), interval, ); return () => clearInterval(id); }, [paused, interval, words.length]); const swap = reduce ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, } : { initial: { opacity: 0, y: '0.55em', filter: 'blur(4px)' }, animate: { opacity: 1, y: '0em', filter: 'blur(0px)' }, exit: { opacity: 0, y: '-0.55em', filter: 'blur(4px)' }, }; return ( <span style={{ '--well-accent': accent } as React.CSSProperties} tabIndex={0} onPointerEnter={() => setPaused(true)} onPointerLeave={() => setPaused(false)} onFocus={() => setPaused(true)} onBlur={() => setPaused(false)} className={cn( 'bloom-edge inline-flex items-baseline rounded-[11px] px-[0.5em] py-[0.12em]', 'bg-black/[0.02] dark:bg-black/20', 'shadow-[inset_0_1px_2px_0_rgba(0,0,0,0.04)] dark:shadow-[inset_0_1px_3px_0_rgba(0,0,0,0.5)]', FOCUS, className, )} > <motion.span className="relative inline-block whitespace-pre [clip-path:inset(0_0_0_0)]" initial={false} animate={width !== null ? { width } : undefined} transition={reduce ? { duration: 0 } : SPRING} > <span ref={strutRef} className="invisible inline-block whitespace-pre" aria-hidden > {word} </span> <AnimatePresence initial={false}> <motion.span key={index % words.length} className="absolute left-0 top-0 whitespace-pre" initial={swap.initial} animate={swap.animate} exit={{ ...swap.exit, transition: { duration: 0.14, ease: EASE_OUT }, }} transition={{ duration: 0.24, ease: EASE_OUT }} > {word} </motion.span> </AnimatePresence> </motion.span> </span> );}Usage
import { WordWell } from '@/components/ui/word-well';
export default function Example() {
return (
<h2 className="text-3xl font-semibold">
Ship <WordWell words={['faster', 'safer', 'together']} /> with Liten
</h2>
);
}Examples
Each word carries its own emoji as part of the string, so it rolls through the well with the word and the width springs to fit the pair.
The team is 🚀 shipping today
Props
| Prop | Type | Default | Description |
|---|---|---|---|
words | string[] | - | The words that rotate through the well. |
interval | number | 2600 | Milliseconds between swaps. |
accent | string | "#f0883e" | Colors the focus outline only. |
className | string | - | Forwarded to the well. |