Component library
Back to catalog

Focus reveal statement

A centred statement whose words come into focus one after another as the page scrolls past, blurring again on the way back.

statementtypographyscrollblurreveal

Open preview in a new tab

Use this component

Three ways to get Focus reveal statement into one of your own projects. The first two carry the component's files with them, so nothing has to fetch this page.

Start here

Placement prompt

The whole thing: what to do, where it goes, what must not change, and every one of the component's 1 file appended underneath with the path each belongs at. Paste it into Claude Code inside the destination project — it fetches nothing and needs nothing else.

Read it first — the files are added underneath when you copy
Add Focus reveal statement from my Astro Component Library to this Astro project.

Library page, for reference only:
https://astro.baysixmedia.com/components/focus-reveal-statement/

Place it in:
[ENTER PAGE, FILE, OR ROUTE]

Position:
[ENTER WHERE IT SHOULD APPEAR]

Every file you need is appended below, each fenced with the path it belongs at. Write them there, relative to the project root. Do not import anything from the deployed library at runtime, and do not fetch the library page — the files below are the source of truth.

Adapt its demo content, links, colors, typography, and images to this project through documented props where possible. Preserve its responsive behavior, accessibility, interaction logic, reduced-motion behavior, and support for multiple instances. Avoid changing unrelated project code.

Test it at desktop, tablet, and mobile widths. Run the project's available check and build commands. Report the files changed and any differences from the library version. Do not commit or deploy.

Install kit

The 1 file this component needs and nothing else, each fenced with the path it belongs at, followed by what the library's own verification covered and what it did not. No instructions attached — for placing it yourself, or handing it to something that has its own.

Read it first
Install kit: "Focus reveal statement"
Library page: https://astro.baysixmedia.com/components/focus-reveal-statement/
Files: 1 required, 1 supporting

Write each block below to the path named in its header, relative to the project root. A block marked (required) is one the component does not work without; (supporting) is documentation. Nothing here needs downloading except a block that says so in as many words.

===== FILE (required): src/components/FocusRevealStatement.astro =====
---
/**
 * Focus reveal statement
 *
 * A centred statement whose words start blurred and dim, then come into focus
 * one after another as the page scrolls past. The reveal is scroll-linked, not
 * timed: a travelling wavefront moves through the words, and scrolling back up
 * un-focuses them again.
 *
 * Nothing here moves or resizes — only `filter`, `opacity` and `color` change —
 * so the block occupies exactly the same space before, during and after.
 *
 * Self-contained: no imports, no packages, no global stylesheet. Every colour
 * and measurement is a `--fr-*` custom property on the root.
 */

interface Props {
	/** One entry per paragraph. The wavefront runs through all of them in order. */
	paragraphs?: string[];
	/** Text alignment. The reference is centred. */
	align?: "center" | "start";
	/** Caps the text column and centres it. */
	maxWidth?: string;
	/** Type size. Fluid by default so it never overflows a narrow screen. */
	fontSize?: string;
	/** Line height, unitless or with units. */
	lineHeight?: string;
	/** Font weight of the statement. */
	fontWeight?: number;
	/** Gap between paragraphs. */
	gap?: string;
	/** Vertical padding of the section. */
	padding?: string;
	/** Colour of a word once it is in focus. */
	textColor?: string;
	/** Colour of a word before it is reached. */
	dimColor?: string;
	/** Ground behind the statement. */
	background?: string;
	/** Blur applied to a word before it is reached. */
	blur?: string;
	/** Opacity of a word before it is reached. */
	dimOpacity?: number;
	/** How many words are mid-reveal at once. Higher is a softer wavefront. */
	wordWindow?: number;
	/** Fraction of the viewport height at which the reveal starts. */
	startRatio?: number;
	/** Fraction of the viewport height the reveal takes to finish. */
	spanRatio?: number;
	/** Keep words in focus once reached, instead of un-focusing on the way back. */
	triggerOnce?: boolean;
	/** Added to the root `<section>`. */
	class?: string;
	/** Root element id. */
	id?: string;
}

const {
	// No built-in statement: the copy always comes from the caller.
	paragraphs = [],
	align = "center",
	maxWidth = "700px",
	// 32px at desktop, matching the reference, but allowed to fall to 24px so a
	// 320px screen never has to scroll sideways to finish a sentence.
	fontSize = "clamp(1.5rem, 1.05rem + 2.2vw, 2rem)",
	lineHeight = "1.25",
	fontWeight = 500,
	gap = "40px",
	padding = "clamp(64px, 12vw, 128px)",
	textColor = "#ffffff",
	dimColor = "#505050",
	background = "#0b0b0d",
	blur = "8px",
	dimOpacity = 0.8,
	wordWindow = 2,
	/*
	 * The reveal runs from "top of the block at 75% of the viewport" to "top of
	 * the block at 5%". Deliberately `startRatio > spanRatio`: it guarantees
	 * that a block sitting at or above the top of the viewport resolves to a
	 * progress of 1, so a statement that is already fully on screen with no
	 * room left to scroll ends up readable rather than stranded part-blurred.
	 */
	startRatio = 0.75,
	spanRatio = 0.7,
	triggerOnce = false,
	class: className,
	id,
} = Astro.props;

