Split Text
An expressive word-by-word reveal for headlines that deserve an entrance.
ONE WORD AT A TIME
Good things take motion.
A staggered entrance.
Live component. Go ahead, interact.
REACT / TYPESCRIPT
'use client';
import { useEffect, useRef, useState, type CSSProperties } from 'react';
import './split-text.css';
export interface SplitTextProps {
text: string;
className?: string;
/** The stagger between words, in seconds. */
delay?: number;
/** Change this value to replay the reveal. */
replayKey?: number;
as?: 'h1' | 'h2' | 'h3' | 'p' | 'span';
}
/** Reveals words once on entry while preserving one complete screen reader phrase. */
export function SplitText({
text,
className = '',
delay = 0.06,
replayKey = 0,
as: Element = 'p',
}: SplitTextProps) {
const textRef = useRef<HTMLSpanElement>(null);
const [visible, setVisible] = useState(false);
const stagger = Number.isFinite(delay) ? Math.max(0, delay) : 0.06;
let wordIndex = 0;
useEffect(() => {
const element = textRef.current;
if (!element) return;
setVisible(false);
const preference = window.matchMedia('(prefers-reduced-motion: reduce)');
let frame = 0;
let observer: IntersectionObserver | undefined;
const reveal = () => {
frame = window.requestAnimationFrame(() => setVisible(true));
observer?.disconnect();
};
const handlePreference = () => {
if (preference.matches) reveal();
};
if (preference.matches || !('IntersectionObserver' in window)) {
reveal();
} else {
observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) reveal();
},
{ threshold: 0.1 },
);
observer.observe(element);
}
preference.addEventListener('change', handlePreference);
return () => {
window.cancelAnimationFrame(frame);
observer?.disconnect();
preference.removeEventListener('change', handlePreference);
};
}, [text, replayKey]);
return (
<Element className={`ac-split-text ${className}`.trim()} data-visible={visible}>
<span className="ac-split-text__accessible">{text}</span>
<span ref={textRef} className="ac-split-text__visual" aria-hidden="true">
{text.split(/(\s+)/).map((part, index) => {
if (/^\s+$/.test(part)) return part;
if (!part) return null;
const style = { '--ac-word-delay': `${wordIndex++ * stagger}s` } as CSSProperties;
return (
<span className="ac-split-text__mask" key={`${replayKey}-${index}`}>
<span className="ac-split-text__word" style={style}>{part}</span>
</span>
);
})}
</span>
</Element>
);
}
export default SplitText;
01
Installation
Run this in your shadcn-configured React project. The component and stylesheet are added together.
TERMINAL
npx shadcn@latest add http://localhost:5173/r/split-text.jsonThis command uses the current site address. For localhost, keep this preview running on the same computer. Files follow your configured components alias.
02
Usage
Import the component and make the details your own.
TypeScript
import { SplitText } from '@/components/anticraft/SplitText';
export function Example() {
return <SplitText as="h2" text="Less ordinary. More you." delay={0.08} />;
}03
Props
| Prop | Type | Default | Description |
|---|---|---|---|
text | string | Required | The complete phrase to reveal. Screen readers receive one uninterrupted copy. |
as | 'h1' | 'h2' | 'h3' | 'p' | 'span' | 'p' | The semantic element wrapping the phrase. |
delay | number | 0.06 | The stagger between words in seconds. |
replayKey | number | 0 | Change this value to replay the reveal. |
className | string | '' | Additional classes for typography and layout. |
04
Motion & accessibility
Considered by default.
Reveals once on entering the viewport. With reduced motion, the complete phrase is immediately visible without animation.
Use semantic content and meaningful labels in your composition. Check contrast and keyboard behavior with your own styles and content.