Skip to content
Motion
FreeIn reviewv1.0.0

Liquid Word Swap

One word in a headline cycles through options with a liquid morph: "Websites that sell, book, grow".

npx shadcn@latest add @vibebox/liquid-word-swap

Controls

Tone
2.4
2
<LiquidWordSwap />

About

A headline that says three things. One word cycles through your options: the outgoing word drips downward and fades while the incoming one wells up letter by letter, and for the moment the two overlap a goo filter merges them like liquid. The filter is on only while letters move, so the resting word is crisp. The longest option reserves the space, so the line never jumps. It pauses while the pointer or focus is on it, stops on the first word after a few loops, and never moves under reduced motion; screen readers hear the options once as a list. Original to VibeBox Motion.

Use it when

  • A hero headline serves several audiences or offers ("sell, book, grow").
  • A short, punchy line needs one moving part.

Skip it when

  • The options are long sentences; one to three words each read best.
  • More than one swapping word would be on screen.

Install

  1. 1Add the VibeBox registry to components.json:
    {
      "registries": {
        "@vibebox": "https://motion.vibeboxph.com/r/{name}.json"
      }
    }
  2. 2Add the component with the shadcn CLI:
    npx shadcn@latest add @vibebox/liquid-word-swap
  3. 3Use it:
    import { LiquidWordSwap } from "@/components/vibebox/liquid-word-swap";
    
    <h1 className="text-5xl font-semibold">
      Websites that <LiquidWordSwap words={["sell", "book", "grow"]} tone="spectrum" />
    </h1>

Code

2 files
components/vibebox/liquid-word-swap/index.tsx
/**
 * VibeBox Motion · Liquid Word Swap
 * Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
 * https://motion.vibeboxph.com/license
 * Use it in unlimited personal, commercial and client projects.
 * Do not sell or redistribute it as a component, kit, template or builder.
 */
export { LiquidWordSwap, type LiquidWordSwapProps } from "./liquid-word-swap.client";
components/vibebox/liquid-word-swap/liquid-word-swap.client.tsx
/**
 * VibeBox Motion · Liquid Word Swap
 * Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
 * https://motion.vibeboxph.com/license
 * Use it in unlimited personal, commercial and client projects.
 * Do not sell or redistribute it as a component, kit, template or builder.
 */
"use client";

import { useEffect, useId, useRef, useState, type CSSProperties } from "react";
import { cn } from "@/lib/utils";

/*
 * One word in a headline that cycles through options with a liquid morph: the old word drips
 * away as the new one wells up, letter by letter, and a goo filter (on only while letters move)
 * merges them like liquid. It pauses on hover and focus, stops after a few loops on the first
 * word, and never moves under reduced motion. Screen readers hear every option once.
 */

const KEYFRAMES = `
@keyframes vb-lws-in { from { transform: translateY(0.55em) scale(0.7, 1.25); opacity: 0; } 55% { opacity: 1; } to { transform: none; opacity: 1; } }
@keyframes vb-lws-out { to { transform: translateY(0.45em) scale(1.15, 0.6); opacity: 0; } }
@media (prefers-reduced-motion: no-preference) {
  [data-vb="liquid-word-swap"][data-state="swapping"] [data-vb-lws-word="in"] [data-vb-lws-letter] {
    animation: vb-lws-in 640ms var(--ease-expo-out, cubic-bezier(0.16, 1, 0.3, 1)) both;
    animation-delay: calc(var(--vb-lws-i) * 38ms);
  }
  [data-vb="liquid-word-swap"][data-state="swapping"] [data-vb-lws-word="out"] [data-vb-lws-letter] {
    animation: vb-lws-out 420ms cubic-bezier(0.5, 0, 0.75, 0) both;
    animation-delay: calc(var(--vb-lws-i) * 28ms);
  }
}
[data-vb-lws-tone="spectrum"] {
  background-image: var(--vb-gradient-spectrum-text);
  background-size: calc(var(--vb-lws-n) * 100%) 100%;
  background-position: calc(var(--vb-lws-i) * 100% / max(var(--vb-lws-n) - 1, 1)) 0;
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}
`;

export type LiquidWordSwapProps = {
  /** The options, in order; the first is where it starts and stops. */
  words: readonly string[];
  /** Seconds each word holds. */
  interval?: number;
  /** Full cycles before it settles on the first word. Use Infinity only with a pause control nearby. */
  loops?: number;
  /** Inherit the heading's colour, paint the word with the spectrum, or switch it to the serif voice. */
  tone?: "inherit" | "spectrum" | "serif";
  /** Where shorter words sit in the space the longest one reserves. */
  align?: "start" | "center";
  /** What screen readers hear instead of the list of words. */
  srLabel?: string;
  className?: string;
};

