Web InnoventixFreeCode

Split Hover Text Effect

Original · free

letters that split on hover

byWeb InnoventixReact + Tailwind
txfxsplithovertext-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-split-hover.json
txfx-split-hover.tsx
"use client";

import {
  useId,
  useMemo,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent,
} from "react";
import { motion, useReducedMotion } from "motion/react";

type Axis = "vertical" | "horizontal";

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

  const textId = `${uid}-text`;
  const axisLabelId = `${uid}-axis`;
  const axisName = `${uid}-axis-name`;
  const distId = `${uid}-dist`;
  const accentLabelId = `${uid}-accent`;
  const waveLabelId = `${uid}-wave`;
  const hintId = `${uid}-hint`;

  const [text, setText] = useState("SPLIT");
  const [axis, setAxis] = useState<Axis>("vertical");
  const [distance, setDistance] = useState(0.42);
  const [accent, setAccent] = useState(true);
  const [wave, setWave] = useState(false);

  const [hoverIndex, setHoverIndex] = useState<number | null>(null);
  const [kbIndex, setKbIndex] = useState<number | null>(null);
  const stageRef = useRef<HTMLDivElement | null>(null);

  const chars = useMemo(() => Array.from(text), [text]);
  const letterPositions = useMemo(
    () =>
      chars
        .map((c, i) => ({ c, i }))
        .filter((x) => x.c !== " ")
        .map((x) => x.i),
    [chars],
  );

  const moveKb = (dir: 1 | -1) => {
    setKbIndex((prev) => {
      if (letterPositions.length === 0) return null;
      if (prev == null) return letterPositions[dir === 1 ? 0 : letterPositions.length - 1];
      const pos = letterPositions.indexOf(prev);
      const nextPos = Math.min(letterPositions.length - 1, Math.max(0, pos + dir));
      return letterPositions[nextPos];
    });
  };

  const onStageKey = (e: KeyboardEvent<HTMLDivElement>) => {
    if (e.key === "ArrowRight" || e.key === "ArrowDown") {
      e.preventDefault();
      moveKb(1);
    } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
      e.preventDefault();
      moveKb(-1);
    } else if (e.key === "Home") {
      e.preventDefault();
      setKbIndex(letterPositions[0] ?? null);
    } else if (e.key === "End") {
      e.preventDefault();
      setKbIndex(letterPositions[letterPositions.length - 1] ?? null);
    } else if (e.key === "Escape") {
      stageRef.current?.blur();
    }
  };

  const reset = () => {
    setText("SPLIT");
    setAxis("vertical");
    setDistance(0.42);
    setAccent(true);
    setWave(false);
  };

  const stageClasses = [
    "txfxsh-stage",
    axis === "vertical" ? "txfxsh-y" : "txfxsh-x",
    accent ? "txfxsh-accent-on" : "",
    wave && !reduce ? "txfxsh-wave" : "",
  ]
    .filter(Boolean)
    .join(" ");

  const activeChar = kbIndex != null ? chars[kbIndex] ?? "" : "";

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-6 py-20 text-slate-900 dark:bg-slate-950 dark:text-slate-100 sm:py-28">
      <style>{`
        .txfxsh-stage { --txfxsh-dur: 480ms; --txfxsh-ease: cubic-bezier(.16,1,.3,1); }

        .txfxsh-letter {
          position: relative;
          display: inline-block;
          margin: 0 0.015em;
          color: inherit;
        }
        .txfxsh-space { display: inline-block; width: 0.34em; }
        .txfxsh-sizer { visibility: hidden; }

        .txfxsh-face {
          position: absolute;
          top: 0;
          left: 0;
          pointer-events: none;
          transition: transform var(--txfxsh-dur) var(--txfxsh-ease);
        }
        .txfxsh-mid {
          opacity: 0;
          transition: opacity 300ms ease;
          -webkit-background-clip: text;
          background-clip: text;
          color: transparent;
        }

        .txfxsh-y .txfxsh-a { clip-path: inset(-4% 0 50% 0); }
        .txfxsh-y .txfxsh-b { clip-path: inset(50% 0 -4% 0); }
        .txfxsh-x .txfxsh-a { clip-path: inset(0 50% 0 -4%); }
        .txfxsh-x .txfxsh-b { clip-path: inset(0 -4% 0 50%); }

        .txfxsh-y .txfxsh-mid { background-image: linear-gradient(180deg,#6366f1,#a855f7 50%,#0ea5e9); }
        .txfxsh-x .txfxsh-mid { background-image: linear-gradient(90deg,#6366f1,#a855f7 50%,#0ea5e9); }

        .txfxsh-y .txfxsh-letter.is-split .txfxsh-a { transform: translateY(calc(var(--txfxsh-d) * -1em)); }
        .txfxsh-y .txfxsh-letter.is-split .txfxsh-b { transform: translateY(calc(var(--txfxsh-d) * 1em)); }
        .txfxsh-x .txfxsh-letter.is-split .txfxsh-a { transform: translateX(calc(var(--txfxsh-d) * -1em)); }
        .txfxsh-x .txfxsh-letter.is-split .txfxsh-b { transform: translateX(calc(var(--txfxsh-d) * 1em)); }

        .txfxsh-accent-on .txfxsh-letter.is-split .txfxsh-mid { opacity: 1; }

        .txfxsh-letter::after {
          content: "";
          position: absolute;
          pointer-events: none;
          opacity: 0;
          transition: transform var(--txfxsh-dur) var(--txfxsh-ease), opacity 260ms ease;
        }
        .txfxsh-y .txfxsh-letter::after {
          left: -10%; right: -10%; top: 50%; height: 2px;
          transform: translateY(-50%) scaleX(0);
          background: linear-gradient(90deg, transparent, #a855f7, transparent);
        }
        .txfxsh-x .txfxsh-letter::after {
          top: -10%; bottom: -10%; left: 50%; width: 2px;
          transform: translateX(-50%) scaleY(0);
          background: linear-gradient(180deg, transparent, #a855f7, transparent);
        }
        .txfxsh-accent-on.txfxsh-y .txfxsh-letter.is-split::after { transform: translateY(-50%) scaleX(1); opacity: .85; }
        .txfxsh-accent-on.txfxsh-x .txfxsh-letter.is-split::after { transform: translateX(-50%) scaleY(1); opacity: .85; }

        @keyframes txfxsh-wave-ay { 0%,16%,100% { transform: translateY(0); } 50% { transform: translateY(calc(var(--txfxsh-d) * -1em)); } }
        @keyframes txfxsh-wave-by { 0%,16%,100% { transform: translateY(0); } 50% { transform: translateY(calc(var(--txfxsh-d) * 1em)); } }
        @keyframes txfxsh-wave-ax { 0%,16%,100% { transform: translateX(0); } 50% { transform: translateX(calc(var(--txfxsh-d) * -1em)); } }
        @keyframes txfxsh-wave-bx { 0%,16%,100% { transform: translateX(0); } 50% { transform: translateX(calc(var(--txfxsh-d) * 1em)); } }
        @keyframes txfxsh-wave-mid { 0%,16%,100% { opacity: 0; } 50% { opacity: 1; } }

        .txfxsh-wave.txfxsh-y .txfxsh-a { animation: txfxsh-wave-ay 2.6s var(--txfxsh-ease) infinite; animation-delay: calc(var(--i) * 0.08s); }
        .txfxsh-wave.txfxsh-y .txfxsh-b { animation: txfxsh-wave-by 2.6s var(--txfxsh-ease) infinite; animation-delay: calc(var(--i) * 0.08s); }
        .txfxsh-wave.txfxsh-x .txfxsh-a { animation: txfxsh-wave-ax 2.6s var(--txfxsh-ease) infinite; animation-delay: calc(var(--i) * 0.08s); }
        .txfxsh-wave.txfxsh-x .txfxsh-b { animation: txfxsh-wave-bx 2.6s var(--txfxsh-ease) infinite; animation-delay: calc(var(--i) * 0.08s); }
        .txfxsh-wave.txfxsh-accent-on .txfxsh-mid { animation: txfxsh-wave-mid 2.6s ease-in-out infinite; animation-delay: calc(var(--i) * 0.08s); }

        @media (prefers-reduced-motion: reduce) {
          .txfxsh-face, .txfxsh-mid, .txfxsh-letter::after { transition-duration: 1ms !important; }
          .txfxsh-wave .txfxsh-a, .txfxsh-wave .txfxsh-b, .txfxsh-wave .txfxsh-mid { animation: none !important; }
        }
      `}</style>

      {/* atmosphere */}
      <div
        aria-hidden="true"
        className="pointer-events-none absolute -left-24 -top-24 h-72 w-72 rounded-full bg-indigo-400/20 blur-3xl dark:bg-indigo-600/20"
      />
      <div
        aria-hidden="true"
        className="pointer-events-none absolute -bottom-32 right-[-6rem] h-80 w-80 rounded-full bg-sky-400/20 blur-3xl dark:bg-violet-600/20"
      />

      <div className="relative mx-auto max-w-5xl">
        <header className="text-center">
          <span className="inline-flex items-center gap-2 rounded-full border border-slate-300 bg-white/70 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-slate-600 backdrop-blur dark:border-slate-700 dark:bg-slate-900/70 dark:text-slate-400">
            <span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
            Text effect · Split hover
          </span>
          <h2 className="mt-6 text-2xl font-semibold tracking-tight text-slate-900 dark:text-white sm:text-3xl">
            Letters that come apart at the seam
          </h2>
          <p className="mx-auto mt-3 max-w-xl text-pretty text-sm leading-relaxed text-slate-600 dark:text-slate-400">
            Each glyph is sliced through its centre. Point at a letter and the two halves glide
            apart to reveal an accent core underneath. Prefer the keyboard? Focus the line and steer
            with the arrow keys.
          </p>
        </header>

        {/* stage */}
        <div className="mt-14 flex justify-center">
          <div
            ref={stageRef}
            role="group"
            tabIndex={0}
            aria-label={`Interactive headline reading ${text || "empty"}. Split-letter hover effect.`}
            aria-describedby={hintId}
            onKeyDown={onStageKey}
            onFocus={() => setKbIndex((prev) => prev ?? (letterPositions[0] ?? null))}
            onBlur={() => setKbIndex(null)}
            className="rounded-3xl px-4 py-6 outline-none transition focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-4 focus-visible:ring-offset-slate-50 dark:focus-visible:ring-offset-slate-950 sm:px-8 sm:py-10"
          >
            <div
              aria-hidden="true"
              style={{ "--txfxsh-d": distance } as CSSProperties}
              className={`${stageClasses} select-none text-center text-6xl font-black leading-[0.9] tracking-tight text-slate-900 dark:text-white sm:text-7xl md:text-8xl`}
            >
              {chars.length === 0 ? (
                <span className="text-3xl font-medium text-slate-400 dark:text-slate-600">
                  Type a headline below
                </span>
              ) : (
                chars.map((ch, i) => {
                  if (ch === " ") {
                    return (
                      <span key={i} className="txfxsh-space">
                        &nbsp;
                      </span>
                    );
                  }
                  const split = hoverIndex === i || kbIndex === i;
                  return (
                    <motion.span
                      key={i}
                      className={`txfxsh-letter${split ? " is-split" : ""}`}
                      style={{ "--i": i } as CSSProperties}
                      onMouseEnter={() => setHoverIndex(i)}
                      onMouseLeave={() => setHoverIndex((h) => (h === i ? null : h))}
                      initial={reduce ? false : { opacity: 0, y: 18 }}
                      animate={reduce ? undefined : { opacity: 1, y: 0 }}
                      transition={{ duration: 0.5, delay: i * 0.025, ease: [0.16, 1, 0.3, 1] }}
                    >
                      <span className="txfxsh-sizer">{ch}</span>
                      <span className="txfxsh-face txfxsh-mid">{ch}</span>
                      <span className="txfxsh-face txfxsh-a">{ch}</span>
                      <span className="txfxsh-face txfxsh-b">{ch}</span>
                    </motion.span>
                  );
                })
              )}
            </div>
            <span className="sr-only" aria-live="polite">
              {activeChar ? `Focused letter ${activeChar}, split open` : ""}
            </span>
          </div>
        </div>

        <p id={hintId} className="mt-6 text-center text-xs text-slate-500 dark:text-slate-500">
          Hover any letter — or press Tab to the line, then use ← and → to split each one.
        </p>

        {/* controls */}
        <div className="mx-auto mt-12 max-w-2xl rounded-2xl border border-slate-200 bg-white/80 p-6 shadow-sm backdrop-blur dark:border-slate-800 dark:bg-slate-900/60">
          <div className="flex items-center justify-between">
            <h3 className="text-sm font-semibold text-slate-800 dark:text-slate-200">Controls</h3>
            <button
              type="button"
              onClick={reset}
              className="rounded-lg px-2.5 py-1 text-xs font-medium text-slate-500 transition hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-400 dark:hover:text-white dark:focus-visible:ring-offset-slate-900"
            >
              Reset
            </button>
          </div>

          <div className="mt-5 grid gap-6 sm:grid-cols-2">
            {/* text */}
            <div className="sm:col-span-2">
              <label
                htmlFor={textId}
                className="mb-1.5 block text-xs font-medium text-slate-600 dark:text-slate-400"
              >
                Headline text
              </label>
              <input
                id={textId}
                type="text"
                value={text}
                maxLength={16}
                onChange={(e) => setText(e.target.value)}
                placeholder="Type something"
                className="w-full rounded-xl border border-slate-300 bg-white px-3.5 py-2.5 text-sm font-medium text-slate-900 placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:bg-slate-950 dark:text-white dark:placeholder:text-slate-600 dark:focus-visible:ring-offset-slate-900"
              />
            </div>

            {/* axis */}
            <fieldset className="m-0 border-0 p-0">
              <legend
                id={axisLabelId}
                className="mb-1.5 block text-xs font-medium text-slate-600 dark:text-slate-400"
              >
                Split axis
              </legend>
              <div
                role="radiogroup"
                aria-labelledby={axisLabelId}
                className="grid grid-cols-2 gap-2"
              >
                {(["vertical", "horizontal"] as Axis[]).map((v) => (
                  <label key={v} className="relative cursor-pointer">
                    <input
                      type="radio"
                      name={axisName}
                      value={v}
                      checked={axis === v}
                      onChange={() => setAxis(v)}
                      className="peer sr-only"
                    />
                    <span className="block rounded-xl border border-slate-300 bg-white px-3 py-2 text-center text-sm font-medium text-slate-600 transition peer-checked:border-indigo-500 peer-checked:bg-indigo-50 peer-checked:text-indigo-700 peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-white dark:border-slate-700 dark:bg-slate-950 dark:text-slate-400 dark:peer-checked:border-indigo-500 dark:peer-checked:bg-indigo-500/15 dark:peer-checked:text-indigo-300 dark:peer-focus-visible:ring-offset-slate-900">
                      {v === "vertical" ? "Vertical" : "Horizontal"}
                    </span>
                  </label>
                ))}
              </div>
            </fieldset>

            {/* distance */}
            <div>
              <div className="mb-1.5 flex items-center justify-between">
                <label
                  htmlFor={distId}
                  className="text-xs font-medium text-slate-600 dark:text-slate-400"
                >
                  Split gap
                </label>
                <span className="tabular-nums text-xs font-semibold text-indigo-600 dark:text-indigo-400">
                  {Math.round(distance * 100)}%
                </span>
              </div>
              <input
                id={distId}
                type="range"
                min={0.15}
                max={0.6}
                step={0.01}
                value={distance}
                onChange={(e) => setDistance(parseFloat(e.target.value))}
                className="h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-200 accent-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
              />
            </div>

            {/* accent switch */}
            <div className="flex items-center justify-between rounded-xl border border-slate-200 bg-white px-3.5 py-3 dark:border-slate-800 dark:bg-slate-950">
              <span
                id={accentLabelId}
                className="text-sm font-medium text-slate-700 dark:text-slate-300"
              >
                Accent reveal
              </span>
              <button
                type="button"
                role="switch"
                aria-checked={accent}
                aria-labelledby={accentLabelId}
                onClick={() => setAccent((v) => !v)}
                className={`relative inline-flex h-6 w-11 items-center rounded-full transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950 ${
                  accent ? "bg-indigo-500" : "bg-slate-300 dark:bg-slate-700"
                }`}
              >
                <span
                  className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${
                    accent ? "translate-x-5" : "translate-x-0.5"
                  }`}
                />
              </button>
            </div>

            {/* wave switch */}
            <div className="flex items-center justify-between rounded-xl border border-slate-200 bg-white px-3.5 py-3 dark:border-slate-800 dark:bg-slate-950">
              <span className="flex flex-col">
                <span
                  id={waveLabelId}
                  className="text-sm font-medium text-slate-700 dark:text-slate-300"
                >
                  Auto wave
                </span>
                {reduce ? (
                  <span className="text-[11px] text-slate-400 dark:text-slate-600">
                    Paused · reduced motion
                  </span>
                ) : null}
              </span>
              <button
                type="button"
                role="switch"
                aria-checked={wave}
                aria-labelledby={waveLabelId}
                disabled={!!reduce}
                onClick={() => setWave((v) => !v)}
                className={`relative inline-flex h-6 w-11 items-center rounded-full transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-40 dark:focus-visible:ring-offset-slate-950 ${
                  wave ? "bg-emerald-500" : "bg-slate-300 dark:bg-slate-700"
                }`}
              >
                <span
                  className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${
                    wave ? "translate-x-5" : "translate-x-0.5"
                  }`}
                />
              </button>
            </div>
          </div>
        </div>
      </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 →