Web InnoventixFreeCode

Wave Text Effect

Original · free

wavy letter animation

byWeb InnoventixReact + Tailwind
txfxwavetext-effects
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/txfx-wave.json
txfx-wave.tsx
"use client";

import { useId, useMemo, useState } from "react";
import { motion, useReducedMotion } from "motion/react";

type Theme = {
  id: string;
  name: string;
  gradient: string;
  accent: string;
  ring: string;
  chip: string;
  bar: string;
};

const THEMES: Theme[] = [
  {
    id: "aurora",
    name: "Aurora",
    gradient:
      "from-indigo-500 to-violet-600 dark:from-indigo-300 dark:to-violet-400",
    accent: "accent-indigo-500",
    ring: "focus-visible:ring-indigo-500",
    chip: "bg-gradient-to-br from-indigo-400 to-violet-600",
    bar: "from-indigo-500 via-violet-500 to-sky-500",
  },
  {
    id: "ember",
    name: "Ember",
    gradient:
      "from-amber-500 to-rose-600 dark:from-amber-300 dark:to-rose-400",
    accent: "accent-rose-500",
    ring: "focus-visible:ring-rose-500",
    chip: "bg-gradient-to-br from-amber-400 to-rose-600",
    bar: "from-amber-500 via-rose-500 to-amber-500",
  },
  {
    id: "meadow",
    name: "Meadow",
    gradient:
      "from-emerald-500 to-sky-600 dark:from-emerald-300 dark:to-sky-400",
    accent: "accent-emerald-500",
    ring: "focus-visible:ring-emerald-500",
    chip: "bg-gradient-to-br from-emerald-400 to-sky-600",
    bar: "from-emerald-500 via-sky-500 to-emerald-500",
  },
];

const DEFAULTS = {
  text: "Words that move like water",
  amplitude: 18,
  speed: 1.4,
  stagger: 0.075,
  themeId: "aurora",
  playing: true,
};

type WaveLetterProps = {
  ch: string;
  idx: number;
  amplitude: number;
  duration: number;
  stagger: number;
  active: boolean;
  gradient: string;
};

function WaveLetter({
  ch,
  idx,
  amplitude,
  duration,
  stagger,
  active,
  gradient,
}: WaveLetterProps) {
  return (
    <motion.span
      aria-hidden="true"
      className={`inline-block bg-gradient-to-b ${gradient} bg-clip-text text-transparent [will-change:transform]`}
      animate={active ? { y: [0, -amplitude, 0, amplitude, 0] } : { y: 0 }}
      transition={
        active
          ? {
              duration,
              times: [0, 0.25, 0.5, 0.75, 1],
              ease: "easeInOut",
              repeat: Infinity,
              delay: idx * stagger,
            }
          : { duration: 0.4, ease: "easeOut" }
      }
    >
      {ch}
    </motion.span>
  );
}

type RangeControlProps = {
  id: string;
  label: string;
  hint: string;
  value: number;
  min: number;
  max: number;
  step: number;
  accent: string;
  ring: string;
  display: string;
  onChange: (v: number) => void;
};

function RangeControl({
  id,
  label,
  hint,
  value,
  min,
  max,
  step,
  accent,
  ring,
  display,
  onChange,
}: RangeControlProps) {
  return (
    <div className="flex flex-col gap-2">
      <div className="flex items-baseline justify-between gap-3">
        <label
          htmlFor={id}
          className="text-sm font-medium text-slate-700 dark:text-slate-200"
        >
          {label}
        </label>
        <span className="font-mono text-xs tabular-nums text-slate-500 dark:text-slate-400">
          {display}
        </span>
      </div>
      <input
        id={id}
        type="range"
        min={min}
        max={max}
        step={step}
        value={value}
        aria-valuetext={display}
        onChange={(e) => onChange(Number(e.target.value))}
        className={`h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-200 dark:bg-slate-700 ${accent} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950 ${ring}`}
      />
      <p className="text-xs text-slate-400 dark:text-slate-500">{hint}</p>
    </div>
  );
}

