Signature
An animated SVG signature effect that draws out text as if hand-written.
Live preview recorded from Mellow UI; component and demo published by Mellow UI. Preview: platform recorded · rights authorized.
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 UIWhy 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.
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
An animated SVG signature effect that draws out text as if hand-written.
More from Mellow UI