Skip to content
Plinth

Text Effects

Split Reveal

Lines sliding up from behind a mask, offset in percent so it holds at any font size.

#text#reveal#mask#stagger#animation

Install

npx shadcn@latest add https://plinthui.com/r/split-reveal.json
Open in v0
'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',
};

More in Text Effects

Similar tags