/*
 * Words are numbered continuously across every paragraph, so the wavefront
 * crosses the paragraph break without restarting — which is what makes the
 * second paragraph read as the end of one thought rather than a new one.
 */
let cursor = 0;
const blocks = paragraphs.map((text) => {
	const words = text.split(/\s+/).filter(Boolean);
	return words.map((word) => ({ word, index: cursor++ }));
});
const total = Math.max(cursor, 1);

const style = [
	`--fr-max:${maxWidth}`,
	`--fr-size:${fontSize}`,
	`--fr-leading:${lineHeight}`,
	`--fr-weight:${fontWeight}`,
	`--fr-gap:${gap}`,
	`--fr-pad:${padding}`,
	`--fr-color:${textColor}`,
	`--fr-dim:${dimColor}`,
	`--fr-bg:${background}`,
	`--fr-blur:${blur}`,
	`--fr-dim-opacity:${dimOpacity}`,
	`--fr-align:${align === "center" ? "center" : "start"}`,
].join(";");
---

{cursor > 0 && (
<><section
	class:list={["reveal", className]}
	id={id}
	style={style}
	data-fr-root
	data-fr-total={total}
	data-fr-window={wordWindow}
	data-fr-start={startRatio}
	data-fr-span={spanRatio}
	data-fr-once={triggerOnce ? "" : null}
>
	<div class="inner">
		{
			blocks.map((words) => (
				<p class="line">
					{words.map(({ word, index }, i) => (
						<>
							{i > 0 && " "}
							{/* A real text node, not an aria-hidden decoration: the
							    paragraph reads normally to a screen reader, and there
							    is no duplicate copy of the sentence to keep in sync. */}
							<span class="word" data-fr-word style={`--fr-i:${index}`}>
								{word}
							</span>
						</>
					))}
				</p>
			))
		}
	</div>
</section>

{/*
	Runs during parse, before the first paint, and only ever touches its own
	section — `currentScript.previousElementSibling` is the <section> above.

	This is what lets the blurred starting state be the CSS default *without*
	stranding anyone: with no JavaScript the attribute is never added, the words
	keep `--fr-t: 1`, and the statement is simply legible. With JavaScript the
	attribute lands before anything is painted, so there is no flash of finished
	text collapsing back into a blur.
*/}
<script is:inline>
	document.currentScript.previousElementSibling.setAttribute("data-fr-js", "");
</script>
</>
)}

<style>
	.reveal {
		box-sizing: border-box;
		padding: var(--fr-pad) clamp(16px, 4vw, 32px);
		background: var(--fr-bg);
		font-family:
			system-ui,
			-apple-system,
			"Segoe UI",
			Roboto,
			sans-serif;
	}

	/* Set rather than inherited, so a host stylesheet cannot change the shape of
	   the block or push a line onto the next row. */
	.reveal *,
	.reveal *::before,
	.reveal *::after {
		box-sizing: border-box;
	}

	.inner {
		display: flex;
		flex-direction: column;
		gap: var(--fr-gap);
		max-width: var(--fr-max);
		margin-inline: auto;
	}

	.line {
		margin: 0;
		padding: 0;
		color: var(--fr-color);
		font-size: var(--fr-size);
		font-weight: var(--fr-weight);
		line-height: var(--fr-leading);
		letter-spacing: -0.01em;
		text-align: var(--fr-align);
		/* Evens the ragged edge the way the reference does, so a centred
		   statement does not end on a single orphaned word. */
		text-wrap: balance;
	}

	/*
	 * `--fr-t` is the word's own progress, 0 (untouched) to 1 (in focus). It
	 * defaults to 1, which is the finished state — so with no script, or before
	 * the script has run, every word is simply readable.
	 */
	.word {
		--fr-t: 1;
		display: inline-block;
		filter: blur(calc((1 - var(--fr-t)) * var(--fr-blur)));
		opacity: calc(
			var(--fr-dim-opacity) + (1 - var(--fr-dim-opacity)) * var(--fr-t)
		);
		color: color-mix(
			in srgb,
			var(--fr-color) calc(var(--fr-t) * 100%),
			var(--fr-dim)
		);
	}

	/* Only once the script is present does the blurred start state apply. */
	.reveal[data-fr-js] .word {
		--fr-t: 0;
	}

	/*
	 * Nothing is animated, hidden or moved for a reader who asked for less
	 * motion: the statement is simply in focus from the start. The script sees
	 * the same query and never attaches a scroll listener.
	 */
	@media (prefers-reduced-motion: reduce) {
		.reveal[data-fr-js] .word {
			--fr-t: 1;
		}
	}
</style>

