Line Reveal
Headlines rise into place, one line at a time.
Settings
What it does
Line Reveal is a text animation ready for your AI assistant, React or plain JavaScript, with no dependencies. Also called text reveal, split text, masked lines or block reveal.
Each line of text slides up from behind an invisible edge, or a bar of colour sweeps across and uncovers it, the next line a beat later. Lines follow the real wrapping at any width, links stay clickable, and reduced motion gets a gentle fade.
Documentation
One copy gives an assistant everything it needs: the complete code, every option and every pitfall. Paste it in Claude, Cursor or ChatGPT and say where the effect goes; it adapts the integration to your stack, not the code.
Copy the prompt
Ask for it
For instance: Add this line reveal effect to the hero of my home page.What it sends# Foundry44 · Line Reveal Text lines slide up from a mask, or are uncovered by a sweeping colour bar, one after another when scrolled into view. Add this component to my project. Pick the setup below that matches my project and follow only that one. When it uses the code at the end, keep its logic as written (it is self-contained, with no dependencies) and adapt only the integration: JavaScript instead of TypeScript if the project has none, file names, imports, how it starts. ## Markup it expects The element marked `data-f44="line-reveal"`, with this structure inside. Texts, images and the number of items are the demo's: use mine. The `data-f44-*` attributes set options for the ready-made script only; with the code, pass the same options to `lineReveal()` or as props. ```html <h2 data-f44="line-reveal" data-f44-variant="block" data-f44-stagger="100">We design gardens to be walked slowly, by the people who live with them.</h2> ``` ## Options | Option | Attribute | Type | Default | Description | |---|---|---|---|---| | `duration` | `data-f44-duration` | `number` | `700` | Time in ms for one line to slide into place. In block style, the time for each half (cover, then uncover); 750 by default there. | | `stagger` | `data-f44-stagger` | `number` | `80` | Delay in ms between the start of one line and the next. 150 by default in block style. | | `blockColor` | `data-f44-block-color` | `string` | `` | Block style: the bar's colour. Empty uses `--f44-line-reveal-block`, else the text colour. | | `direction` | `data-f44-direction` | `"left" | "right" | "alternate"` | `left` | Block style: the edge the bar grows from. "alternate" flips every other line. | | `keep` | `data-f44-keep` | `boolean` | `false` | Block style: the bar stays behind the text as a highlight band instead of leaving. | | `once` | `data-f44-once` | `boolean` | `true` | Play only the first time the text enters the viewport. Otherwise it hides again once fully offscreen and replays. | | `variant` | `data-f44-variant` | `"mask" | "block"` | `mask` | How lines appear: sliding up from a mask, or uncovered by a solid bar. | ## Setup - **Plain HTML, no build step**: don't paste the code below. Load the ready-made script once, at the end of `<body>`: `<script src="https://foundry44.dev/demos/line-reveal/line-reveal.js" defer></script>` (or download that file and serve it with the page). It starts itself on every `data-f44="line-reveal"` element and reads the `data-f44-*` attributes above. - **React / Next.js with shadcn/ui** (the project has a `components.json`; otherwise, see the next line): `npx shadcn@latest add https://foundry44.dev/r/line-reveal.json` writes `lib/line-reveal-core.ts` and `components/ui/line-reveal.tsx`, then use `<LineReveal />` with the options as props. - **React / Next.js from npm** (no shadcn/ui, or the user prefers a package): `npm install @foundry44/react` (or the project's package manager), then `import { LineReveal } from "@foundry44/react"` with the options as props. With a bundler and no React: `@foundry44/core`, then `import { lineReveal } from "@foundry44/core/line-reveal"`. - **React / Next.js by hand**: save the code below as `lib/line-reveal-core.ts` and import it from there (`@/lib/line-reveal-core` where that alias exists). Don't name it `line-reveal.ts` next to a `LineReveal.tsx`: on macOS and Windows the two names collide. In a component marked `"use client"`, call `lineReveal(ref.current, options)` in a `useEffect` and return `instance.destroy` as its cleanup; call `instance.update(options)` when options change. - **Any other stack**: create on mount with `lineReveal(element, options)`, `update(options)` when they change, `destroy()` on unmount. Only the ready-made script finds `data-f44` elements and starts them by itself. Pasted code does not: call `lineReveal(element)` on each element. ## Pitfalls - Respects prefers-reduced-motion: keep that behaviour. - Lines are measured when the reveal starts. Resizing afterwards is fine: at rest the words are ordinary inline text. - Words are wrapped in spans while the effect is active, so hyphenation and kerning across a space can differ slightly. destroy() puts back the original text nodes. - Do not change the text while the component is mounted: wrapped words are not updated. In React, give the component a new key when the text changes. - Until the script runs, the text is visible; it hides as soon as the component starts, then reveals when in view. - Block style: the bars are absolutely positioned inside the element (set to position: relative if static). With keep, the band sits behind the text: pick a blockColor the text reads on (the default, the text colour, would hide it). ## Code: `line-reveal-core.ts` ```ts // Copyright (c) 2026 Foundry44 · MIT License (SPDX-License-Identifier: MIT) /** * Foundry44 — line-reveal * Text lines appear one after another when the text scrolls into view. Two styles: * - "mask": each line slides up from behind a mask. * - "block": a solid bar grows across each line, the text appears under full cover, * then the bar shrinks away toward the other edge (or stays as a highlight band). * Lines are the lines as the browser wrapped them, measured when the reveal starts, * so the effect follows any width or font. * * Self-contained: no imports, so this file can be copied as-is. */ export interface LineRevealOptions { /** Time in ms for one line to slide into place. In block style, the time for each half (cover, then uncover); 750 by default there. @range 150 2000 50 */ duration?: number; /** Delay in ms between the start of one line and the next. 150 by default in block style. @range 0 300 10 */ stagger?: number; /** Block style: the bar's colour. Empty uses `--f44-line-reveal-block`, else the text colour. */ blockColor?: string; /** Block style: the edge the bar grows from. "alternate" flips every other line. */ direction?: "left" | "right" | "alternate"; /** Block style: the bar stays behind the text as a highlight band instead of leaving. */ keep?: boolean; /** Play only the first time the text enters the viewport. Otherwise it hides again once fully offscreen and replays. */ once?: boolean; /** How lines appear: sliding up from a mask, or uncovered by a solid bar. */ variant?: "mask" | "block"; } export interface LineRevealInstance { update(options: LineRevealOptions): void; destroy(): void; } export const lineRevealDefaults: Required<LineRevealOptions> = { duration: 700, stagger: 80, blockColor: "", direction: "left", keep: false, once: true, variant: "mask", }; // Block style has its own pace: each half of the bar's sweep is a full movement. const BLOCK_DURATION = 750; const BLOCK_STAGGER = 150; const EASE_OUT = "cubic-bezier(0.23, 1, 0.32, 1)"; // A strong in-out: the bar leaves slowly, rushes across, and settles at the edge. const EASE_IN_OUT = "cubic-bezier(0.77, 0, 0.175, 1)"; // Further than 100% so descenders, which the mask lets through slightly, start fully hidden. const HIDDEN_TRANSFORM = "translateY(125%)"; // The mask clips only the bottom edge, with a little room for descenders; the other sides // are extended so accents and italic overhangs are never cut. const MASK_CLIP = "inset(-0.5em -0.25em -0.12em -0.25em)"; const DEFAULT_BLOCK_COLOR = "var(--f44-line-reveal-block, currentColor)"; interface Line { left: number; top: number; width: number; height: number; } export function lineReveal(el: HTMLElement, options: LineRevealOptions = {}): LineRevealInstance { // What the caller set, kept apart from the defaults: duration and stagger default // differently in each style. let given: LineRevealOptions = { ...options }; let opts = { ...lineRevealDefaults, ...given }; if (typeof window === "undefined") return { update() {}, destroy() {} }; // Reduced motion keeps the line-by-line rhythm but drops the movement and the bar: lines fade in. const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true; // Each original text node and the nodes that replaced it, so destroy() can put back // the very same nodes (frameworks keep references to them). const splits: { original: Text; parts: Node[] }[] = []; const masks: HTMLSpanElement[] = []; const words: HTMLSpanElement[] = []; const bars: HTMLSpanElement[] = []; const savedPosition = el.style.position; const savedIsolation = el.style.isolation; let observer: IntersectionObserver | null = null; let resizeObserver: ResizeObserver | null = null; let cleanupTimer = 0; let shown = false; let played = false; let lastWidth = -1; const isBlock = () => opts.variant === "block" && !reduced; const duration = () => given.duration ?? (isBlock() ? BLOCK_DURATION : lineRevealDefaults.duration); const stagger = () => given.stagger ?? (isBlock() ? BLOCK_STAGGER : lineRevealDefaults.stagger); const fades = () => reduced || isBlock(); const property = () => (fades() ? "opacity" : "transform"); const hiddenValue = () => (fades() ? "0" : HIDDEN_TRANSFORM); const shownValue = () => (fades() ? "1" : "none"); const barColor = () => opts.blockColor || DEFAULT_BLOCK_COLOR; const fromRight = (line: number) => opts.direction === "right" || (opts.direction === "alternate" && line % 2 === 1); // Every word gets its own span rather than regrouping words into line elements: // inline markup (links, <em>) stays exactly where it was. Words on the same line // share a delay, so they move as one line. const split = () => { const textNodes: Text[] = []; const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); for (let node = walker.nextNode(); node; node = walker.nextNode()) { if ((node as Text).data.trim()) textNodes.push(node as Text); } for (const original of textNodes) { const parts: Node[] = []; for (const chunk of original.data.split(/(\s+)/)) { if (!chunk) continue; if (/^\s+$/.test(chunk)) { parts.push(document.createTextNode(chunk)); continue; } const mask = document.createElement("span"); mask.style.display = "inline-block"; if (!fades()) mask.style.clipPath = MASK_CLIP; const word = document.createElement("span"); word.style.display = "inline-block"; word.style.setProperty(property(), hiddenValue()); word.textContent = chunk; mask.appendChild(word); parts.push(mask); masks.push(mask); words.push(word); } const fragment = document.createDocumentFragment(); for (const part of parts) fragment.appendChild(part); original.parentNode?.replaceChild(fragment, original); splits.push({ original, parts }); } }; const unsplit = () => { for (const { original, parts } of splits) { const first = parts[0]; first?.parentNode?.insertBefore(original, first); for (const part of parts) part.parentNode?.removeChild(part); } splits.length = 0; masks.length = 0; words.length = 0; }; // Groups words by line (same top) and returns, for each word, its line index, // and for each line its box relative to the element. const measure = () => { const box = el.getBoundingClientRect(); const lineOf: number[] = []; const lines: Line[] = []; let lineTop = Number.NEGATIVE_INFINITY; for (const mask of masks) { const r = mask.getBoundingClientRect(); let current = lines[lines.length - 1]; if (!current || r.top > lineTop + 1) { lineTop = r.top; current = { left: r.left, top: r.top, width: r.width, height: r.height }; lines.push(current); } else { const right = Math.max(current.left + current.width, r.left + r.width); current.left = Math.min(current.left, r.left); current.width = right - current.left; current.height = Math.max(current.height, r.height); } lineOf.push(lines.length - 1); } for (const line of lines) { line.left -= box.left + el.clientLeft; line.top -= box.top + el.clientTop; } return { lineOf, lines }; }; const removeBars = () => { window.clearTimeout(cleanupTimer); for (const bar of bars) bar.remove(); bars.length = 0; }; // A bar is two nested spans: the outer one grows from one edge (cover), the inner one // shrinks toward the other edge (uncover). Their product is the visible bar, so each // half is a plain CSS transition with its own delay. const makeBar = (line: Line, index: number) => { const right = fromRight(index); const outer = document.createElement("span"); outer.setAttribute("aria-hidden", "true"); // Larger than the words, so ascenders and descenders never poke out at the switch // and the band frames them like a highlighter. const padX = Math.round(line.height * 0.08); const padY = Math.round(line.height * 0.12); Object.assign(outer.style, { position: "absolute", left: `${line.left - padX}px`, top: `${line.top - padY}px`, width: `${line.width + padX * 2}px`, height: `${line.height + padY * 2}px`, pointerEvents: "none", // In front while it hides the text; behind it when it stays as a band. zIndex: opts.keep ? "-1" : "1", transformOrigin: right ? "right center" : "left center", }); const inner = document.createElement("span"); Object.assign(inner.style, { display: "block", width: "100%", height: "100%", background: barColor(), transformOrigin: right ? "left center" : "right center", }); outer.appendChild(inner); el.appendChild(outer); bars.push(outer); return { outer, inner }; }; const prepareElement = () => { if (getComputedStyle(el).position === "static") el.style.position = "relative"; // The kept band sits behind the text but above the element's own background. if (opts.keep) el.style.isolation = "isolate"; }; const showBlock = () => { prepareElement(); removeBars(); const { lineOf, lines } = measure(); const half = duration(); const step = stagger(); const made = lines.map((line, i) => makeBar(line, i)); for (const { outer } of made) outer.style.transform = "scaleX(0)"; // Flush the start state so the transitions below run from it. void el.offsetWidth; made.forEach(({ outer, inner }, i) => { const delay = i * step; outer.style.transition = `transform ${half}ms ${EASE_IN_OUT} ${delay}ms`; outer.style.transform = "scaleX(1)"; if (!opts.keep) { inner.style.transition = `transform ${half}ms ${EASE_IN_OUT} ${delay + half}ms`; inner.style.transform = "scaleX(0)"; } }); // The text switches on at full cover, hidden by the bar at that instant. words.forEach((word, i) => { word.style.transition = `opacity 0ms linear ${(lineOf[i] ?? 0) * step + half}ms`; word.style.opacity = "1"; }); if (!opts.keep) cleanupTimer = window.setTimeout(removeBars, (lines.length - 1) * step + half * 2 + 100); lastWidth = el.clientWidth; }; // A kept band follows the text when it rewraps: rebuilt at rest, without animating. const relayoutBands = () => { if (!shown || !opts.keep || !isBlock()) return; if (el.clientWidth === lastWidth) return; lastWidth = el.clientWidth; removeBars(); const { lines } = measure(); lines.forEach((line, i) => { makeBar(line, i); }); }; const show = () => { if (shown) return; shown = true; played = true; if (isBlock()) { showBlock(); return; } // Measuring also flushes the hidden state, so the transition below has a start value. const { lineOf } = measure(); const prop = property(); words.forEach((word, i) => { word.style.transition = `${prop} ${duration()}ms ${EASE_OUT} ${(lineOf[i] ?? 0) * stagger()}ms`; word.style.setProperty(prop, shownValue()); }); }; const hide = () => { if (!shown) return; shown = false; removeBars(); for (const word of words) { word.style.transition = "none"; word.style.setProperty(property(), hiddenValue()); } }; const bind = () => { if (isBlock() && opts.keep && typeof ResizeObserver !== "undefined") { resizeObserver = new ResizeObserver(relayoutBands); resizeObserver.observe(el); } if (opts.once && played) return; if (typeof IntersectionObserver === "undefined") { show(); return; } observer = new IntersectionObserver( (entries) => { for (const entry of entries) { if (entry.isIntersecting) { show(); if (opts.once) { observer?.disconnect(); observer = null; } continue; } // The observed area is shrunk at the bottom, so "not intersecting" can still be // partly on screen: hide only once the text is entirely out of sight. const { top, bottom } = entry.boundingClientRect; if (bottom <= 0 || top >= window.innerHeight) hide(); } }, // Wait until the text is a little inside the viewport so the start is actually seen. { rootMargin: "0px 0px -10% 0px" }, ); observer.observe(el); }; const unbind = () => { observer?.disconnect(); observer = null; resizeObserver?.disconnect(); resizeObserver = null; }; const restoreElement = () => { el.style.position = savedPosition; el.style.isolation = savedIsolation; }; split(); bind(); return { update(next) { const previous = opts; given = { ...given, ...next }; opts = { ...lineRevealDefaults, ...given }; // The style decides how words are wrapped and how bars are built: start over, // and replay at once if the text was already revealed. if (opts.variant !== previous.variant || opts.direction !== previous.direction || opts.keep !== previous.keep) { const wasShown = shown; unbind(); removeBars(); unsplit(); restoreElement(); shown = false; played = false; split(); if (wasShown) show(); bind(); return; } if (opts.blockColor !== previous.blockColor) { for (const bar of bars) { const inner = bar.firstElementChild as HTMLElement | null; if (inner) inner.style.background = barColor(); } } // duration and stagger are read when a reveal starts; only `once` changes the wiring. if (opts.once !== previous.once) { unbind(); bind(); } }, destroy() { unbind(); removeBars(); unsplit(); restoreElement(); }, }; } ```
Two files that become yours: the engine, written without React, and a thin component around it. No package to install. The component is marked use client, so it sits inside a server component as it is.
Add it with the shadcn CLI
In a project set up for shadcn/ui, one command writes both files. Without it, copy them by hand in the two steps below.npx shadcn@latest add https://foundry44.dev/r/line-reveal.json
Or add the engine by hand
Save it aslib/line-reveal-core.ts. It has no dependencies and does nothing on the server.And the component
Save it ascomponents/ui/line-reveal.tsx. It creates the effect on mount, passes every prop change to it live, and cleans up on unmount.// Copyright (c) 2026 Foundry44 · MIT License (SPDX-License-Identifier: MIT) // Generated by scripts/generate.mjs. Do not edit: change the core component instead. "use client"; import { useEffect, useRef, type ComponentPropsWithoutRef } from "react"; import { lineReveal, type LineRevealOptions, type LineRevealInstance } from "@/lib/line-reveal-core"; export interface LineRevealProps extends LineRevealOptions, Omit<ComponentPropsWithoutRef<"div">, keyof LineRevealOptions> {} export function LineReveal({ duration, stagger, blockColor, direction, keep, once, variant, style, ...props }: LineRevealProps) { const ref = useRef<HTMLElementTagNameMap["div"]>(null); const instance = useRef<LineRevealInstance | null>(null); useEffect(() => { if (!ref.current) return; instance.current = lineReveal(ref.current); return () => { instance.current?.destroy(); instance.current = null; }; }, []); // Runs right after creation, then on every option change. useEffect(() => { instance.current?.update(definedOnly({ duration, stagger, blockColor, direction, keep, once, variant })); }, [duration, stagger, blockColor, direction, keep, once, variant]); return <div ref={ref} style={style} {...props} />; } function definedOnly<T extends object>(o: T): T { return Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined)) as T; }
Or install the package
It is also on npm, in@foundry44/reactwith the other free components. Import it from there instead of@/components/ui.npm install @foundry44/react
import { LineReveal } from "@foundry44/react";
Use it
Every prop is optional: with none, you get the effect as the preview shows it.import { LineReveal } from "@/components/ui/line-reveal"; export function Example() { return ( <LineReveal duration={700} stagger={80} blockColor="" direction="left" keep={false} once={true} variant="mask" > {/* your content */} </LineReveal> ); }
PropsProp Default What it is duration 700 Time in ms for one line to slide into place. In block style, the time for each half (cover, then uncover); 750 by default there. stagger 80 Delay in ms between the start of one line and the next. 150 by default in block style. blockColor Block style: the bar's colour. Empty uses --f44-line-reveal-block, else the text colour.direction left Block style: the edge the bar grows from. "alternate" flips every other line. keep false Block style: the bar stays behind the text as a highlight band instead of leaving. once true Play only the first time the text enters the viewport. Otherwise it hides again once fully offscreen and replays. variant mask How lines appear: sliding up from a mask, or uncovered by a solid bar. Avoid the pitfalls
- It follows
prefers-reduced-motion: under reduced motion the effect settles at once. - Lines are measured when the reveal starts. Resizing afterwards is fine: at rest the words are ordinary inline text.
- Words are wrapped in spans while the effect is active, so hyphenation and kerning across a space can differ slightly. destroy() puts back the original text nodes.
- Do not change the text while the component is mounted: wrapped words are not updated. In React, give the component a new key when the text changes.
- Until the script runs, the text is visible; it hides as soon as the component starts, then reveals when in view.
- Block style: the bars are absolutely positioned inside the element (set to position: relative if static). With keep, the band sits behind the text: pick a blockColor the text reads on (the default, the text colour, would hide it).
- It follows
One file, readable, no dependencies and no build step. It sets itself up on every element marked data-f44="line-reveal", in plain HTML, WordPress, Framer embeds or any framework.
Load the script
Download it, put it next to your page and load it once, anywhere in the page.<script src="line-reveal.js" defer></script>
The file/*! * Foundry44 · Line Reveal — https://foundry44.dev/components/line-reveal * Text lines slide up from a mask, or are uncovered by a sweeping colour bar, one after another when scrolled into view. * Copyright (c) 2026 Foundry44 · MIT License (SPDX-License-Identifier: MIT) * * Usage: load this file, then add data-f44="line-reveal" to any element. * Optional attributes (defaults shown): * data-f44-duration="700" * data-f44-stagger="80" * data-f44-block-color="" * data-f44-direction="left" * data-f44-keep="false" * data-f44-once="true" * data-f44-variant="mask" * Manual: const instance = Foundry44.lineReveal(element, { duration: 700, stagger: 80, blockColor: "", direction: "left", keep: false, once: true, variant: "mask" }); * instance.destroy(); * Re-scan the page after adding elements dynamically: Foundry44.init(); * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ (function () { "use strict"; /** * Foundry44 — line-reveal * Text lines appear one after another when the text scrolls into view. Two styles: * - "mask": each line slides up from behind a mask. * - "block": a solid bar grows across each line, the text appears under full cover, * then the bar shrinks away toward the other edge (or stays as a highlight band). * Lines are the lines as the browser wrapped them, measured when the reveal starts, * so the effect follows any width or font. * * Self-contained: no imports, so this file can be copied as-is. */ const lineRevealDefaults = { duration: 700, stagger: 80, blockColor: "", direction: "left", keep: false, once: true, variant: "mask", }; // Block style has its own pace: each half of the bar's sweep is a full movement. const BLOCK_DURATION = 750; const BLOCK_STAGGER = 150; const EASE_OUT = "cubic-bezier(0.23, 1, 0.32, 1)"; // A strong in-out: the bar leaves slowly, rushes across, and settles at the edge. const EASE_IN_OUT = "cubic-bezier(0.77, 0, 0.175, 1)"; // Further than 100% so descenders, which the mask lets through slightly, start fully hidden. const HIDDEN_TRANSFORM = "translateY(125%)"; // The mask clips only the bottom edge, with a little room for descenders; the other sides // are extended so accents and italic overhangs are never cut. const MASK_CLIP = "inset(-0.5em -0.25em -0.12em -0.25em)"; const DEFAULT_BLOCK_COLOR = "var(--f44-line-reveal-block, currentColor)"; function lineReveal(el, options = {}) { var _a; // What the caller set, kept apart from the defaults: duration and stagger default // differently in each style. let given = { ...options }; let opts = { ...lineRevealDefaults, ...given }; if (typeof window === "undefined") return { update() {}, destroy() {} }; // Reduced motion keeps the line-by-line rhythm but drops the movement and the bar: lines fade in. const reduced = ((_a = window.matchMedia) === null || _a === void 0 ? void 0 : _a.call(window, "(prefers-reduced-motion: reduce)").matches) === true; // Each original text node and the nodes that replaced it, so destroy() can put back // the very same nodes (frameworks keep references to them). const splits = []; const masks = []; const words = []; const bars = []; const savedPosition = el.style.position; const savedIsolation = el.style.isolation; let observer = null; let resizeObserver = null; let cleanupTimer = 0; let shown = false; let played = false; let lastWidth = -1; const isBlock = () => opts.variant === "block" && !reduced; const duration = () => { var _a; return (_a = given.duration) !== null && _a !== void 0 ? _a : isBlock() ? BLOCK_DURATION : lineRevealDefaults.duration; }; const stagger = () => { var _a; return (_a = given.stagger) !== null && _a !== void 0 ? _a : isBlock() ? BLOCK_STAGGER : lineRevealDefaults.stagger; }; const fades = () => reduced || isBlock(); const property = () => (fades() ? "opacity" : "transform"); const hiddenValue = () => (fades() ? "0" : HIDDEN_TRANSFORM); const shownValue = () => (fades() ? "1" : "none"); const barColor = () => opts.blockColor || DEFAULT_BLOCK_COLOR; const fromRight = (line) => opts.direction === "right" || (opts.direction === "alternate" && line % 2 === 1); // Every word gets its own span rather than regrouping words into line elements: // inline markup (links, <em>) stays exactly where it was. Words on the same line // share a delay, so they move as one line. const split = () => { var _a; const textNodes = []; const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); for (let node = walker.nextNode(); node; node = walker.nextNode()) { if (node.data.trim()) textNodes.push(node); } for (const original of textNodes) { const parts = []; for (const chunk of original.data.split(/(\s+)/)) { if (!chunk) continue; if (/^\s+$/.test(chunk)) { parts.push(document.createTextNode(chunk)); continue; } const mask = document.createElement("span"); mask.style.display = "inline-block"; if (!fades()) mask.style.clipPath = MASK_CLIP; const word = document.createElement("span"); word.style.display = "inline-block"; word.style.setProperty(property(), hiddenValue()); word.textContent = chunk; mask.appendChild(word); parts.push(mask); masks.push(mask); words.push(word); } const fragment = document.createDocumentFragment(); for (const part of parts) fragment.appendChild(part); (_a = original.parentNode) === null || _a === void 0 ? void 0 : _a.replaceChild(fragment, original); splits.push({ original, parts }); } }; const unsplit = () => { var _a, _b; for (const { original, parts } of splits) { const first = parts[0]; (_a = first === null || first === void 0 ? void 0 : first.parentNode) === null || _a === void 0 ? void 0 : _a.insertBefore(original, first); for (const part of parts) (_b = part.parentNode) === null || _b === void 0 ? void 0 : _b.removeChild(part); } splits.length = 0; masks.length = 0; words.length = 0; }; // Groups words by line (same top) and returns, for each word, its line index, // and for each line its box relative to the element. const measure = () => { const box = el.getBoundingClientRect(); const lineOf = []; const lines = []; let lineTop = Number.NEGATIVE_INFINITY; for (const mask of masks) { const r = mask.getBoundingClientRect(); let current = lines[lines.length - 1]; if (!current || r.top > lineTop + 1) { lineTop = r.top; current = { left: r.left, top: r.top, width: r.width, height: r.height }; lines.push(current); } else { const right = Math.max(current.left + current.width, r.left + r.width); current.left = Math.min(current.left, r.left); current.width = right - current.left; current.height = Math.max(current.height, r.height); } lineOf.push(lines.length - 1); } for (const line of lines) { line.left -= box.left + el.clientLeft; line.top -= box.top + el.clientTop; } return { lineOf, lines }; }; const removeBars = () => { window.clearTimeout(cleanupTimer); for (const bar of bars) bar.remove(); bars.length = 0; }; // A bar is two nested spans: the outer one grows from one edge (cover), the inner one // shrinks toward the other edge (uncover). Their product is the visible bar, so each // half is a plain CSS transition with its own delay. const makeBar = (line, index) => { const right = fromRight(index); const outer = document.createElement("span"); outer.setAttribute("aria-hidden", "true"); // Larger than the words, so ascenders and descenders never poke out at the switch // and the band frames them like a highlighter. const padX = Math.round(line.height * 0.08); const padY = Math.round(line.height * 0.12); Object.assign(outer.style, { position: "absolute", left: `${line.left - padX}px`, top: `${line.top - padY}px`, width: `${line.width + padX * 2}px`, height: `${line.height + padY * 2}px`, pointerEvents: "none", // In front while it hides the text; behind it when it stays as a band. zIndex: opts.keep ? "-1" : "1", transformOrigin: right ? "right center" : "left center", }); const inner = document.createElement("span"); Object.assign(inner.style, { display: "block", width: "100%", height: "100%", background: barColor(), transformOrigin: right ? "left center" : "right center", }); outer.appendChild(inner); el.appendChild(outer); bars.push(outer); return { outer, inner }; }; const prepareElement = () => { if (getComputedStyle(el).position === "static") el.style.position = "relative"; // The kept band sits behind the text but above the element's own background. if (opts.keep) el.style.isolation = "isolate"; }; const showBlock = () => { prepareElement(); removeBars(); const { lineOf, lines } = measure(); const half = duration(); const step = stagger(); const made = lines.map((line, i) => makeBar(line, i)); for (const { outer } of made) outer.style.transform = "scaleX(0)"; // Flush the start state so the transitions below run from it. void el.offsetWidth; made.forEach(({ outer, inner }, i) => { const delay = i * step; outer.style.transition = `transform ${half}ms ${EASE_IN_OUT} ${delay}ms`; outer.style.transform = "scaleX(1)"; if (!opts.keep) { inner.style.transition = `transform ${half}ms ${EASE_IN_OUT} ${delay + half}ms`; inner.style.transform = "scaleX(0)"; } }); // The text switches on at full cover, hidden by the bar at that instant. words.forEach((word, i) => { var _a; word.style.transition = `opacity 0ms linear ${((_a = lineOf[i]) !== null && _a !== void 0 ? _a : 0) * step + half}ms`; word.style.opacity = "1"; }); if (!opts.keep) cleanupTimer = window.setTimeout(removeBars, (lines.length - 1) * step + half * 2 + 100); lastWidth = el.clientWidth; }; // A kept band follows the text when it rewraps: rebuilt at rest, without animating. const relayoutBands = () => { if (!shown || !opts.keep || !isBlock()) return; if (el.clientWidth === lastWidth) return; lastWidth = el.clientWidth; removeBars(); const { lines } = measure(); lines.forEach((line, i) => { makeBar(line, i); }); }; const show = () => { if (shown) return; shown = true; played = true; if (isBlock()) { showBlock(); return; } // Measuring also flushes the hidden state, so the transition below has a start value. const { lineOf } = measure(); const prop = property(); words.forEach((word, i) => { var _a; word.style.transition = `${prop} ${duration()}ms ${EASE_OUT} ${((_a = lineOf[i]) !== null && _a !== void 0 ? _a : 0) * stagger()}ms`; word.style.setProperty(prop, shownValue()); }); }; const hide = () => { if (!shown) return; shown = false; removeBars(); for (const word of words) { word.style.transition = "none"; word.style.setProperty(property(), hiddenValue()); } }; const bind = () => { if (isBlock() && opts.keep && typeof ResizeObserver !== "undefined") { resizeObserver = new ResizeObserver(relayoutBands); resizeObserver.observe(el); } if (opts.once && played) return; if (typeof IntersectionObserver === "undefined") { show(); return; } observer = new IntersectionObserver( (entries) => { for (const entry of entries) { if (entry.isIntersecting) { show(); if (opts.once) { observer === null || observer === void 0 ? void 0 : observer.disconnect(); observer = null; } continue; } // The observed area is shrunk at the bottom, so "not intersecting" can still be // partly on screen: hide only once the text is entirely out of sight. const { top, bottom } = entry.boundingClientRect; if (bottom <= 0 || top >= window.innerHeight) hide(); } }, // Wait until the text is a little inside the viewport so the start is actually seen. { rootMargin: "0px 0px -10% 0px" }, ); observer.observe(el); }; const unbind = () => { observer === null || observer === void 0 ? void 0 : observer.disconnect(); observer = null; resizeObserver === null || resizeObserver === void 0 ? void 0 : resizeObserver.disconnect(); resizeObserver = null; }; const restoreElement = () => { el.style.position = savedPosition; el.style.isolation = savedIsolation; }; split(); bind(); return { update(next) { const previous = opts; given = { ...given, ...next }; opts = { ...lineRevealDefaults, ...given }; // The style decides how words are wrapped and how bars are built: start over, // and replay at once if the text was already revealed. if (opts.variant !== previous.variant || opts.direction !== previous.direction || opts.keep !== previous.keep) { const wasShown = shown; unbind(); removeBars(); unsplit(); restoreElement(); shown = false; played = false; split(); if (wasShown) show(); bind(); return; } if (opts.blockColor !== previous.blockColor) { for (const bar of bars) { const inner = bar.firstElementChild; if (inner) inner.style.background = barColor(); } } // duration and stagger are read when a reveal starts; only `once` changes the wiring. if (opts.once !== previous.once) { unbind(); bind(); } }, destroy() { unbind(); removeBars(); unsplit(); restoreElement(); }, }; } function parse(value) { if (value === "true") return true; if (value === "false") return false; var n = Number(value); return value !== "" && !isNaN(n) ? n : value; } function init() { document.querySelectorAll('[data-f44="line-reveal"]').forEach(function (el) { if (el.__f44) return; var options = {}; [ ["duration", "data-f44-duration"], ["stagger", "data-f44-stagger"], ["blockColor", "data-f44-block-color"], ["direction", "data-f44-direction"], ["keep", "data-f44-keep"], ["once", "data-f44-once"], ["variant", "data-f44-variant"], ].forEach(function (pair) { var value = el.getAttribute(pair[1]); if (value !== null) options[pair[0]] = parse(value); }); el.__f44 = lineReveal(el, options); }); } var Foundry44 = (window.Foundry44 = window.Foundry44 || {}); Foundry44.lineReveal = lineReveal; Foundry44._inits = Foundry44._inits || []; Foundry44._inits.push(init); Foundry44.init = function () { Foundry44._inits.forEach(function (fn) { fn(); }); }; if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init); else init(); })();
Mark your element
Adddata-f44="line-reveal". Each setting is an attribute on the same element, and an attribute always wins over the default.<div data-f44="line-reveal" data-f44-duration="700"> <!-- your content --> </div>
SettingsAttribute Default What it is data-f44-duration 700 Time in ms for one line to slide into place. In block style, the time for each half (cover, then uncover); 750 by default there. data-f44-stagger 80 Delay in ms between the start of one line and the next. 150 by default in block style. data-f44-block-color Block style: the bar's colour. Empty uses --f44-line-reveal-block, else the text colour.data-f44-direction left Block style: the edge the bar grows from. "alternate" flips every other line. data-f44-keep false Block style: the bar stays behind the text as a highlight band instead of leaving. data-f44-once true Play only the first time the text enters the viewport. Otherwise it hides again once fully offscreen and replays. data-f44-variant mask How lines appear: sliding up from a mask, or uncovered by a solid bar. Drive it from JavaScript
When the element arrives after the page has loaded, or when the settings come from your code.// an element without data-f44, or the script would set it up twice const element = document.querySelector(".my-element"); const instance = Foundry44.lineReveal(element, { duration: 700, stagger: 80 }); instance.update({ duration: 700 }); // any option, live instance.destroy(); // listeners and observers gone, inline styles restored // elements added after the page loaded Foundry44.init();
Avoid the pitfalls
- It follows
prefers-reduced-motion: under reduced motion the effect settles at once. - Lines are measured when the reveal starts. Resizing afterwards is fine: at rest the words are ordinary inline text.
- Words are wrapped in spans while the effect is active, so hyphenation and kerning across a space can differ slightly. destroy() puts back the original text nodes.
- Do not change the text while the component is mounted: wrapped words are not updated. In React, give the component a new key when the text changes.
- Until the script runs, the text is visible; it hides as soon as the component starts, then reveals when in view.
- Block style: the bars are absolutely positioned inside the element (set to position: relative if static). With keep, the band sits behind the text: pick a blockColor the text reads on (the default, the text colour, would hide it).
- It follows
For a site builder: Webflow, WordPress, Shopify, Framer, or any site that accepts HTML. One block carries the whole script; paste it, then mark an element.
Paste the embed
Paste it where your builder takes custom HTML: an embed or code block on the page, or the site's footer code to use the effect on every page.Mark the element
Give the element the attributedata-f44with the valueline-reveal. Most builders have a field for custom attributes; otherwise, add it to the element's HTML.SettingsEvery setting is an attribute too, on the same element. Leave one out and the default applies.Attribute Default What it is data-f44-duration 700 Time in ms for one line to slide into place. In block style, the time for each half (cover, then uncover); 750 by default there. data-f44-stagger 80 Delay in ms between the start of one line and the next. 150 by default in block style. data-f44-block-color Block style: the bar's colour. Empty uses --f44-line-reveal-block, else the text colour.data-f44-direction left Block style: the edge the bar grows from. "alternate" flips every other line. data-f44-keep false Block style: the bar stays behind the text as a highlight band instead of leaving. data-f44-once true Play only the first time the text enters the viewport. Otherwise it hides again once fully offscreen and replays. data-f44-variant mask How lines appear: sliding up from a mask, or uncovered by a solid bar. Avoid the pitfalls
- It follows
prefers-reduced-motion: under reduced motion the effect settles at once. - Lines are measured when the reveal starts. Resizing afterwards is fine: at rest the words are ordinary inline text.
- Words are wrapped in spans while the effect is active, so hyphenation and kerning across a space can differ slightly. destroy() puts back the original text nodes.
- Do not change the text while the component is mounted: wrapped words are not updated. In React, give the component a new key when the text changes.
- Until the script runs, the text is visible; it hides as soon as the component starts, then reveals when in view.
- Block style: the bars are absolutely positioned inside the element (set to position: relative if static). With keep, the band sits behind the text: pick a blockColor the text reads on (the default, the text colour, would hide it).
- It follows