Skip to main content
Back to discovery

Live preview recorded from Mellow UI; component and demo published by Mellow UI. Preview: platform recorded · rights authorized.

Word Reveal

A paragraph that reveals word-by-word as it scrolls through the viewport — ink rising to full strength, scrubbed directly to scroll position.

MSource LandMellow UI

Why it stands out

A paragraph that reveals word-by-word as it scrolls through the viewport — ink rising to full strength, scrubbed directly to scroll position.

Prompt

You are given a task to integrate a React component into your codebase.
Please verify your project has the following setup:
- shadcn/ui project structure
- Tailwind CSS v4.0
- TypeScript
- motion/react v11 (install: `pnpm add motion`)

If any of these are missing, provide instructions on how to setup project via shadcn CLI, install Tailwind or TypeScript.

Determine the default path for components and styles. Mellow components live in `components/mellow/`. If that folder does not exist yet, create it.

Copy-paste these files:

File location: components/word-reveal-demo.tsx

File content: "use client";

import React from "react";
import { WordReveal } from "../mellow/word-reveal";

export default function WordRevealDemo() {
  return (
    <div className="flex h-full w-full items-center justify-center px-6 py-10">
      <WordReveal
        text="Most interfaces shout. Mellow moves *quietly* — texture, timing, and the small *theatre* of a cursor crossing a surface. Read it. Patch it. Make it *yours.*"
        className="max-w-[28ch] text-[clamp(1.375rem,3.2vw,2.25rem)] leading-[1.3] font-medium tracking-[-0.02em] text-(--ink)"
      />
    </div>
  );
}



File location: components/mellow/word-reveal.tsx

File content: "use client";

import React, { useMemo, useRef } from "react";
import {
  motion,
  useScroll,
  useTransform,
  useReducedMotion,
  type MotionValue,
} from "motion/react";

export interface WordRevealProps {
  /** The paragraph to reveal. Wrap a word in asterisks (`*quietly*`) to give it the accent style. */
  text: string;
  as?: "p" | "h1" | "h2" | "h3" | "h4";
  /** Opacity of words before they're revealed. */
  fromOpacity?: number;
  /** Viewport offset where the reveal starts/ends — passed to useScroll. */
  offset?: [string, string];
  /** How many words overlap in the reveal window (higher = softer wave). */
  overlap?: number;
  /** Scrollable ancestor to scrub against. Defaults to the window viewport. */
  containerRef?: React.RefObject<HTMLElement | null>;
  className?: string;
  /** Class applied to `*accented*` words. Defaults to serif italic. */
  accentClassName?: string;
}

function Word({
  children,
  progress,
  range,
  fromOpacity,
  className,
}: {
  children: string;
  progress: MotionValue<number>;
  range: [number, number];
  fromOpacity: number;
  className?: string;
}) {
  const opacity = useTransform(progress, range, [fromOpacity, 1]);
  return (
    <motion.span style={{ opacity }} className={className}>
      {children}{" "}
    </motion.span>
  );
}

/**
 * A paragraph that reveals word-by-word as it scrolls through the viewport —
 * ink rising to full strength, scrubbed directly to scroll position.
 */
export function WordReveal({ as: Tag = "p", ...rest }: WordRevealProps) {
  // Keyed on Tag so switching the wrapper element (e.g. h2 -> h4) fully
  // remounts this subtree instead of swapping the host element type under a
  // persistent ref. Without this, useScroll's scroll-position tracking stays
  // bound to the old (now-detached) node and the reveal stops updating.
  return <WordRevealBody key={Tag} as={Tag} {...rest} />;
}

function WordRevealBody({
  text,
  as: Tag = "p",
  fromOpacity = 0.13,
  offset = ["start 0.8", "start 0.3"],
  overlap = 3,
  containerRef,
  className,
  accentClassName = "[font-family:var(--font-serif)] font-normal tracking-[-0.01em] italic",
}: WordRevealProps) {
  const ref = useRef<HTMLElement>(null);
  const reduced = useReducedMotion();

  const { scrollYProgress } = useScroll({
    target: ref,
    container: containerRef ?? undefined,
    // motion's offset type is stricter than its docs; the string pairs are valid
    offset: offset as never,
  });

  const words = useMemo(
    () =>
      text
        .split(/\s+/)
        .filter(Boolean)
        .map((raw) => {
          const accent = /^\*.+\*[.,;:!?—]*$/.test(raw);
          return { word: raw.replace(/\*/g, ""), accent };
        }),
    [text]
  );

  if (reduced) {
    return (
      <Tag ref={ref as never} className={className}>
        {words.map((w, i) => (
          <span key={i} className={w.accent ? accentClassName : undefined}>
            {w.word}{" "}
          </span>
        ))}
      </Tag>
    );
  }

  const n = words.length;
  return (
    <Tag ref={ref as never} className={className}>
      {words.map((w, i) => {
        const start = i / (n + overlap);
        const end = Math.min((i + overlap) / (n + overlap), 1);
        return (
          <Word
            key={i}
            progress={scrollYProgress}
            range={[start, end]}
            fromOpacity={fromOpacity}
            className={w.accent ? accentClassName : undefined}
          >
            {w.word}
          </Word>
        );
      })}
    </Tag>
  );
}

export default WordReveal;



Usage notes:
Add a WordReveal component from the mellow library. It splits the text prop into words and scrubs each word's opacity from fromOpacity to 1 as the element scrolls through the viewport, using motion/react useScroll. Wrap a word in asterisks (*quietly*) to style it with accentClassName (defaults to serif italic). Props: text, as ('p'|'h1'|'h2'|'h3'|'h4'), fromOpacity (default 0.13), offset (useScroll offset pair, default ['start 0.8','start 0.3']), overlap (words revealed together, default 3), accentClassName, className. Respects prefers-reduced-motion by rendering at full opacity.

Discovery vocabulary

Related by governed terms

Continue comparing

More from Mellow UI