Reveal Text

Reveals text with a directional slide-and-fade when it enters the viewport.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/reveal-text.json

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

3 stories available:

Code

"use client"; import * as React from "react"; import { cn } from "../../lib/utils"; /** Slide-in origin for the reveal. */ export type RevealDirection = "down" | "left" | "right" | "up"; /** Props for {@link RevealText}. */ export type RevealTextProps = React.ComponentPropsWithoutRef<"div"> & { /** Content slid into view behind a clipping mask. */ children: React.ReactNode; /** Milliseconds before the reveal starts. Defaults to `0`. */ delay?: number; /** Origin the content slides from. Defaults to `"up"`. */ direction?: RevealDirection; }; function usePrefersReducedMotion(): boolean { const [reduced, setReduced] = React.useState(false); React.useEffect(() => { if ( typeof window === "undefined" || typeof window.matchMedia !== "function" ) { return; } const query = window.matchMedia("(prefers-reduced-motion: reduce)"); const onChange = (): void => { setReduced(query.matches); }; onChange(); query.addEventListener("change", onChange); return () => { query.removeEventListener("change", onChange); }; }, []); return reduced; } function useInView(nodeRef: React.RefObject<HTMLDivElement | null>): boolean { const [inView, setInView] = React.useState( () => typeof IntersectionObserver !== "function", ); React.useEffect(() => { const node = nodeRef.current; if (!node || typeof IntersectionObserver !== "function") { return; } const observer = new IntersectionObserver((entries) => { if (entries.some((entry) => entry.isIntersecting)) { setInView(true); observer.disconnect(); } }); observer.observe(node); return () => { observer.disconnect(); }; }, [nodeRef]); return inView; } const hiddenTransforms: Record<RevealDirection, string> = { down: "-translate-y-full", left: "translate-x-full", right: "-translate-x-full", up: "translate-y-full", }; /** * Slides content into view from one edge behind a clipping mask, once. * * Respects `prefers-reduced-motion`: the content shows in place. * * @example * ```tsx * <RevealText direction="up">Headline</RevealText> * ``` */ export const RevealText = ({ children, className, delay = 0, direction = "up", ref, ...props }: RevealTextProps & { ref?: React.Ref<HTMLDivElement> }) => { const reduced = usePrefersReducedMotion(); const nodeRef = React.useRef<HTMLDivElement>(null); const inView = useInView(nodeRef); const visible = reduced || inView; return ( <div className={cn("overflow-hidden", className)} ref={(node) => { nodeRef.current = node; if (typeof ref === "function") { ref(node); } else if (ref) { ref.current = node; } }} {...props} > <div className={cn( "transition-[transform,opacity] duration-700 ease-out motion-reduce:transition-none", visible ? "translate-x-0 translate-y-0 opacity-100" : cn("opacity-0", hiddenTransforms[direction]), )} style={{ transitionDelay: `${delay}ms` }} > {children} </div> </div> ); }; RevealText.displayName = "RevealText";

Dependencies

  • @vllnt/ui@^0.2.1