Three ways to get Focus reveal statement into one of your own projects, all pre-filled with this component's name and library page.
Start here
Component reference
Paste it into 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 the branding and content open.
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.
ChatGPT brief
Ask ChatGPT to plan a more detailed customization. Use it when you are still deciding which project the component belongs in, where on the page it sits, and what needs changing. It answers with a Claude Code prompt to run.
Read it first
Help me add this component to one of my Astro websites:
Component: Focus reveal statement
Library page: https://astro.baysixmedia.com/components/focus-reveal-statement
I want it added to:
[ENTER PROJECT, PAGE, OR ROUTE]
Place it:
[ENTER LOCATION OR SELECTOR]
Customize it for:
[ENTER BUSINESS, CONTENT, COLORS, IMAGES, OR OTHER CHANGES]
Create a complete Claude Code implementation prompt. Tell Claude to read the component's source, README, documented props, dependencies, and required assets from the library page; copy everything locally into the target project; preserve its responsive behavior, accessibility, interactions, reduced-motion behavior, and multiple-instance safety; test it at desktop, tablet, and mobile widths; run the target project's available check and build commands; report all changed files and differences from the library version; and avoid committing or deploying.
Placement prompt
Send the implementation request straight to Claude Code. Use it when you already know which project, which page and where on that page it goes. Paste it into Claude Code inside the destination project and it does the work.
Read it first
Add Focus reveal statement from my Astro Component Library to this Astro project.
Library page:
https://astro.baysixmedia.com/components/focus-reveal-statement
First read the component source, README, documented props, dependencies, and required assets from the library page. Copy the component and every required companion asset locally into this project. Do not import files from the deployed library at runtime.
Place it in:
[ENTER PAGE, FILE, OR ROUTE]
Position:
[ENTER WHERE IT SHOULD APPEAR]
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.
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 {
paragraphs = [
"Millions of answers are assembled each day without anyone opening a website. Brands that go unmentioned quietly stop being considered.",
"This is how you stay in the answer.",
],
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(";");
---
<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>