Split Hover Text Effect
Original · freeletters that split on hover
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"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">
</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
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 quoteSimilar components
Browse all →
Aurora Gradient Headline
OriginalAn animated headline whose living multi-colour gradient drifts across every letter while the words blur and stagger into place on scroll.

Character Reveal Heading
OriginalA cinematic heading that blurs and lifts in one character at a time with a staggered, scroll-triggered reveal.

Shimmer Sweep Headline
OriginalA headline with a polished light sheen that glides endlessly across the letters and races faster on hover.

Rotating Words Headline
OriginalA headline whose final word flips through a curated set with a blurred vertical roll while the rest of the line stays perfectly still.

Gradient Animate Text Effect
Originalanimated gradient text

Typing Text Effect
Originaltypewriter text with caret

Shiny Text Effect
Originalshiny sweep text

Glitch Text Effect
Originalglitch text effect

Wave Text Effect
Originalwavy letter animation

Reveal Mask Text Effect
Originalmask reveal heading

Outline Text Effect
Originaloutline-to-fill text

3D Text Effect
Originallayered 3D text

