Text Effects
RansomNote
Every letter is cut from a different page, then slapped down crooked one by one. Hover lifts a letter. The same seed always gives the same note.
Download
- React
- Tailwind CSS v4
- motion
components/ui/ransom-note.tsx
"use client";
import { useRef } from "react";
import { motion, useInView, useReducedMotion } from "motion/react";
type RansomNoteProps = {
text: string;
className?: string;
/** Change it to cut the letters again from other pages. */
seed?: number;
/** Seconds between letters landing. */
stagger?: number;
/** Play only the first time it scrolls into view. */
once?: boolean;
};
const CUTS = [
{ paper: "#fbf3df", ink: "#141414", font: "Georgia, serif", weight: 700 },
{ paper: "#141414", ink: "#ffffff", font: "Impact, 'Arial Narrow', sans-serif", weight: 400 },
{ paper: "#e0392b", ink: "#ffffff", font: "'Arial Black', Arial, sans-serif", weight: 900 },
{ paper: "#f6cf45", ink: "#141414", font: "'Times New Roman', serif", weight: 700, italic: true },
{ paper: "#2558b0", ink: "#ffffff", font: "Verdana, sans-serif", weight: 700 },
{ paper: "#ffffff", ink: "#c2185b", font: "'Courier New', monospace", weight: 700 },
{ paper: "#d8d0bc", ink: "#2b2b2b", font: "Arial, Helvetica, sans-serif", weight: 800, dots: true },
{ paper: "#2f8f5b", ink: "#fbf3df", font: "Georgia, serif", weight: 700, italic: true },
];
// Same seed, same note: 0 to 1 from integers, so server and browser agree.
const roll = (n: number) => (Math.imul(n + 7, 2654435761) >>> 16) / 65536;
function scissorCut(n: number) {
const j = (k: number, most: number) => (roll(n * 8 + k) * most).toFixed(1);
return `polygon(${j(1, 9)}% ${j(2, 8)}%, 100% ${j(3, 11)}%, ${100 - +j(4, 8)}% ${100 - +j(5, 7)}%, ${j(6, 12)}% 100%)`;
}
/* RansomNote: every letter cut from a different page, then slapped down crooked. Same seed gives the same note. */
export function RansomNote({ text, className = "", seed = 1, stagger = 0.06, once = true }: RansomNoteProps) {
const reduce = useReducedMotion();
const box = useRef<HTMLSpanElement>(null);
const inView = useInView(box, { once, amount: 0.2 });
let letter = -1;
let last = -1;
return (
<span ref={box} className={`inline-flex flex-wrap items-center justify-center gap-x-[0.42em] gap-y-[0.2em] leading-none ${className}`}>
<span className="sr-only">{text}</span>
{text.split(" ").map((word, w) => (
<span key={`${seed}-${w}`} aria-hidden className="inline-flex items-center whitespace-nowrap">
{[...word].map((ch, i) => {
letter += 1;
const n = seed * 1000 + letter;
const cut = (last + 1 + Math.floor(roll(n) * (CUTS.length - 1))) % CUTS.length;
last = cut;
const look = CUTS[cut];
const tilt = roll(n + 1) * 14 - 7;
const lift = roll(n + 2) * 0.16 - 0.08;
const size = 0.84 + roll(n + 3) * 0.32;
const glyph = roll(n + 4) > 0.5 ? ch.toUpperCase() : ch.toLowerCase();
const hidden = { opacity: 0, scale: 1.9, rotate: tilt + 24 };
return (
<motion.span
key={i}
className="relative -mx-[0.015em] inline-block [filter:drop-shadow(0_0.04em_0.05em_rgba(0,0,0,0.5))]"
style={{ y: `${lift}em` }}
initial={reduce ? false : hidden}
animate={reduce || inView ? { opacity: 1, scale: 1, rotate: tilt } : hidden}
whileHover={{ scale: 1.18, rotate: 0, zIndex: 2 }}
transition={{ type: "spring", stiffness: 420, damping: 18, delay: inView ? letter * stagger : 0 }}
>
<span
className="block px-[0.2em] py-[0.13em]"
style={{
clipPath: scissorCut(n),
backgroundColor: look.paper,
backgroundImage: look.dots ? "radial-gradient(rgba(0,0,0,0.2) 1px, transparent 1.3px)" : undefined,
backgroundSize: "4px 4px",
color: look.ink,
fontFamily: look.font,
fontWeight: look.weight,
fontStyle: look.italic ? "italic" : "normal",
fontSize: `${size}em`,
}}
>
{glyph}
</span>
{letter % 5 === 2 && <span className="absolute -top-[0.08em] left-1/2 h-[0.2em] w-[0.72em] -translate-x-1/2 -rotate-6 bg-[#f1e6b8]/70" />}
</motion.span>
);
})}
</span>
))}
</span>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/ransom-note.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/ransom-note-demo.tsx
"use client"; import { useState } from "react"; import { RansomNote } from "@/components/ui/ransom-note"; export default function RansomNoteExample() { const [seed, setSeed] = useState(1); return ( <div className="@container grid h-full place-items-center bg-[#b9a98c] bg-[radial-gradient(rgba(70,50,20,0.14)_1px,transparent_1.5px)] bg-[length:5px_5px] p-6"> <div className="max-w-xl text-center"> <RansomNote seed={seed} text="Last chance Friday only" className="text-[min(9.5cqw,60px)]" once={false} /> <button type="button" onClick={() => setSeed((s) => s + 1)} className="mt-8 block w-full text-xs text-[#3a2c14]/70 hover:text-[#3a2c14]"> Cut again </button> </div> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| text | string | — | Text to cut out. |
| seed | number | 1 | Change it to cut the letters again from other pages. |
| stagger | number | 0.06 | Seconds between letters landing. |
| once | boolean | true | Play only the first time it scrolls into view. |
| className | string | "" | Font size. Each cut brings its own font and colours. |