<script>
	/**
	 * One controller per instance. Every element is looked up inside its own
	 * section, so any number of statements can share a page and each reads its
	 * own position independently.
	 */
	function initFocusReveal(root: HTMLElement) {
		const words = Array.from(root.querySelectorAll<HTMLElement>("[data-fr-word]"));
		if (words.length === 0) return;

		// The inline script normally did this already; repeated for the case
		// where it was stripped by a sanitiser or a strict CSP.
		root.setAttribute("data-fr-js", "");

		const reduce = window.matchMedia("(prefers-reduced-motion: reduce)");
		const total = Number(root.dataset.frTotal) || words.length;
		const win = Math.max(Number(root.dataset.frWindow) || 2, 0.5);
		const startRatio = Number(root.dataset.frStart) || 0.6;
		const spanRatio = Number(root.dataset.frSpan) || 0.65;
		const once = root.hasAttribute("data-fr-once");

		const clamp = (n: number, lo: number, hi: number) => Math.min(Math.max(n, lo), hi);

		// Last value written per word, so a frame that changes nothing writes
		// nothing. Rounded, because sub-percent changes are not visible.
		const written = new Array<number>(words.length).fill(-1);
		let frame = 0;

		function settle() {
			for (let i = 0; i < words.length; i += 1) {
				words[i].style.setProperty("--fr-t", "1");
				written[i] = 1;
			}
		}

		function paint() {
			const rect = root.getBoundingClientRect();
			const vh = window.innerHeight || document.documentElement.clientHeight;
			const start = vh * startRatio;
			const span = Math.max(vh * spanRatio, 1);
			const progress = clamp((start - rect.top) / span, 0, 1);

			/*
			 * Each word owns a slice of the progress, and the slices overlap by
			 * `win` words — so at any moment roughly `win` words are part-way
			 * between dim and focused and the edge reads as a soft wavefront
			 * rather than a hard switch.
			 */
			const per = 1 / (total - 1 + win);
			for (let i = 0; i < words.length; i += 1) {
				const index = Number(words[i].style.getPropertyValue("--fr-i")) || i;
				let t = clamp((progress - index * per) / (win * per), 0, 1);
				if (once && written[i] > t) t = written[i];
				const rounded = Math.round(t * 100) / 100;
				if (rounded !== written[i]) {
					words[i].style.setProperty("--fr-t", String(rounded));
					written[i] = rounded;
				}
			}
		}

		function schedule() {
			if (frame) return;
			frame = requestAnimationFrame(() => {
				frame = 0;
				paint();
			});
		}

		function apply() {
			if (reduce.matches) {
				window.removeEventListener("scroll", schedule);
				window.removeEventListener("resize", schedule);
				settle();
				return;
			}
			window.addEventListener("scroll", schedule, { passive: true });
			window.addEventListener("resize", schedule, { passive: true });
			paint();
		}

		// Honour a preference that changes while the page is open.
		if (typeof reduce.addEventListener === "function") {
			reduce.addEventListener("change", apply);
		}
		apply();
	}

	document.querySelectorAll<HTMLElement>("[data-fr-root]").forEach(initFocusReveal);
</script>

===== END src/components/FocusRevealStatement.astro =====

===== FILE (supporting): src/components/FocusRevealStatement.md =====
# Focus reveal statement

A centred statement whose words start blurred and dim, then come into focus one
after another as the page scrolls past. The reveal is **scroll-linked, not
timed**: a travelling wavefront moves through the words at whatever speed you
scroll, and scrolling back up un-focuses them again.

Nothing moves or resizes. Only `filter`, `opacity` and `color` change, so the
block occupies exactly the same space before, during and after — verified to the
hundredth of a pixel.

Catalog page: `/components/focus-reveal-statement`

## What the reference selector matched

The reference was a single `<section>` on a live marketing homepage, matched by
an id selector that returned **exactly one element**. Inside it:

```
section
└─ div.container
   └─ div (flex, centred)
      └─ div (padding wrapper)
         └─ div (max-width 700px, flex column, gap 40px)
            ├─ p  ← 16 word spans
            └─ p  ← 7 word spans
```

**Both paragraphs were inside the matched element**, and the reveal wavefront
ran continuously through all 23 words without restarting at the paragraph break.
No wrapper outside the selector was needed and none was included — the section
was self-contained, and the four nested `div`s above the paragraphs existed only
to centre a 700px column inside a page container. This component collapses them
to one `.inner` element with the same `max-width`, `gap` and centring.

Nothing outside the section — navigation, cards, logos, footer — was inspected
or reproduced.

## Copying it into another Astro project

Copy **`FocusRevealStatement.astro`**. That is the whole component.

| What it needs | Status |
| :--- | :--- |
| Imports from this catalog | none |
| npm packages | none |
| Astro built-ins (`astro:assets`, …) | none |
| Global stylesheet | none — every value is a `--fr-*` property on the root |
| Webfonts | none — system stack |
| Images or other assets | **none at all** |
| JavaScript | ~60 lines, inline in the file, plus one inline one-liner |
| Network requests | none |
| Host requirement | Astro compiles `.astro` files |

### Exact steps

1. Copy `FocusRevealStatement.astro` into your components folder, e.g.
   `src/components/FocusRevealStatement.astro`.
2. Render it with your own copy:

```astro
---
import FocusRevealStatement from "../components/FocusRevealStatement.astro";
---

<FocusRevealStatement
  paragraphs={[
    "The first sentence, long enough that the reveal has somewhere to travel.",
    "The short line that lands it.",
  ]}
/>
```

