Text Effects
Split Reveal
Lines sliding up from behind a mask, offset in percent so it holds at any font size.
Original by Plinth · MIT
Report a problem or suggest an improvementInstall
npx shadcn@latest add https://plinthui.com/r/split-reveal.json'use client';
import { useRef, useState } from 'react';
const css = `
.reveal {
margin: 0;
font: 600 clamp(26px, 7vw, 46px)/1.18 ui-serif, Georgia, serif;
letter-spacing: -0.02em;
color: #f3f0e9;
text-align: center;
}
/* The outer span is the mask and the inner one moves inside it. A single
element cannot do this: you need something with overflow: hidden that stays
put while the text slides up out of it. */
.reveal__line {
display: block;
overflow: hidden;
}
.reveal__inner {
display: inline-block;
/* A percentage translateY is relative to the element's own height, so the
text starts exactly one line below the mask at any font size. */
transform: translateY(110%);
animation: reveal-up 760ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
.reveal__inner--em { font-style: italic; color: #ffca7a; }
@keyframes reveal-up {
to { transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.reveal__inner { animation: none; transform: none; }
}
`;
export interface RevealLine {
text: string;
emphasis?: boolean;
}
const LINES: RevealLine[] = [
{ text: 'Fine components,' },
{ text: 'collected.', emphasis: true },
];
export function SplitReveal({
lines = LINES,
stagger = 130,
}: {
lines?: RevealLine[];
stagger?: number;
}) {
// Remounting the whole block is how the animation replays. Toggling a class
// does not work: the browser coalesces the remove and re-add into no change
// at all, so the keyframes never restart. Changing the key forces new
// elements, which start from their initial state by definition.
const [run, setRun] = useState(0);
const ref = useRef<HTMLDivElement>(null);
return (
<>
<style>{css}</style>
<div ref={ref}>
<h2 className="reveal" aria-label={lines.map(l => l.text).join(' ')} key={run}>
{lines.map((line, i) => (
// aria-hidden on the pieces: the heading is announced once from
// aria-label, or a screen reader reads it line by fragmented line.
<span className="reveal__line" key={line.text} aria-hidden="true">
<span
className={`reveal__inner${line.emphasis ? ' reveal__inner--em' : ''}`}
style={{ animationDelay: `${i * stagger}ms` }}
>
{line.text}
</span>
</span>
))}
</h2>
</div>
<button type="button" onClick={() => setRun(n => n + 1)} style={replayStyle}>
Replay
</button>
</>
);
}
const replayStyle: React.CSSProperties = {
marginTop: 26,
padding: '7px 15px',
border: '1px solid rgba(255, 255, 255, 0.18)',
borderRadius: 999,
background: 'rgba(255, 255, 255, 0.05)',
color: 'rgba(243, 240, 233, 0.7)',
font: '500 12.5px/1 ui-sans-serif, system-ui, sans-serif',
cursor: 'pointer',
};