export function LiquidWordSwap({ words, interval = 2.4, loops = 2, tone = "inherit", align = "start", srLabel, className }: LiquidWordSwapProps) {
  const rootRef = useRef<HTMLSpanElement>(null);
  const filterId = `vb-lws-${useId().replace(/[^a-zA-Z0-9-]/g, "")}`;
  const [active, setActive] = useState(0);
  const [leaving, setLeaving] = useState<number | null>(null);
  const [blur, setBlur] = useState(3);
  const [paused, setPaused] = useState(false);
  const [visible, setVisible] = useState(false);
  const [swaps, setSwaps] = useState(0);
  const total = Math.max(1, loops) * words.length;
  const key = words.join("\u0000");

  // A fresh list starts over.
  useEffect(() => {
    setActive(0);
    setLeaving(null);
    setSwaps(0);
  }, [key, loops]);

  useEffect(() => {
    const root = rootRef.current;
    if (!root) return;
    // The goo radius follows the font size, so the merge looks the same at any size.
    setBlur(Math.max(1.5, parseFloat(getComputedStyle(root).fontSize) * 0.07));
    const observer = new IntersectionObserver(([entry]) => setVisible(Boolean(entry?.isIntersecting)));
    observer.observe(root);
    const onVisibility = () => setVisible(!document.hidden && root.getBoundingClientRect().bottom > 0);
    document.addEventListener("visibilitychange", onVisibility);
    return () => {
      observer.disconnect();
      document.removeEventListener("visibilitychange", onVisibility);
    };
  }, []);

  const playing = visible && !paused && words.length > 1 && swaps < total;
  useEffect(() => {
    if (!playing || leaving !== null) return;
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
    const timer = window.setTimeout(() => {
      setLeaving(active);
      setActive((index) => (index + 1) % words.length);
      setSwaps((count) => count + 1);
    }, interval * 1000);
    return () => window.clearTimeout(timer);
  }, [playing, leaving, active, interval, words.length]);

  // The swap ends when the last incoming letter lands.
  useEffect(() => {
    if (leaving === null) return;
    const longest = Math.max([...(words[active] ?? "")].length, [...(words[leaving] ?? "")].length);
    const timer = window.setTimeout(() => setLeaving(null), 700 + longest * 38);
    return () => window.clearTimeout(timer);
  }, [leaving, active, words]);

  const label = srLabel ?? new Intl.ListFormat("en", { type: "disjunction" }).format(words);

  return (
    <span
      ref={rootRef}
      data-vb="liquid-word-swap"
      data-state={leaving === null ? "rest" : "swapping"}
      onPointerEnter={() => setPaused(true)}
      onPointerLeave={() => setPaused(false)}
      onFocus={() => setPaused(true)}
      onBlur={() => setPaused(false)}
      className={cn("relative inline-grid align-baseline", className)}
    >
      <style href="vb-liquid-word-swap" precedence="vb">
        {KEYFRAMES}
      </style>
      <span className="sr-only">{label}</span>
      <span aria-hidden="true" className="inline-grid" style={leaving === null ? undefined : { filter: `url(#${filterId})` }}>
        {words.map((word, wordIndex) => {
          const role = wordIndex === active ? "in" : wordIndex === leaving ? "out" : "idle";
          const letters = [...word];
          return (
            <span
              key={`${word}-${wordIndex}`}
              data-vb-lws-word={role}
              className={cn(
                "col-start-1 row-start-1 whitespace-nowrap",
                align === "center" ? "justify-self-center" : "justify-self-start",
                role === "idle" && "invisible",
                tone === "serif" && "font-serif font-normal italic",
              )}
            >
              {letters.map((char, index) => (
                <span
                  key={index}
                  data-vb-lws-letter=""
                  data-vb-lws-tone={tone === "spectrum" ? "spectrum" : undefined}
                  className="inline-block"
                  style={{ "--vb-lws-i": index, "--vb-lws-n": letters.length } as CSSProperties}
                >
                  {char === " " ? " " : char}
                </span>
              ))}
            </span>
          );
        })}
      </span>
      <svg aria-hidden="true" width="0" height="0" className="absolute">
        <filter id={filterId}>
          <feGaussianBlur in="SourceGraphic" stdDeviation={blur} result="blur" />
          <feColorMatrix in="blur" values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 18 -7" result="goo" />
          <feComposite in="SourceGraphic" in2="goo" operator="atop" />
        </filter>
      </svg>
    </span>
  );
}

Props

PropTypeDefaultDescription
words *readonly string[]—The options, in order; the first is where it starts and stops.
intervalnumber2.4Seconds each word holds.
loopsnumber2Full cycles before it settles on the first word. Use Infinity only with a pause control nearby.
tone"inherit" | "spectrum" | "serif""inherit"The heading's colour, the spectrum gradient, or the serif voice.
align"start" | "center""start"Where shorter words sit in the space the longest reserves.
srLabelstring—What screen readers hear instead of the list of words.
classNamestring—Merged with cn().

Accessibility and motion

Keyboard
Not focusable; focus inside a parent link or heading pauses it.
Screen readers
Announces the options once as a list ("sell, book, or grow"), or srLabel; the moving letters are hidden.
Touch
Nothing to touch; it stops on its own after loops cycles.
Reduced motion
The first word only; no swapping.

Edge cases and limits

  • Very different word lengths leave space after short words; use align="center" in centred headings.
  • Emoji and combining characters are split by code point and may animate oddly; prefer plain words.
  • A large headline whose words rise into place one after another, with a serif and a gradient accent

    A headline that rises into place word by word behind a mask, on CSS alone, with serif and spectrum accents.

    Text · CSS

  • Three statistics whose digits spin into place

    Rolling digits for real numbers: each digit spins once into place when visible, and screen readers get the value.

    Text · CSS

  • A ring of light bars pulsing around a floating glass box

    A crown of light bars pulsing like a visualizer around a floating glass box; bars rise toward the pointer.

    3D · three.js

↑ ↓ to move↵ to openesc to close