3. There is no step three. It needs no assets and no configuration.

**Give it the full page width.** The section paints its own padding and centres
the column; nesting it in an already-padded container will double the inset.

**It needs page scroll to animate.** The reveal reads the block's position
against the viewport, so a block that is already fully on screen with nothing
left to scroll simply renders finished — which is the intended resting state,
not a failure. See *Already visible on load*.

## Props

| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `paragraphs` | `string[]` | `[]` | One entry per paragraph. The wavefront runs through all of them in order. With no words, nothing renders: no section and no inline script. |
| `align` | `"center" \| "start"` | `"center"` | Text alignment. |
| `maxWidth` | `string` | `"700px"` | Caps the text column and centres it. |
| `fontSize` | `string` | `clamp(1.5rem, 1.05rem + 2.2vw, 2rem)` | Type size. Fluid so it never overflows a narrow screen. |
| `lineHeight` | `string` | `"1.25"` | Line height. |
| `fontWeight` | `number` | `500` | Weight of the statement. |
| `gap` | `string` | `"40px"` | Space between paragraphs. |
| `padding` | `string` | `clamp(64px, 12vw, 128px)` | Vertical padding of the section. |
| `textColor` | `string` | `"#ffffff"` | Colour of a word once in focus. |
| `dimColor` | `string` | `"#505050"` | Colour of a word before it is reached. |
| `background` | `string` | `"#0b0b0d"` | Ground behind the statement. |
| `blur` | `string` | `"8px"` | Blur on a word before it is reached. |
| `dimOpacity` | `number` | `0.8` | Opacity of a word before it is reached. |
| `wordWindow` | `number` | `2` | How many words are mid-reveal at once. Higher is a softer edge. |
| `startRatio` | `number` | `0.75` | Viewport-height fraction at which the reveal starts. |
| `spanRatio` | `number` | `0.7` | Viewport-height fraction of scroll the reveal takes. |
| `triggerOnce` | `boolean` | `false` | Keep words focused once reached, instead of un-focusing on the way back. |
| `class` | `string` | — | Added to the root `<section>`. |
| `id` | `string` | — | Root element id. |

### Props this component deliberately does not have

- **`durationMs` / `staggerMs`** — there is no duration. The effect is bound to
  scroll position, not to a clock; the "speed" is how fast the reader scrolls.
  `spanRatio` is the closest equivalent: how much scrolling the whole reveal
  takes. `wordWindow` is the closest equivalent to a stagger.
- **`threshold`** — there is no IntersectionObserver. `startRatio` plays that
  role and is continuous rather than a single crossing point.
- **`headingLevel`** — the reference renders paragraphs, not a heading, and so
  does this. If the statement opens a section in your page, put a real heading
  above it rather than making this one lie about its role.

## Animation, measured

Every word carries a progress value `--fr-t` from 0 (untouched) to 1 (in focus).
Three properties interpolate linearly against it:

| Property | At `--fr-t: 0` | At `--fr-t: 1` |
| :--- | :--- | :--- |
| `filter` | `blur(8px)` | `blur(0px)` |
| `opacity` | `0.8` | `1` |
| `color` | `#505050` | `#ffffff` |

This matches the reference exactly, which was measured mid-transition at three
independent points — blur `3.129px`, opacity `0.9218`, colour `rgb(187,187,187)`
— all three of which solve to the same `t` of `0.609`, confirming plain linear
interpolation with no easing curve.

### The wavefront

Progress across the whole block is

```
progress = clamp((viewportHeight × startRatio − blockTop) ÷ (viewportHeight × spanRatio), 0, 1)
```

Each word then owns a slice of that progress, with the slices overlapping by
`wordWindow` words:

```
per = 1 ÷ (wordCount − 1 + wordWindow)
t   = clamp((progress − index × per) ÷ (wordWindow × per), 0, 1)
```

With the default `wordWindow: 2`, **exactly two words are mid-transition at any
moment** — measured at every sample point through a full scroll — which is what
makes the edge read as a soft front rather than a hard switch. The reference
measures the same two-word window.

With the defaults on a 900px viewport the reveal begins when the block's top
reaches 675px from the top of the viewport and completes as it reaches 45px.

**`startRatio` is deliberately larger than `spanRatio`.** That is what
guarantees a block sitting at or above the top of the viewport resolves to
progress 1 — so a statement already fully on screen, with no scrolling left to
do, ends up readable instead of stranded part-blurred.

### Already visible on load

If the block is on screen when the page loads, its position decides the state
immediately: near the bottom of the viewport it starts dim, near the top it is
already finished. There is no entrance animation to miss and no "played
already" flag to get stuck on.

**The catalog demo adds scroll space that is not part of this component.** A
preview frame is otherwise only as tall as the component, which would leave
nothing to scroll and the statement already finished on arrival — so the
catalog's preview route stages this one entry with an empty run-up above it, an
empty run-out below it, and a small "Scroll to reveal" hint.

