liten

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.

Terminal
bun add motion

Copy the component into components/ui/word-well.tsx.

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

Example.tsx
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

PropTypeDefaultDescription
wordsstring[]-The words that rotate through the well.
intervalnumber2600Milliseconds between swaps.
accentstring"#f0883e"Colors the focus outline only.
classNamestring-Forwarded to the well.
On this page0%