# 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.

- ID: `cmp_text_reveal_01`
- Slug: `text-reveal-01`
- Version: `1.0.0` (current)
- Status: published
- Published: 2026-09-23
- Updated: 2026-09-23
- Available versions: `1.0.0`
- Kind: control
- Primary category: `visual-effects`
- Detail page: https://pagesugar.com/components/text-reveal-01?variant=default
- Preview: https://pagesugar.com/preview/text-reveal-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `default` | Default | yes | `sha256-f69f8cd83765fb1370617aea0b6842ec62fa76e6751a1b36ea3cad2688cb182b` |

## Runtime and compatibility

- Runtime: svelte
- Svelte: 5
- SvelteKit required: no (portable Svelte component)
- Tailwind CSS: 4
- SSR: supported
- Requires client-side JavaScript: no
- Integration level: local-interaction
- Appearance modes: light
- Suggested directory: `src/lib/components/text-reveal-01`

## Dependencies

No third-party runtime packages.

## Services

No external services required.

## Usage

Pass 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.

Required props: `text`

```svelte
<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"
/>
```

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, startOnView or 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.

## Usage guide

### 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 with `animate={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`:

```svelte
<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

- `words` suits hero headings up to two or three lines.
- `lines` suits longer section titles, where thirty words arriving one by one would read as
  noise.
- `typewriter` suits a short status line or tagline. On long text each character gets only a
  few milliseconds, so the effect reads as a wipe; use `words` instead.

#### 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

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `text` | `string` | yes |  | 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 |  | Id for the rendered element, for aria-labelledby or in-page links. |
| `class` | `string` | no |  | Classes for the rendered element. Typography comes from here or from the surrounding page. |

## Customization

Typography 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-5xl font-semibold tracking-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-caret on the element or an ancestor; it defaults to the text colour.
- Scroll start: startOnView uses an IntersectionObserver with a 10% bottom margin; change rootMargin in the source to start earlier or later.
- Repeat visits: pass animate={false} when the intro has already been seen.

| Token | Public CSS variable |
| --- | --- |
| `caret` | `--text-reveal-caret` |

## Accessibility

- 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.

## License

- Declared source: MIT
- Default license approval is pending. See https://pagesugar.com/docs/license.

## Source

- Palette: Default (`default`)
- Entry: `TextReveal.svelte`
- Suggested directory: `src/lib/components/text-reveal-01`
- Files: 1
- Artifact digest: `sha256-f69f8cd83765fb1370617aea0b6842ec62fa76e6751a1b36ea3cad2688cb182b`

Paths below are relative to the suggested directory. Copy the files as they are;
they import nothing from this site.

#### `TextReveal.svelte`

Role: entry · 10774 bytes · SHA-256 `39c860b7e5c475d25a78ed30e7c186d314c85b5813f263f2571deeb55450239f`

```svelte
<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>
```

## Artifacts

### Default (`default`) (default)

- Artifact digest: `sha256-f69f8cd83765fb1370617aea0b6842ec62fa76e6751a1b36ea3cad2688cb182b`
- Entry: `TextReveal.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_text_reveal_01/1.0.0/default/sha256-f69f8cd83765fb1370617aea0b6842ec62fa76e6751a1b36ea3cad2688cb182b/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_text_reveal_01/1.0.0/default/sha256-f69f8cd83765fb1370617aea0b6842ec62fa76e6751a1b36ea3cad2688cb182b/bundle.zip (7381 bytes, sha256 `031ec38fa4ed92bd9a55ea4f4d0e025e4dc7aeb59ba6c347f836de2128f1c358`)

Files:

- `TextReveal.svelte` (entry, 10774 bytes): https://pagesugar.com/artifacts/cmp_text_reveal_01/1.0.0/default/sha256-f69f8cd83765fb1370617aea0b6842ec62fa76e6751a1b36ea3cad2688cb182b/source/TextReveal.svelte
