Skip to content
Skip to Content
DocsRectangular Text Reveal
Rectangular Text Reveal

Rectangular Text Reveal

Colored rectangles sweep across each line before revealing the text underneath.

Use Reload component to replay the reveal.

Component preview
Rectangular Text Reveal

Install using CLI

npx shadcn@latest add "https://www.obsidianui.dev/r/rectangular-text-reveal.json"

Usage

Code
"use client"; import { RectangularTextReveal } from "@/components/block/rectangular-text-reveal"; export default function Demo() { return ( <RectangularTextReveal className="text-3xl leading-tight text-foreground" overlayColor="var(--background)"> Design less.<br />Ship better. </RectangularTextReveal> ); }

Install Manually

1

Install dependencies

npm install clsx gsap tailwind-merge
2

Copy the source code

Copy into components/block/rectangular-text-reveal.jsx

"use client"; import { useEffect, useRef } from "react"; import gsap from "gsap"; import { CustomEase } from "gsap/CustomEase"; import { ScrollTrigger } from "gsap/ScrollTrigger"; import { SplitText } from "gsap/SplitText"; import { cn } from "@/lib/utils"; if (typeof window !== "undefined") { gsap.registerPlugin(SplitText, CustomEase, ScrollTrigger); } const DEFAULT_TAG = "div"; const DEFAULT_BASE_COLOR = "#ea580c"; const DEFAULT_OVERLAY_COLOR = "var(--background)"; 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 85%"; const DEFAULT_DIRECTION = "left"; const DEFAULT_DELAY = 0; const REVEAL_EASE = "obsidianRectangularReveal"; 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; } /** * @param {import('react').HTMLAttributes<HTMLElement> & { * as?: import('react').ElementType, baseColor?: string, overlayColor?: string, * useOverlay?: boolean, stagger?: number, coverDuration?: number, revealDuration?: number, * overlayEnterDuration?: number, overlayExitDuration?: number, * insetX?: string, insetY?: string, triggerStart?: string, once?: boolean, * direction?: 'left' | 'right' | 'top' | 'bottom', delay?: number, * scroller?: HTMLElement | Window | import('react').RefObject<HTMLElement | null>, * trigger?: HTMLElement | import('react').RefObject<HTMLElement | null> * }} props */ export function RectangularTextReveal({ children, 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 = true, direction = DEFAULT_DIRECTION, delay = DEFAULT_DELAY, scroller, trigger, style, ...props }) { const elementRef = useRef(null); useEffect(() => { if (!elementRef.current) { return; } const media = gsap.matchMedia(); media.add("(prefers-reduced-motion: no-preference)", () => { 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 wrappers = []; const baseRects = []; const overlayRects = []; const lines = split.lines; // Line setup gsap.set(elementRef.current, { opacity: 1 }); 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 }); 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 let delayedPlay; const scrollTrigger = ScrollTrigger.create({ trigger: trigger?.current !== undefined ? trigger.current : trigger ?? elementRef.current, scroller: scroller?.current !== undefined ? scroller.current : scroller, start: triggerStart, once, onEnter: () => { delayedPlay?.kill(); delayedPlay = gsap.delayedCall(delay, () => timeline.play()); }, ...(once ? {} : { onLeaveBack: () => { delayedPlay?.kill(); 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 () => { delayedPlay?.kill(); timeline.kill(); scrollTrigger.kill(); split.revert(); wrappers.forEach((wrapper) => { if (!wrapper.parentNode) { return; } while (wrapper.firstChild) { wrapper.parentNode.insertBefore(wrapper.firstChild, wrapper); } wrapper.remove(); }); }; }, elementRef); return () => media.revert(); }, [ children, baseColor, coverDuration, delay, direction, insetX, insetY, once, overlayColor, overlayEnterDuration, overlayExitDuration, revealDuration, stagger, triggerStart, useOverlay, scroller, trigger, ]); return ( <Tag {...props} ref={elementRef} className={cn("font-heading text-foreground", className)} style={style}> {children} </Tag> ); }
3

Add the supporting file

Copy into lib/utils.ts

import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) }

Preview behavior

Use Reload component to replay the reveal. The effect stays inside its container and respects reduced motion preferences.

Last updated on