Text reveal
A heading that reveals its words, lines or characters once, within a capped duration, while the full text stays readable to screen readers, search engines and visitors without JavaScript.
cmp_text_reveal_01 Preview
Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.
Use this component with your coding agent
Using the PageSugar MCP server, fetch component cmp_text_reveal_01 version 1.0.0 with variant "default", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.
Code
Artifact sha256-f69f8cd8…88cb182b
sha256-f69f8cd83765fb1370617aea0b6842ec62fa76e6751a1b36ea3cad2688cb182b<script module lang="ts">
export type TextRevealEffect = 'words' | 'lines' | 'typewriter';
export type TextRevealElement = 'h1' | 'h2' | 'h3' | 'p' | 'span';
interface Token {
text: string;
/** Breaking whitespace between words: a plain text node, so wrapping matches the static text. */
space: boolean;
/** Position among the non-space tokens. */
index: number;
/** The whole text as one token: the fallback when words cannot be split safely. */
whole?: boolean;
}
/** Longest a single word or line takes to arrive, before the stagger is added. */
const TOKEN_MS = 480;
/** Gaps between arrivals when the text is short enough not to need compressing. */
const WORD_STAGGER_MS = 50;
const LINE_STAGGER_MS = 110;
/** Bounds for the duration prop; the upper one keeps any reveal far inside five seconds. */
const MIN_MS = 200;
const MAX_MS = 3000;
/** Typewriter frame. Progress comes from elapsed time, so clamped timers never stretch the cap. */
const TICK_MS = 16;
/**
* When the server-rendered text has already been on screen this long, taking it away to replay
* an intro would delay reading it, so a hydrating instance in view stays static instead.
*/
const STALE_AFTER_MS = 1000;
/** Whitespace a line may break at. No-break spaces stay inside the token they join. */
const BREAKING_SPACE = /^[^\S\u00a0\u2007\u202f]+$/;
const NO_BREAK = /[\u00a0\u2007\u202f]$/;
/** Opening brackets and quotes belong to the word after them. */
const OPENING = /^[\p{Ps}\p{Pi}]+$/u;
const RTL = /[\u0590-\u08ff\ufb1d-\ufdff\ufe70-\ufefc]/;
const LTR = /[A-Za-z\u00c0-\u024f\u0370-\u03ff\u0400-\u04ff]/;
function segmenter(granularity: 'word' | 'grapheme') {
return typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function'
? new Intl.Segmenter(undefined, { granularity })
: null;
}
/**
* Splits text into word tokens, keeping closing punctuation with the word before it and opening
* punctuation with the word after it, so no line starts with a comma or ends with a bracket.
* The whole text is one token, and simply fades in, without Intl.Segmenter or when right-to-left
* and left-to-right words are mixed: separate inline blocks would lose their bidi order.
*/
function splitWords(text: string): Token[] {
const words = segmenter('word');
if (!words || (RTL.test(text) && LTR.test(text)))
return [{ text, space: false, index: 0, whole: true }];
const tokens: Token[] = [];
let current: Token | null = null;
let hasWord = false;
let glued = false;
let index = 0;
for (const { segment, isWordLike } of words.segment(text)) {
if (BREAKING_SPACE.test(segment)) {
current = null;
tokens.push({ text: segment, space: true, index: -1 });
continue;
}
// CJK has no spaces, so each word-like segment starts its own token, unless a no-break
// space ties it to the one before.
const startsToken = !glued && hasWord && (isWordLike || OPENING.test(segment));
if (current && !startsToken) {
current.text += segment;
hasWord ||= !!isWordLike;
} else {
current = { text: segment, space: false, index: index++ };
hasWord = !!isWordLike;
tokens.push(current);
}
glued = NO_BREAK.test(segment);
}
return tokens;
}
function splitGraphemes(text: string): string[] {
return Array.from(segmenter('grapheme')!.segment(text), (s) => s.segment);
}
</script>
<script lang="ts">
import { onMount, tick, untrack } from 'svelte';
interface Props {
text: string;
as?: TextRevealElement;
effect?: TextRevealEffect;
duration?: number;
startOnView?: boolean;
animate?: boolean;
id?: string;
class?: string;
}
let {
text,
as = 'h2',
// Named `effect` would shadow the $effect rune, so the prop is read as `reveal`.
effect: reveal = 'words',
duration = 900,
startOnView = false,
animate = true,
id,
class: className
}: Props = $props();
const uid = $props.id();
// Server markup carrying this instance's id already exists only when the component is hydrating.
const hydrating =
typeof document !== 'undefined' &&
document.querySelector(`[data-text-reveal="${uid}"]`) !== null;
/** static: plain text (SSR, no JS, reduced motion, finished). parked: split and waiting. */
let phase = $state<'static' | 'parked' | 'revealing'>('static');
let root = $state<HTMLElement>();
let lineOf = $state<number[]>([]);
let typed = $state(0);
const cap = $derived(
Math.min(MAX_MS, Math.max(MIN_MS, Number.isFinite(duration) ? duration : 900))
);
// Without a grapheme segmenter, typing could show half an emoji or accent, so it fades instead.
const typing = $derived(reveal === 'typewriter' && segmenter('grapheme') !== null);
const tokens = $derived(typing ? [] : splitWords(text));
const graphemes = $derived(typing ? splitGraphemes(text) : []);
// Words that touch with no space between them (CJK) must stay inline: an inline block would add
// a break opportunity, so the split text would wrap differently from the plain text it reverts to.
// The whole-text fallback, as an inline block, would wrap as a box, so it fades inline too.
const inline = $derived(
tokens.some((t, i) => i > 0 && !t.space && !tokens[i - 1].space) || tokens.some((t) => t.whole)
);
const steps = $derived(
reveal === 'lines'
? Math.max(1, ...lineOf.map((line) => line + 1))
: tokens.filter((t) => !t.space).length
);
const tokenMs = $derived(Math.min(TOKEN_MS, cap * 0.6));
const staggerMs = $derived(
steps > 1
? Math.min(
reveal === 'lines' ? LINE_STAGGER_MS : WORD_STAGGER_MS,
(cap - tokenMs) / (steps - 1)
)
: 0
);
let timeout: ReturnType<typeof setTimeout> | undefined;
let interval: ReturnType<typeof setInterval> | undefined;
let observer: IntersectionObserver | undefined;
let destroyed = false;
function inViewport(el: Element) {
const rect = el.getBoundingClientRect();
return rect.bottom > 0 && rect.top < window.innerHeight;
}
function cancel() {
clearTimeout(timeout);
clearInterval(interval);
timeout = interval = undefined;
observer?.disconnect();
observer = undefined;
}
function finish() {
cancel();
phase = 'static';
}
/** Groups the inline-block words into the rows they occupy right now. */
function measureLines() {
const tops = Array.from(
root!.querySelectorAll<HTMLElement>('[data-token]'),
(word) => word.offsetTop
);
const rows = [...new Set(tops)].sort((a, b) => a - b);
lineOf = tops.map((top) => rows.indexOf(top));
}
function start() {
if (destroyed || phase !== 'parked' || !root) return;
cancel();
if (reveal === 'lines' && !typing) measureLines();
phase = 'revealing';
if (typing) {
const began = performance.now();
const count = graphemes.length;
interval = setInterval(() => {
const progress = (performance.now() - began) / cap;
typed = Math.min(count, Math.ceil(progress * count));
if (progress >= 1) finish();
}, TICK_MS);
} else {
timeout = setTimeout(finish, (steps - 1) * staggerMs + tokenMs);
}
}
async function begin(reducedMotion: boolean) {
if (!animate || !text.trim() || !root || reducedMotion) return;
if (hydrating && performance.now() > STALE_AFTER_MS && inViewport(root)) return;
typed = 0;
phase = 'parked';
await tick();
if (destroyed || phase !== 'parked' || !root) return;
if (!startOnView || typeof IntersectionObserver === 'undefined' || inViewport(root)) {
start();
return;
}
observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) start();
},
{ rootMargin: '0px 0px -10% 0px' }
);
observer.observe(root);
}
// The reveal runs once per mount. Changing what it reveals, or turning motion off, cancels it and
// shows the current text at once rather than mixing new content with the old schedule.
$effect(() => {
void [text, as, reveal, cap, animate, startOnView];
untrack(() => {
if (phase !== 'static') finish();
});
});
onMount(() => {
const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
const onMotion = () => {
if (motion.matches && phase !== 'static') finish();
};
motion.addEventListener?.('change', onMotion);
begin(motion.matches);
return () => {
destroyed = true;
motion.removeEventListener?.('change', onMotion);
cancel();
};
});
</script>
<svelte:element
this={as}
bind:this={root}
{id}
class={className}
data-text-reveal={uid}
data-phase={phase}
data-flow={inline ? 'inline' : undefined}
style:--tr-token="{tokenMs}ms"
style:--tr-stagger="{staggerMs}ms"
>
{#if phase === 'static'}
{text}
{:else}
<span class="sr-only">{text}</span>
{#if typing}
<!-- prettier-ignore -->
<span aria-hidden="true" class="select-none">{graphemes.slice(0, typed).join('')}<span class="tr-caret"></span><span class="tr-rest">{graphemes.slice(typed).join('')}</span></span>
{:else}
<!-- prettier-ignore -->
<span aria-hidden="true" class="select-none">{#each tokens as token, i (i)}{#if token.space}{token.text}{:else}<span data-token class="tr-token" style:--tr-i={reveal === 'lines' ? (lineOf[token.index] ?? 0) : token.index}>{token.text}</span>{/if}{/each}</span>
{/if}
{/if}
</svelte:element>
<style>
[data-text-reveal] {
--_caret: var(--text-reveal-caret, currentColor);
}
.tr-token {
display: inline-block;
}
[data-phase='parked'] .tr-token {
opacity: 0;
transform: translateY(0.25em);
}
[data-phase='revealing'] .tr-token {
animation: tr-rise var(--tr-token) cubic-bezier(0.16, 1, 0.3, 1)
calc(var(--tr-i) * var(--tr-stagger)) both;
}
/* Inline words cannot move, so they fade in place and wrap exactly as the plain text does. */
[data-flow='inline'] .tr-token {
display: inline;
}
[data-flow='inline'][data-phase='revealing'] .tr-token {
animation-name: tr-fade;
}
/* A zero-width anchor, so the caret moves through the text without pushing a glyph along. */
.tr-caret {
position: relative;
}
.tr-caret::after {
content: '';
position: absolute;
inset-inline-start: 0;
bottom: 0.08em;
width: max(2px, 0.06em);
height: 1em;
background: var(--_caret);
}
[data-phase='parked'] .tr-caret::after {
opacity: 0;
}
/* Untyped text keeps its place in the layout, so nothing reflows as the caret moves. */
.tr-rest {
visibility: hidden;
}
@keyframes tr-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes tr-rise {
from {
opacity: 0;
transform: translateY(0.25em);
}
to {
opacity: 1;
transform: none;
}
}
@media (prefers-reduced-motion: reduce) {
[data-phase] .tr-token {
animation: none;
opacity: 1;
transform: none;
}
.tr-rest {
visibility: visible;
}
.tr-caret::after {
display: none;
}
}
</style>
Usage #
On this pagePass the text and pick an effect; the component renders one element (h1, h2, h3, p or span) and animates it once on mount or when it scrolls into view. It sets no type styles of its own: size, weight, tracking and colour come from the class you pass or the surrounding page. It does not animate rich content, links or markup inside the text, does not loop, and is not a marquee or rotating-word effect. The text is fully usable without JavaScript; the motion is the only thing that needs it.
- Suggested location
src/lib/components/text-reveal-01- Required props
text
Limitations
- Plain text only. Links, emphasis and other inline markup are out of scope; split the heading into several instances or edit the source to render them.
- The reveal runs once per mount. Changing text, as, effect, duration,
startOnViewor animate while it runs cancels it and shows the current text at once; remount with{#key}to replay. - The line effect groups words by where they wrap when the reveal starts. If the element is resized during the reveal, words finish in their original groups, then the text reverts to plain text and wraps normally.
- Scripts written without spaces between words (Chinese, Japanese) fade each word in place instead of rising, so the text wraps exactly as it will once the reveal ends.
- Without Intl.Segmenter, and for text that mixes right-to-left and left-to-right words, the word and line effects fade the whole text in at once; the typewriter does the same without a grapheme segmenter.
- A server-rendered instance that is already on screen when it hydrates more than a second after navigation start stays static: page age is used as the measure of how long the text has been readable.
Example
<script lang="ts">
import TextReveal from '$lib/components/text-reveal-01/TextReveal.svelte';
</script>
<TextReveal
as="h1"
text="Trace every deploy from commit to edge in one timeline."
class="max-w-4xl text-5xl font-semibold tracking-tight text-balance text-zinc-950 sm:text-7xl"
/>Text reveal #
An introductory reveal for one line of display text: words rise in a staggered pass, whole
lines rise a row at a time, or characters are typed behind a caret. It runs once, finishes
inside the duration cap (900 ms by default, clamped to 200–3000 ms) however long the text is,
and then turns back into plain text.
How it stays readable #
- The server renders the plain text in the element you choose. Visitors without JavaScript, search engines and screen readers get the text and nothing else.
- On the client, the text is split only when motion is allowed. While the animated spans play
they are
aria-hidden, and a visually hidden copy of the full text stays in the element, so the accessible text never changes. - Under
prefers-reduced-motion: reduce, or withanimate={false}, nothing is split. - A server-rendered heading that is already on screen when the page hydrates more than a second after navigation start stays static: the visitor has been reading it, and taking it away to replay an intro would be worse than no intro.
Typography #
The component sets no type styles. Pass your heading classes through class:
<TextReveal
as="h1"
text="Trace every deploy from commit to edge in one timeline."
class="max-w-4xl text-5xl font-semibold tracking-tight text-balance text-zinc-950 sm:text-7xl"
/>Choosing an effect #
wordssuits hero headings up to two or three lines.linessuits longer section titles, where thirty words arriving one by one would read as noise.typewritersuits a short status line or tagline. On long text each character gets only a few milliseconds, so the effect reads as a wipe; usewordsinstead.
Replaying #
The reveal runs once per mount. Changing the text or any setting while it runs cancels it and
shows the current text at once. Wrap the component in {#key} to replay it, and pass
animate={false} on repeat visits if the intro has already been seen.
Props and content inputs #
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
text | string | Yes | None | The text to reveal. Plain text; it is also the element's complete accessible text. |
as | 'h1' | 'h2' | 'h3' | 'p' | 'span' | No | 'h2' | Element to render. Pick the heading level that fits the page outline. |
effect | 'words' | 'lines' | 'typewriter' | No | 'words' | Reveal style: words rise in a staggered pass, lines rise as whole rows, or characters are typed behind a caret. Changing it mid-reveal shows the text at once. |
duration | number | No | 900 | Cap in milliseconds for the whole reveal, however long the text; the stagger shrinks to fit. Clamped to 200–3000. |
startOnView | boolean | No | false | Wait until the element scrolls into view before revealing. An element already in view starts immediately. |
animate | boolean | No | true | Set to false to render the text statically, for example on repeat visits or when motion is controlled elsewhere. |
id | string | No | None | Id for the rendered element, for aria-labelledby or in-page links. |
class | string | No | None | Classes for the rendered element. Typography comes from here or from the surrounding page. |
Customization #
On this pageTypography comes from the class prop, so the effect adopts whatever heading styles you already use. Motion values are constants at the top of the source; the caret colour is a token.
- Typography: pass your heading classes through class, for example
text-5xlfont-semiboldtracking-tight text-balance. - Timing: duration caps the whole reveal; TOKEN_MS, WORD_STAGGER_MS and LINE_STAGGER_MS in the source set how long each word or line takes and the gap between them.
- Distance: the 0.25em rise lives in the parked rule and the tr-rise keyframes; change both together.
- Caret: set
--text-reveal-careton the element or an ancestor; it defaults to the text colour. - Scroll start:
startOnViewuses an IntersectionObserver with a 10% bottom margin; changerootMarginin the source to start earlier or later. - Repeat visits: pass animate=
{false}when the intro has already been seen.
Public CSS variables
| Variable | Token |
|---|---|
--text-reveal-caret | caret |
Accessibility #
On this page- The element holds its complete text in the accessibility tree from the first render: plain text on the server, then a visually hidden copy while the animated spans, marked
aria-hidden, play. Only one copy is exposed at a time. - The animated copy is not selectable, so selecting or copying during the reveal takes the text once. When the reveal finishes the element returns to plain text.
- Under prefers-reduced-motion: reduce the text renders statically and never splits; switching the preference on mid-reveal shows the text at once.
- The reveal runs once and takes at most the duration prop (900 ms by default, never more than 3000 ms), inside WCAG 2.2.2's five-second limit for motion without a pause control.
- The component contains no interactive content. Choose the heading level with as so the page outline stays correct.
Known limitations
- The visual reveal is not described to assistive technology, which is intended: it carries no information the text does not.
Release details #
On this page- Integration
- Local interaction
- Works without client-side JavaScript
- Server-side rendering supported
- Dependencies
- No additional runtime packages beyond Svelte and Tailwind CSS
- License
MIT. Default license approval is pending; see the license status before adopting the source.
- Version history
- 1.0.0 Published Current release · 23 September 2026
Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.