None of that is in `FocusRevealStatement.astro`. There are no spacers, no hint
and no catalog classes in the component, and the source panel's copy button
hands over the component alone. **When you drop this into your own page, supply
the scroll yourself** — which normally means nothing at all, because a real page
already has content above and below it.

### Reversal

With `triggerOnce: false` (the default, and the reference's behaviour) the
effect is a true scrub: scroll back up and the words blur again. Verified —
fully focused at `blockTop: 0`, fully blurred again after returning to
`blockTop: 1080`.

`triggerOnce: true` latches each word at its highest value, so the statement
never un-focuses once read.

## Responsive behavior

| Viewport | Type size | Column | Lines (first paragraph) |
| :--- | :--- | :--- | :--- |
| 1280px | 32px / 40px | 700px | 3 |
| 834px | 32px / 40px | 700px | 3 |
| 390px | 25.4px | 358px | 5 |
| 320px | 24px / 30px | 288px | 6 |

The column is `max-width` plus the section's own horizontal padding, so it
narrows with the screen rather than at a breakpoint. `text-wrap: balance` evens
the ragged edge, as in the reference, so a centred statement does not end on a
single orphaned word.

**The type size is fluid, and the reference's is not.** The reference holds 32px
at every width, which makes it overflow horizontally at 320px — measured,
`scrollWidth ≠ clientWidth`. The `clamp()` here falls to 24px instead, and this
component has `scrollWidth === clientWidth` at 320, 390, 834 and 1280.

## Accessibility

- **The words are real text.** Each is a `<span>` with a real text node, and
  real whitespace separates them, so the paragraph reads normally to a screen
  reader. There is no `aria-hidden` on the words and no duplicated copy of the
  sentence to keep in sync. *The reference marks every word `aria-hidden="true"`
  and repeats the whole sentence in an `aria-label` — and nests `<div>`s inside
  a `<p>`, which is invalid HTML.*
- **Nothing is ever hidden.** The dimmest state is `opacity: 0.8` with a blur —
  still present in the accessibility tree, still selectable, still findable by
  the browser's in-page search.
- **No layout shift.** `filter`, `opacity` and `color` do not affect layout.
  Verified: every word's box is identical to two decimal places blurred versus
  focused, and the section's height is unchanged.
- **Reduced motion** shows the completed statement immediately. The CSS sets
  every word to `--fr-t: 1` under `prefers-reduced-motion: reduce`, and the
  script never attaches a scroll listener. A change of preference while the page
  is open is honoured. *The reference ignores the preference entirely — measured
  with reduce on, 19 of its 23 words stayed at `blur(8px)` and only scrolling
  cleared them.*
- **Contrast.** Focused text is `#ffffff` on `#0b0b0d` — 19.8:1. The dim state
  is `#505050` at 80% on the same ground, roughly 2.4:1, which is **below** the
  text minimum. That is acceptable only because the dim state is transient and
  the same words reach full contrast as they are read; if you set
  `triggerOnce: false` on a block that a reader may never scroll past, raise
  `dimColor`.

## Without JavaScript

The blurred starting state is applied **only** when a `data-fr-js` attribute is
present, and that attribute is added by a one-line inline script that runs
during parse. So:

- **Scripting disabled** — the attribute is never added, every word keeps
  `--fr-t: 1`, and the statement is simply legible. Verified: all 28 words at
  `blur(0px)`, opacity 1, full colour.
- **Scripting enabled** — the attribute lands before the first paint, so there
  is no flash of finished text collapsing back into a blur.

The inline script touches only its own section, via
`document.currentScript.previousElementSibling` — no global class on `<html>`,
no document-wide selector. The main script re-applies the attribute defensively
in case a sanitiser or a strict CSP stripped the inline one.

## Multiple instances

**Verified with two on one page**, one using the defaults and one with a light
palette, `align="start"`, a different `maxWidth`, `blur`, `dimOpacity`,
`wordWindow` and `triggerOnce: true`:

- Each controller queries only inside its own section.
- Parked so the first was fully focused, the second was still completely dim —
  each reads its own position.
- The second latched under `triggerOnce` while the first continued to scrub.
- No id is emitted unless you pass one, so no duplicate ids are possible.

## Customisation

Everything visual is a custom property on the root:

```css
.my-statement {
  --fr-color: #101014;
  --fr-dim: #c9c9d1;
  --fr-bg: #f6f6f8;
  --fr-blur: 10px;
  --fr-max: 46rem;
}
```

Pass `class="my-statement"` to attach it, or use the equivalent props inline.
For a light background, raise `dimColor` towards the background rather than
lowering it — the dim state should read as *out of focus*, not as a different
colour.

## The catalog demo's scroll stage

> **This section is about the catalog, not the component.** Nothing here ships
> with the file you copy.

A preview frame is exactly as tall as what it renders. For a scroll-linked
component that is fatal to the demo: there is nothing to scroll, so the reveal
is over before the frame has finished loading and every visitor sees the same
finished statement.

The registry entry for this component therefore sets `previewMode: "scroll"`,
and `src/pages/preview/[slug].astro` responds by wrapping it in a stage:

| Part | Height | Purpose |
| :--- | :--- | :--- |
| Lead spacer | `min(95vh, 900px)` | Puts the block below the reveal's start line, so it arrives fully blurred |
| The component | its own height | Unchanged, unwrapped, unstyled by the stage |
| Tail spacer | `min(95vh, 900px)` | Leaves room to finish the reveal and scroll past it |

Both spacers are transparent, so the entry's registered `background` shows
through and the run-up reads as more of the same page. A small "Scroll to
reveal" hint sits in the lead and scrolls away with it; it is hidden entirely
under `prefers-reduced-motion: reduce`, where there is no reveal to wait for.

`min(95vh, 900px)` rather than plain `vh` because the homepage thumbnail's
iframe is sized from the entry's crop rather than from a device — a purely
viewport-relative spacer would change height with the crop, and the card would
never frame the same thing twice. The 900px cap makes the lead a known height at
any frame taller than that, which is what the entry's `thumb.offsetY: 900` is
measured against.

**None of this is in the component.** `FocusRevealStatement.astro` contains no
spacers, no hint, no catalog class names and no knowledge that a catalog exists.
The source panel still reports one self-contained file, and the copy button
still hands over exactly that file — verified by hashing the clipboard against
the file on disk.

`previewMode` is opt-in per entry and defaults to `"default"`; every other
component's preview is byte-for-byte what it was.

## Differences from the reference

| | Reference | Here |
| :--- | :--- | :--- |
| Word markup | `<div aria-hidden>` inside `<p>` (invalid HTML) | `<span>` with real text |
| Screen readers | `aria-label` repeating the sentence | the paragraph itself |
| Reduced motion | ignored; words stay blurred | completed state shown immediately |
| No JavaScript | words stay blurred and unreadable | fully legible |
| Type size | 32px at every width; overflows at 320px | fluid `clamp()`; no overflow |
| First words | the first four never animate | every word animates |
| Element already at top | resolves to ~0.92 progress | resolves to 1 |

The last two are worth expanding:

- **The reference leaves its first four words permanently in focus** — they
  carry no inline style at any scroll position. Whether that is deliberate or an
  artefact of its server-rendered initial state was not determinable from the
  outside, and a reveal that skips its own opening words is harder to justify
  than one that does not, so every word animates here.
- The reference's start and span ratios (≈0.62 and ≈0.65) leave a fully-visible
  block at 92% progress, with its last words permanently blurred if the page
  cannot scroll further. The ratios here are chosen so that case resolves to a
  readable statement.

## Originality

Every line of markup, CSS and JavaScript here was written for this catalog. The
reference was measured in a browser — computed styles, bounding boxes and
interpolation values at sampled scroll offsets — not copied. No source, class
names, utility framework, wording, brand or product names, fonts, logo, scripts,
analytics, external URLs or assets came from it. The component has no default
copy; the catalog preview's statement is original demo copy passed through the
registry props. The component ships no images and makes no network requests, so
there is nothing to attribute and no third-party licence attached to it.

===== END src/components/FocusRevealStatement.md =====

--- VERIFICATION ---
This component is not enrolled in the library's verification system, so there is no
recorded evidence about it either way. Treat it as unverified and test it yourself.

--- TEST IN THIS PROJECT AFTER INSTALLING ---
- Horizontal overflow at 1440, 1280, 1024, 834, 390 and 320px.
- Keyboard operation end to end, including visible focus.
- The no-JavaScript render.
- prefers-reduced-motion: reduce.
- Two instances on one page, if this project renders more than one.
- If this project uses <ClientRouter />: teardown and reinitialisation across a real navigation.

Component reference

A fragment for a request you write yourself, e.g. “I want to add [paste] to Section 3 of my homepage.” It names the component, links its page, and fixes what must not change — the design build and its effects — while leaving branding and content open. It does not carry the code.

Read it first
“Focus reveal statement” from my Astro Component Library:
https://astro.baysixmedia.com/components/focus-reveal-statement/

Use the existing library component as the implementation source. Preserve its design structure, layout, proportions, spacing system, responsive behavior, visual effects, animations, transitions, hover states, interactions, and accessibility.

The copy, colors, typography, images, and other content may be adapted to match the destination website. Do not redesign, simplify, or reinterpret the component’s underlying design build or effects.

src/components/library/FocusRevealStatement.astro

one fileNo imports, no companion files, no packages. Copy FocusRevealStatement.astro into any Astro project's components folder and render it.

---
/**
 * Focus reveal statement
 *
 * A centred statement whose words start blurred and dim, then come into focus
 * one after another as the page scrolls past. The reveal is scroll-linked, not
 * timed: a travelling wavefront moves through the words, and scrolling back up
 * un-focuses them again.
 *
 * Nothing here moves or resizes — only `filter`, `opacity` and `color` change —
 * so the block occupies exactly the same space before, during and after.
 *
 * Self-contained: no imports, no packages, no global stylesheet. Every colour
 * and measurement is a `--fr-*` custom property on the root.
 */

interface Props {
	/** One entry per paragraph. The wavefront runs through all of them in order. */
	paragraphs?: string[];
	/** Text alignment. The reference is centred. */
	align?: "center" | "start";
	/** Caps the text column and centres it. */
	maxWidth?: string;
	/** Type size. Fluid by default so it never overflows a narrow screen. */
	fontSize?: string;
	/** Line height, unitless or with units. */
	lineHeight?: string;
	/** Font weight of the statement. */
	fontWeight?: number;
	/** Gap between paragraphs. */
	gap?: string;
	/** Vertical padding of the section. */
	padding?: string;
	/** Colour of a word once it is in focus. */
	textColor?: string;
	/** Colour of a word before it is reached. */
	dimColor?: string;
	/** Ground behind the statement. */
	background?: string;
	/** Blur applied to a word before it is reached. */
	blur?: string;
	/** Opacity of a word before it is reached. */
	dimOpacity?: number;
	/** How many words are mid-reveal at once. Higher is a softer wavefront. */
	wordWindow?: number;
	/** Fraction of the viewport height at which the reveal starts. */
	startRatio?: number;
	/** Fraction of the viewport height the reveal takes to finish. */
	spanRatio?: number;
	/** Keep words in focus once reached, instead of un-focusing on the way back. */
	triggerOnce?: boolean;
	/** Added to the root `<section>`. */
	class?: string;
	/** Root element id. */
	id?: string;
}

const {
	// No built-in statement: the copy always comes from the caller.
	paragraphs = [],
	align = "center",
	maxWidth = "700px",
	// 32px at desktop, matching the reference, but allowed to fall to 24px so a
	// 320px screen never has to scroll sideways to finish a sentence.
	fontSize = "clamp(1.5rem, 1.05rem + 2.2vw, 2rem)",
	lineHeight = "1.25",
	fontWeight = 500,
	gap = "40px",
	padding = "clamp(64px, 12vw, 128px)",
	textColor = "#ffffff",
	dimColor = "#505050",
	background = "#0b0b0d",
	blur = "8px",
	dimOpacity = 0.8,
	wordWindow = 2,
	/*
	 * The reveal runs from "top of the block at 75% of the viewport" to "top of
	 * the block at 5%". Deliberately `startRatio > spanRatio`: it guarantees
	 * that a block sitting at or above the top of the viewport resolves to a
	 * progress of 1, so a statement that is already fully on screen with no
	 * room left to scroll ends up readable rather than stranded part-blurred.
	 */
	startRatio = 0.75,
	spanRatio = 0.7,
	triggerOnce = false,
	class: className,
	id,
} = Astro.props;

/*
 * Words are numbered continuously across every paragraph, so the wavefront
 * crosses the paragraph break without restarting — which is what makes the
 * second paragraph read as the end of one thought rather than a new one.
 */
let cursor = 0;
const blocks = paragraphs.map((text) => {
	const words = text.split(/\s+/).filter(Boolean);
	return words.map((word) => ({ word, index: cursor++ }));
});
const total = Math.max(cursor, 1);

const style = [
	`--fr-max:${maxWidth}`,
	`--fr-size:${fontSize}`,
	`--fr-leading:${lineHeight}`,
	`--fr-weight:${fontWeight}`,
	`--fr-gap:${gap}`,
	`--fr-pad:${padding}`,
	`--fr-color:${textColor}`,
	`--fr-dim:${dimColor}`,
	`--fr-bg:${background}`,
	`--fr-blur:${blur}`,
	`--fr-dim-opacity:${dimOpacity}`,
	`--fr-align:${align === "center" ? "center" : "start"}`,
].join(";");
---

{cursor > 0 && (
<><section
	class:list={["reveal", className]}
	id={id}
	style={style}
	data-fr-root
	data-fr-total={total}
	data-fr-window={wordWindow}
	data-fr-start={startRatio}
	data-fr-span={spanRatio}
	data-fr-once={triggerOnce ? "" : null}
>
	<div class="inner">
		{
			blocks.map((words) => (
				<p class="line">
					{words.map(({ word, index }, i) => (
						<>
							{i > 0 && " "}
							{/* A real text node, not an aria-hidden decoration: the
							    paragraph reads normally to a screen reader, and there
							    is no duplicate copy of the sentence to keep in sync. */}
							<span class="word" data-fr-word style={`--fr-i:${index}`}>
								{word}
							</span>
						</>
					))}
				</p>
			))
		}
	</div>
</section>

{/*
	Runs during parse, before the first paint, and only ever touches its own
	section — `currentScript.previousElementSibling` is the <section> above.

	This is what lets the blurred starting state be the CSS default *without*
	stranding anyone: with no JavaScript the attribute is never added, the words
	keep `--fr-t: 1`, and the statement is simply legible. With JavaScript the
	attribute lands before anything is painted, so there is no flash of finished
	text collapsing back into a blur.
*/}
<script is:inline>
	document.currentScript.previousElementSibling.setAttribute("data-fr-js", "");
</script>
</>
)}

<style>
	.reveal {
		box-sizing: border-box;
		padding: var(--fr-pad) clamp(16px, 4vw, 32px);
		background: var(--fr-bg);
		font-family:
			system-ui,
			-apple-system,
			"Segoe UI",
			Roboto,
			sans-serif;
	}

	/* Set rather than inherited, so a host stylesheet cannot change the shape of
	   the block or push a line onto the next row. */
	.reveal *,
	.reveal *::before,
	.reveal *::after {
		box-sizing: border-box;
	}

	.inner {
		display: flex;
		flex-direction: column;
		gap: var(--fr-gap);
		max-width: var(--fr-max);
		margin-inline: auto;
	}

	.line {
		margin: 0;
		padding: 0;
		color: var(--fr-color);
		font-size: var(--fr-size);
		font-weight: var(--fr-weight);
		line-height: var(--fr-leading);
		letter-spacing: -0.01em;
		text-align: var(--fr-align);
		/* Evens the ragged edge the way the reference does, so a centred
		   statement does not end on a single orphaned word. */
		text-wrap: balance;
	}

	/*
	 * `--fr-t` is the word's own progress, 0 (untouched) to 1 (in focus). It
	 * defaults to 1, which is the finished state — so with no script, or before
	 * the script has run, every word is simply readable.
	 */
	.word {
		--fr-t: 1;
		display: inline-block;
		filter: blur(calc((1 - var(--fr-t)) * var(--fr-blur)));
		opacity: calc(
			var(--fr-dim-opacity) + (1 - var(--fr-dim-opacity)) * var(--fr-t)
		);
		color: color-mix(
			in srgb,
			var(--fr-color) calc(var(--fr-t) * 100%),
			var(--fr-dim)
		);
	}

	/* Only once the script is present does the blurred start state apply. */
	.reveal[data-fr-js] .word {
		--fr-t: 0;
	}

	/*
	 * Nothing is animated, hidden or moved for a reader who asked for less
	 * motion: the statement is simply in focus from the start. The script sees
	 * the same query and never attaches a scroll listener.
	 */
	@media (prefers-reduced-motion: reduce) {
		.reveal[data-fr-js] .word {
			--fr-t: 1;
		}
	}
</style>

<script>
	/**
	 * One controller per instance. Every element is looked up inside its own
	 * section, so any number of statements can share a page and each reads its
	 * own position independently.
	 */
	function initFocusReveal(root: HTMLElement) {
		const words = Array.from(root.querySelectorAll<HTMLElement>("[data-fr-word]"));
		if (words.length === 0) return;

		// The inline script normally did this already; repeated for the case
		// where it was stripped by a sanitiser or a strict CSP.
		root.setAttribute("data-fr-js", "");

		const reduce = window.matchMedia("(prefers-reduced-motion: reduce)");
		const total = Number(root.dataset.frTotal) || words.length;
		const win = Math.max(Number(root.dataset.frWindow) || 2, 0.5);
		const startRatio = Number(root.dataset.frStart) || 0.6;
		const spanRatio = Number(root.dataset.frSpan) || 0.65;
		const once = root.hasAttribute("data-fr-once");

		const clamp = (n: number, lo: number, hi: number) => Math.min(Math.max(n, lo), hi);

		// Last value written per word, so a frame that changes nothing writes
		// nothing. Rounded, because sub-percent changes are not visible.
		const written = new Array<number>(words.length).fill(-1);
		let frame = 0;

		function settle() {
			for (let i = 0; i < words.length; i += 1) {
				words[i].style.setProperty("--fr-t", "1");
				written[i] = 1;
			}
		}

		function paint() {
			const rect = root.getBoundingClientRect();
			const vh = window.innerHeight || document.documentElement.clientHeight;
			const start = vh * startRatio;
			const span = Math.max(vh * spanRatio, 1);
			const progress = clamp((start - rect.top) / span, 0, 1);

			/*
			 * Each word owns a slice of the progress, and the slices overlap by
			 * `win` words — so at any moment roughly `win` words are part-way
			 * between dim and focused and the edge reads as a soft wavefront
			 * rather than a hard switch.
			 */
			const per = 1 / (total - 1 + win);
			for (let i = 0; i < words.length; i += 1) {
				const index = Number(words[i].style.getPropertyValue("--fr-i")) || i;
				let t = clamp((progress - index * per) / (win * per), 0, 1);
				if (once && written[i] > t) t = written[i];
				const rounded = Math.round(t * 100) / 100;
				if (rounded !== written[i]) {
					words[i].style.setProperty("--fr-t", String(rounded));
					written[i] = rounded;
				}
			}
		}

		function schedule() {
			if (frame) return;
			frame = requestAnimationFrame(() => {
				frame = 0;
				paint();
			});
		}

		function apply() {
			if (reduce.matches) {
				window.removeEventListener("scroll", schedule);
				window.removeEventListener("resize", schedule);
				settle();
				return;
			}
			window.addEventListener("scroll", schedule, { passive: true });
			window.addEventListener("resize", schedule, { passive: true });
			paint();
		}

		// Honour a preference that changes while the page is open.
		if (typeof reduce.addEventListener === "function") {
			reduce.addEventListener("change", apply);
		}
		apply();
	}

	document.querySelectorAll<HTMLElement>("[data-fr-root]").forEach(initFocusReveal);
</script>