Rectangular Text Reveal
A boxed-mask text reveal that introduces short statements through a rectangular frame for graphic brand and campaign sections.

Overview
Rectangular Text Reveal helps important words arrive with intent: a rectangular mask controls the entrance.
Best on short, high-emphasis text: headlines, launch lines, section openers, proof points. Avoid it on body copy, errors, legal text, or instructions, where legibility cannot be negotiable.
The production risk is the text disappearing into the effect. Ship the final phrase as genuine HTML, keep per-character motion out of the accessibility tree, and serve the settled, legible text immediately when motion is reduced.
Install Command
npx hyperiux add rectangular-text-revealUsage Code
import RectangularTextReveal from "@/components/effects/rectangular-text-reveal";
export default function Page() {
return (
<RectangularTextReveal
overlayEnterDuration={0.35}
overlayExitDuration={0.35}
direction="bottom"
coverDuration={0.4}
revealDuration={0.5}
baseColor="#ff6b00"
overlayColor="#111111"
className="max-w-6xl"
>
<h1 className="text-6xl max-sm:text-3xl leading-[0.95] font-semibold">
A directional rectangular reveal built for expressive,
editorial motion systems.
</h1>
</RectangularTextReveal>
);
}Component Code
// Built using Hyperiux Vault: https://vault.hyperiux.com
"use client";
import React, { useLayoutEffect, useRef } from "react";
import gsap from "gsap";
import { CustomEase } from "gsap/CustomEase";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { SplitText } from "gsap/SplitText";
gsap.registerPlugin(SplitText, CustomEase, ScrollTrigger);
const DEFAULT_TAG = "div";
const DEFAULT_TEXT = "Rectangular text reveal in motion.";
const DEFAULT_BASE_COLOR = "#ea580c";
const DEFAULT_OVERLAY_COLOR = "#ffffff";
const DEFAULT_STAGGER = 0.2;
const DEFAULT_COVER_DURATION = 0.34;
const DEFAULT_REVEAL_DURATION = 0.42;
const DEFAULT_OVERLAY_ENTER_DURATION = 0.24;
const DEFAULT_OVERLAY_EXIT_DURATION = 0.28;
const DEFAULT_INSET_X = "0.08em";
const DEFAULT_INSET_Y = "0.08em";
const DEFAULT_TRIGGER_START = "top bottom";
const DEFAULT_DIRECTION = "left";
const DEFAULT_DELAY = 0;
const DEFAULT_TOGGLE_ACTIONS = "play none none reset";
const REDUCED_MOTION_Y_OFFSET = 32;
const REDUCED_MOTION_DURATION = 0.9;
const REDUCED_MOTION_STAGGER = 0.12;
const REVEAL_EASE = "hyperEase";
const SCALE_X_ZERO = "scaleX(0)";
const SCALE_Y_ZERO = "scaleY(0)";
const LINE_CLASS_NAME = "tb-line";
function getOrigins(direction) {
switch (direction) {
case "right":
return {
enterOrigin: "100% 50%",
exitOrigin: "0% 50%",
axis: "scaleX",
};
case "top":
return {
enterOrigin: "50% 0%",
exitOrigin: "50% 100%",
axis: "scaleY",
};
case "bottom":
return {
enterOrigin: "50% 100%",
exitOrigin: "50% 0%",
axis: "scaleY",
};
case "left":
default:
return {
enterOrigin: "0% 50%",
exitOrigin: "100% 50%",
axis: "scaleX",
};
}
}
function createRevealRect({ insetX, insetY, background, transformOrigin, axis, zIndex, dataAttribute, }) {
const rect = document.createElement("div");
rect.setAttribute(dataAttribute, "true");
Object.assign(rect.style, {
position: "absolute",
left: `-${insetX}`,
right: `-${insetX}`,
top: `-${insetY}`,
bottom: `-${insetY}`,
background,
transformOrigin,
transform: axis === "scaleX" ? SCALE_X_ZERO : SCALE_Y_ZERO,
zIndex: String(zIndex),
pointerEvents: "none",
willChange: "transform",
});
return rect;
}
export default function RectangularTextReveal({ children = DEFAULT_TEXT, as: Tag = DEFAULT_TAG, className = "", baseColor = DEFAULT_BASE_COLOR, overlayColor = DEFAULT_OVERLAY_COLOR, useOverlay = true, stagger = DEFAULT_STAGGER, coverDuration = DEFAULT_COVER_DURATION, revealDuration = DEFAULT_REVEAL_DURATION, overlayEnterDuration = DEFAULT_OVERLAY_ENTER_DURATION, overlayExitDuration = DEFAULT_OVERLAY_EXIT_DURATION, insetX = DEFAULT_INSET_X, insetY = DEFAULT_INSET_Y, triggerStart = DEFAULT_TRIGGER_START, once = false, direction = DEFAULT_DIRECTION, delay = DEFAULT_DELAY, toggleActions = DEFAULT_TOGGLE_ACTIONS, }) {
const elementRef = useRef(null);
useLayoutEffect(() => {
if (!elementRef.current) {
return;
}
const prefersReduced = window.matchMedia &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const { enterOrigin, exitOrigin, axis } = getOrigins(direction);
CustomEase.create(REVEAL_EASE, "0.4,0,0.2,1");
const split = new SplitText(elementRef.current, {
type: "lines",
linesClass: LINE_CLASS_NAME,
});
const lines = split.lines;
// Line setup
gsap.set(elementRef.current, { opacity: 1 });
if (prefersReduced) {
gsap.set(lines, {
opacity: 0,
y: REDUCED_MOTION_Y_OFFSET,
willChange: "transform, opacity",
});
const timeline = gsap.timeline({ paused: true, delay });
timeline.to(lines, {
opacity: 1,
y: 0,
duration: REDUCED_MOTION_DURATION,
ease: "power3.out",
stagger: REDUCED_MOTION_STAGGER,
});
const scrollTrigger = ScrollTrigger.create({
trigger: elementRef.current,
start: triggerStart,
once,
animation: timeline,
toggleActions: once ? "play none none none" : toggleActions,
...(once
? {}
: {
onLeaveBack: () => {
timeline.pause(0);
gsap.set(lines, { opacity: 0, y: REDUCED_MOTION_Y_OFFSET });
},
}),
});
return () => {
timeline.kill();
scrollTrigger.kill();
split.revert();
};
}
const wrappers = [];
const baseRects = [];
const overlayRects = [];
lines.forEach((line) => {
const wrapper = document.createElement("div");
wrapper.style.position = "relative";
wrapper.style.display = "block";
wrapper.style.overflow = "hidden";
wrapper.style.width = "fit-content";
wrapper.style.maxWidth = "100%";
line.parentNode.insertBefore(wrapper, line);
wrapper.appendChild(line);
line.style.position = "relative";
line.style.display = "block";
line.style.width = "fit-content";
line.style.maxWidth = "100%";
line.style.zIndex = "1";
line.style.opacity = "0";
line.style.willChange = "opacity";
const baseRect = createRevealRect({
insetX,
insetY,
background: baseColor,
transformOrigin: enterOrigin,
axis,
zIndex: 2,
dataAttribute: "data-reveal-base",
});
wrapper.appendChild(baseRect);
let overlayRect = null;
if (useOverlay) {
overlayRect = createRevealRect({
insetX,
insetY,
background: overlayColor,
transformOrigin: enterOrigin,
axis,
zIndex: 3,
dataAttribute: "data-reveal-overlay",
});
wrapper.appendChild(overlayRect);
}
wrappers.push(wrapper);
baseRects.push(baseRect);
overlayRects.push(overlayRect);
});
// Timeline
const timeline = gsap.timeline({ paused: true, delay });
lines.forEach((line, index) => {
const baseRect = baseRects[index];
const overlayRect = overlayRects[index];
const startAt = index * stagger;
if (useOverlay && overlayRect) {
timeline.to(overlayRect, {
[axis]: 1,
duration: overlayEnterDuration,
ease: REVEAL_EASE,
transformOrigin: enterOrigin,
}, startAt + 0.1);
}
timeline
.to(baseRect, {
[axis]: 1,
duration: coverDuration,
ease: REVEAL_EASE,
transformOrigin: enterOrigin,
}, startAt)
.set(line, {
opacity: 1,
}, startAt + coverDuration);
if (useOverlay && overlayRect) {
timeline.to(overlayRect, {
[axis]: 0,
delay: 0.15,
duration: overlayExitDuration,
ease: REVEAL_EASE,
transformOrigin: exitOrigin,
}, startAt + coverDuration + 0.1);
}
timeline.to(baseRect, {
[axis]: 0,
delay: useOverlay ? 0.2 : 0.12,
duration: revealDuration,
ease: REVEAL_EASE,
transformOrigin: exitOrigin,
}, startAt + coverDuration + 0.1);
});
// Scroll trigger
const scrollTrigger = ScrollTrigger.create({
trigger: elementRef.current,
start: triggerStart,
once,
animation: timeline,
toggleActions: once ? "play none none none" : toggleActions,
...(once
? {}
: {
onLeaveBack: () => {
timeline.pause(0);
lines.forEach((line) => {
line.style.opacity = "0";
});
baseRects.forEach((rect) => {
gsap.set(rect, {
[axis]: 0,
transformOrigin: enterOrigin,
});
});
overlayRects.forEach((rect) => {
if (!rect) {
return;
}
gsap.set(rect, {
[axis]: 0,
transformOrigin: enterOrigin,
});
});
},
}),
});
// Cleanup
return () => {
timeline.kill();
scrollTrigger.kill();
split.revert();
wrappers.forEach((wrapper) => {
if (!wrapper.parentNode) {
return;
}
while (wrapper.firstChild) {
wrapper.parentNode.insertBefore(wrapper.firstChild, wrapper);
}
wrapper.remove();
});
};
}, [
baseColor,
coverDuration,
delay,
direction,
insetX,
insetY,
once,
overlayColor,
overlayEnterDuration,
overlayExitDuration,
revealDuration,
stagger,
toggleActions,
triggerStart,
useOverlay,
]);
// `Tag` is a dynamic element type (defaults to "div"), so its prop shape
// can't be resolved generically at the JSX call site - cast to keep this
// a behavior-preserving conversion rather than chasing full coverage here.
const Component = Tag;
return (<Component ref={elementRef} className={className} style={{ opacity: 0 }}>
{children}
</Component>);
}
Example Production Use Case
Use this as text-animation implementation guidance. Verify the shipped component API, splitting strategy, accessible text exposure, duplicated span handling, layout reservation, and reduced-motion behavior before relying on exact props, defaults, imports, or installation steps.
Best Used For
- Graphic brand pages where a framed or block-based reveal matches the design language.
- Campaign lines where the mask is an art-direction choice, not a gimmick.
- Rectangular Text Reveal makes a short phrase arrive with timing while keeping the final text readable and indexable.
Not For
Avoid it on body copy, labels, error and legal text, or any wording that has to be read on sight.
Performance Budget
Move only the words that carry weight, pre-reserve their space to avoid shift, and skip costly filters on large text blocks.
Accessibility and Mobile
Assistive technology should hear the phrase once, not character by character; visually split fragments are hidden from the accessibility tree with aria-hidden.
Common Mistakes
- Using Rectangular Text Reveal on long body copy.
- Removing the readable text from the DOM.
- Letting split text cause layout shift.
Changelog
v1.1.0
Jul 21, 2026v1.0.0
Feb 10, 2026Props
| Prop | Type | Default | Description |
|---|---|---|---|
baseColor | string | #ea580c | Base reveal rectangle color. |
overlayColor | string | #111111 | Overlay reveal rectangle color. |
useOverlay | boolean | true | Enables the overlay pass. |
stagger | number | 0.2 | Delay between line reveals. |
coverDuration | number | 0.34 | Cover animation duration. |
revealDuration | number | 0.42 | Reveal animation duration. |
direction | string | left | Reveal direction. |
Frequently Asked Questions
When should I use Rectangular Text Reveal?
Use it when a heading needs a rectangular mask controlling its entrance.
How do I keep Rectangular Text Reveal accessible?
Keep the resolved phrase in the DOM, exposed once; mark any duplicated animated fragments hidden from assistive technology.
Does Rectangular Text Reveal affect SEO?
Not as long as the resolved text is genuine HTML and never trapped in canvas or client-only animation.
How do I avoid layout shift with Rectangular Text Reveal?
Hold the finished text's footprint so wrapping does not jump between breakpoints.
What should reduced motion do for Rectangular Text Reveal?
Land directly on the finished, readable text: no character scramble, no stagger, no loop.
Request a Custom Text Animation
Need a custom effect? Tell us what to create.

