Text Effects
PaintRoller
The words sit pencilled on a wall. A paint roller rolls across, lays a streaky wet coat with the letters left clean, and a few drips run down behind it.
Download
- React
- Tailwind CSS v4
- motion
components/ui/paint-roller.tsx
"use client";
import { useEffect, useRef } from "react";
import { animate, motion, useInView, useMotionTemplate, useMotionValue, useReducedMotion, useTransform } from "motion/react";
type PaintRollerProps = {
text: string;
className?: string;
/** Paint colour. */
paint?: string;
/** Colour of the letters once painted. */
ink?: string;
/** Seconds for one pass. */
duration?: number;
/** Play only the first time it scrolls into view. */
once?: boolean;
};
const PAD = "px-[0.45em] py-[0.18em]";
const DRIPS = [{ at: 16, size: 0.42 }, { at: 47, size: 0.26 }, { at: 78, size: 0.5 }];
// The roller leaves a dry, uneven edge along the top and bottom of the coat.
const dryEdge = (() => {
const top: string[] = [];
const bottom: string[] = [];
for (let i = 0; i <= 40; i++) {
const x = i * 2.5;
top.push(`${x}% ${(i * 37) % 7}%`);
bottom.push(`${100 - x}% ${100 - (i * 53) % 8}%`);
}
return `polygon(${[...top, ...bottom].join(",")})`;
})();
/* PaintRoller: letters pencilled on a wall until a real roller passes, laying a streaky wet coat and leaving drips. */
export function PaintRoller({
text,
className = "",
paint = "#0f8b8d",
ink = "#fff8ec",
duration = 1.5,
once = true,
}: PaintRollerProps) {
const reduce = useReducedMotion();
const box = useRef<HTMLSpanElement>(null);
const inView = useInView(box, { once, amount: 0.6 });
const done = useMotionValue(reduce ? 100 : 0);
const left = useTransform(done, (d) => 100 - d);
const coat = useMotionTemplate`inset(-10% ${left}% -10% 0)`;
const rollerLeft = useMotionTemplate`${done}%`;
const turn = useTransform(done, (d) => d * 3);
const nap = useMotionTemplate`${turn}px 0, 0 0`;
const drip0 = useTransform(done, [DRIPS[0].at + 8, DRIPS[0].at + 40], [0, 1]);
const drip1 = useTransform(done, [DRIPS[1].at + 8, DRIPS[1].at + 40], [0, 1]);
const drip2 = useTransform(done, [DRIPS[2].at + 8, 100], [0, 1]);
const drips = [drip0, drip1, drip2];
useEffect(() => {
if (reduce) {
done.set(100);
return;
}
const pass = animate(done, inView ? 100 : 0, inView ? { duration, ease: "easeInOut" } : { duration: 0 });
return () => pass.stop();
}, [inView, reduce, duration, done]);
return (
<span ref={box} className={`relative inline-block ${className}`}>
<span className="sr-only">{text}</span>
<span aria-hidden className={`block ${PAD} opacity-40`} style={{ color: "transparent", WebkitTextStroke: `1px ${paint}` }}>
{text}
</span>
{DRIPS.map((drip, i) => (
<motion.span
key={drip.at}
aria-hidden
className="absolute top-[97%] w-[0.07em] origin-top rounded-b-full"
style={{ left: `${drip.at}%`, height: `${drip.size}em`, background: paint, scaleY: drips[i] }}
/>
))}
<motion.span aria-hidden className="absolute inset-0" style={{ clipPath: coat }}>
<span
className="absolute inset-0"
style={{
clipPath: dryEdge,
backgroundColor: paint,
backgroundImage:
"repeating-linear-gradient(0deg, rgba(255,255,255,0.09) 0 1px, transparent 1px 4px, rgba(0,0,0,0.08) 4px 5px, transparent 5px 9px)",
}}
/>
<span className={`relative block ${PAD}`} style={{ color: ink }}>
{text}
</span>
</motion.span>
<motion.span aria-hidden className="absolute -top-[0.14em] -bottom-[0.14em] w-[0.5em] -translate-x-1/2" style={{ left: rollerLeft }}>
<motion.span
className="absolute inset-0 rounded-[0.1em] shadow-[0_0.05em_0.15em_rgba(0,0,0,0.35)]"
style={{
backgroundColor: paint,
backgroundImage:
"repeating-linear-gradient(90deg, rgba(0,0,0,0.16) 0 2px, transparent 2px 6px), linear-gradient(90deg, rgba(0,0,0,0.4), rgba(255,255,255,0.3) 45%, rgba(0,0,0,0.45))",
backgroundPosition: nap,
}}
/>
<svg viewBox="0 0 44 58" className="absolute top-full left-1/2 h-[1.2em] w-[0.92em] -translate-x-[0.04em]" fill="none">
<path d="M3 0V9H30V24" stroke="#c6cbd3" strokeWidth="3" />
<rect x="23" y="23" width="14" height="33" rx="6" fill="#2b2b33" />
</svg>
</motion.span>
</span>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/paint-roller.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/paint-roller-demo.tsx
"use client"; import { useState } from "react"; import { PaintRoller } from "@/components/ui/paint-roller"; export default function PaintRollerExample() { const [run, setRun] = useState(0); return ( <div className="@container grid h-full place-items-center bg-[#e9e3d6] p-6"> <div className="pb-10 text-center"> <PaintRoller key={run} text="Fresh coat" className="text-[min(13cqw,72px)] font-black tracking-tight" once={false} /> <button type="button" onClick={() => setRun((r) => r + 1)} className="mt-16 block w-full text-xs text-[#3a342a]/60 hover:text-[#3a342a]"> Replay </button> </div> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| text | string | — | Text on the wall. |
| paint | string | "#0f8b8d" | Paint colour, also the roller. |
| ink | string | "#fff8ec" | Letter colour on the coat. |
| duration | number | 1.5 | Seconds for one pass. |
| once | boolean | true | Play only the first time it scrolls into view. |
| className | string | "" | Type styles for the text. Leave room below: the roller handle hangs 1.2em under it. |