export default function TxfxWave() {
  const reduce = useReducedMotion();
  const uid = useId();

  const [text, setText] = useState(DEFAULTS.text);
  const [amplitude, setAmplitude] = useState(DEFAULTS.amplitude);
  const [speed, setSpeed] = useState(DEFAULTS.speed);
  const [stagger, setStagger] = useState(DEFAULTS.stagger);
  const [themeId, setThemeId] = useState(DEFAULTS.themeId);
  const [playing, setPlaying] = useState(DEFAULTS.playing);

  const theme = THEMES.find((t) => t.id === themeId) ?? THEMES[0];
  const active = playing && !reduce;
  const duration = 1.8 / speed;

  const model = useMemo(() => {
    const source = text.length > 0 ? text : " ";
    const words = source.split(" ");
    let idx = 0;
    return words.map((word, wi) => {
      const letters = Array.from(word).map((ch) => ({ ch, idx: idx++ }));
      const withSpace = wi < words.length - 1;
      if (withSpace) idx += 1;
      return { key: `w-${wi}`, letters, withSpace };
    });
  }, [text]);

  const reset = () => {
    setText(DEFAULTS.text);
    setAmplitude(DEFAULTS.amplitude);
    setSpeed(DEFAULTS.speed);
    setStagger(DEFAULTS.stagger);
    setThemeId(DEFAULTS.themeId);
    setPlaying(DEFAULTS.playing);
  };

  const status = reduce
    ? "Motion is disabled by your system's reduced-motion setting."
    : `Wave ${active ? "playing" : "paused"}, height ${amplitude} pixels, speed ${speed.toFixed(
        1
      )} times, spread ${Math.round(stagger * 1000)} milliseconds.`;

  return (
    <section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 to-white px-6 py-20 text-slate-900 dark:from-slate-950 dark:to-slate-900 dark:text-slate-100 sm:py-28">
      <style>{`
        @keyframes txfxwave-shimmer {
          0% { background-position: 0% 50%; }
          100% { background-position: 200% 50%; }
        }
        @keyframes txfxwave-float {
          0%, 100% { transform: translate(0, 0) scale(1); }
          50% { transform: translate(22px, -18px) scale(1.08); }
        }
        @keyframes txfxwave-float-2 {
          0%, 100% { transform: translate(0, 0) scale(1); }
          50% { transform: translate(-26px, 20px) scale(1.06); }
        }
        .txfxwave-bar {
          background-size: 200% auto;
          animation: txfxwave-shimmer 3.5s linear infinite;
        }
        .txfxwave-blob { animation: txfxwave-float 15s ease-in-out infinite; }
        .txfxwave-blob.alt { animation: txfxwave-float-2 17s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .txfxwave-bar, .txfxwave-blob { animation: none !important; }
        }
      `}</style>

      {/* Ambient background */}
      <div aria-hidden="true" className="pointer-events-none absolute inset-0">
        <div className="txfxwave-blob absolute -left-24 top-4 h-72 w-72 rounded-full bg-indigo-300/40 blur-3xl dark:bg-indigo-600/20" />
        <div className="txfxwave-blob alt absolute -right-20 bottom-0 h-80 w-80 rounded-full bg-violet-300/40 blur-3xl dark:bg-violet-600/20" />
        <div
          className="absolute inset-0 opacity-[0.5] dark:opacity-[0.25]"
          style={{
            backgroundImage:
              "radial-gradient(circle at 1px 1px, rgb(100 116 139 / 0.25) 1px, transparent 0)",
            backgroundSize: "26px 26px",
          }}
        />
      </div>

      <div className="relative z-10 mx-auto max-w-4xl">
        <div className="flex items-center gap-2">
          <span
            className={`inline-block h-2 w-2 rounded-full ${theme.chip}`}
            aria-hidden="true"
          />
          <span className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500 dark:text-slate-400">
            Text effects · Per-letter motion
          </span>
        </div>

        <h1 className="mt-6 text-balance text-4xl font-black leading-[1.08] tracking-tight sm:text-6xl">
          <span className="sr-only">{text || "Wavy text preview"}</span>
          <span aria-hidden="true" className="inline">
            {model.map((word) => (
              <span key={word.key} className="inline-block whitespace-nowrap">
                {word.letters.map((l) => (
                  <WaveLetter
                    key={`${word.key}-${l.idx}`}
                    ch={l.ch}
                    idx={l.idx}
                    amplitude={amplitude}
                    duration={duration}
                    stagger={stagger}
                    active={active}
                    gradient={theme.gradient}
                  />
                ))}
                {word.withSpace ? <span> </span> : null}
              </span>
            ))}
          </span>
        </h1>

        <div
          className={`txfxwave-bar mt-7 h-1 w-40 rounded-full bg-gradient-to-r ${theme.bar}`}
          aria-hidden="true"
        />

        <p className="mt-6 max-w-xl text-base leading-relaxed text-slate-600 dark:text-slate-300">
          A per-letter wave built with real controls. Tune the height, speed,
          and spread, pick a palette, then type your own line and watch each
          character ride the curve.
        </p>

        {/* Control panel */}
        <div className="mt-10 rounded-2xl border border-slate-200 bg-white/70 p-6 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/60 sm:p-8">
          <div className="flex flex-wrap items-center justify-between gap-4">
            <button
              type="button"
              onClick={() => setPlaying((p) => !p)}
              disabled={!!reduce}
              aria-pressed={active}
              className={`inline-flex items-center gap-2 rounded-full bg-slate-900 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-700 disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200 dark:focus-visible:ring-offset-slate-950 ${theme.ring}`}
            >
              {active ? (
                <svg
                  aria-hidden="true"
                  viewBox="0 0 24 24"
                  className="h-4 w-4"
                  fill="currentColor"
                >
                  <rect x="6" y="5" width="4" height="14" rx="1" />
                  <rect x="14" y="5" width="4" height="14" rx="1" />
                </svg>
              ) : (
                <svg
                  aria-hidden="true"
                  viewBox="0 0 24 24"
                  className="h-4 w-4"
                  fill="currentColor"
                >
                  <path d="M8 5.14v13.72a1 1 0 0 0 1.5.86l11-6.86a1 1 0 0 0 0-1.72l-11-6.86A1 1 0 0 0 8 5.14Z" />
                </svg>
              )}
              {reduce ? "Motion off" : active ? "Pause" : "Play"}
            </button>

            <div
              role="group"
              aria-label="Colour palette"
              className="flex flex-wrap items-center gap-2"
            >
              {THEMES.map((t) => {
                const selected = t.id === themeId;
                return (
                  <button
                    key={t.id}
                    type="button"
                    onClick={() => setThemeId(t.id)}
                    aria-pressed={selected}
                    className={`inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950 ${t.ring} ${
                      selected
                        ? "border-slate-900 bg-slate-900 text-white dark:border-white dark:bg-white dark:text-slate-900"
                        : "border-slate-200 bg-white text-slate-600 hover:border-slate-300 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-300 dark:hover:border-slate-600"
                    }`}
                  >
                    <span
                      className={`h-3 w-3 rounded-full ${t.chip}`}
                      aria-hidden="true"
                    />
                    {t.name}
                  </button>
                );
              })}
            </div>
          </div>

          <div className="mt-8 grid gap-6 sm:grid-cols-3">
            <RangeControl
              id={`${uid}-amp`}
              label="Wave height"
              hint="How far each letter rises and falls."
              value={amplitude}
              min={4}
              max={48}
              step={2}
              accent={theme.accent}
              ring={theme.ring}
              display={`${amplitude}px`}
              onChange={setAmplitude}
            />
            <RangeControl
              id={`${uid}-speed`}
              label="Wave speed"
              hint="Cycles faster as you turn it up."
              value={speed}
              min={0.5}
              max={3}
              step={0.1}
              accent={theme.accent}
              ring={theme.ring}
              display={`${speed.toFixed(1)}×`}
              onChange={setSpeed}
            />
            <RangeControl
              id={`${uid}-spread`}
              label="Wave spread"
              hint="Delay between neighbouring letters."
              value={stagger}
              min={0.02}
              max={0.16}
              step={0.005}
              accent={theme.accent}
              ring={theme.ring}
              display={`${Math.round(stagger * 1000)}ms`}
              onChange={setStagger}
            />
          </div>

          <div className="mt-8 flex flex-col gap-2">
            <label
              htmlFor={`${uid}-text`}
              className="text-sm font-medium text-slate-700 dark:text-slate-200"
            >
              Your text
            </label>
            <div className="flex flex-col gap-3 sm:flex-row">
              <input
                id={`${uid}-text`}
                type="text"
                value={text}
                maxLength={48}
                placeholder="Type something to animate…"
                onChange={(e) => setText(e.target.value)}
                className={`w-full rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm text-slate-900 placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-100 dark:placeholder:text-slate-500 dark:focus-visible:ring-offset-slate-950 ${theme.ring}`}
              />
              <button
                type="button"
                onClick={reset}
                className={`shrink-0 rounded-xl border border-slate-200 px-4 py-2.5 text-sm font-semibold text-slate-700 transition hover:bg-slate-100 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950 ${theme.ring}`}
              >
                Reset
              </button>
            </div>
            <p className="text-xs text-slate-400 dark:text-slate-500">
              {text.length}/48 characters · Tab to each control, then use arrow
              keys to adjust.
            </p>
          </div>
        </div>

        <p aria-live="polite" className="sr-only">
          {status}
        </p>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →