Ransom note
import { useEffect, useRef, useState, type CSSProperties } from 'react';
const NOTES = [
['JOIN', 'NOW'],
['WELCOME', 'ABOARD'],
];
// Every scrap is cut differently, none of them square.
const CUTS = [
'polygon(3% 6%, 42% 0, 96% 5%, 100% 48%, 94% 97%, 52% 92%, 5% 100%, 0 44%)',
'polygon(0 3%, 97% 0, 92% 40%, 100% 96%, 44% 100%, 4% 94%, 8% 52%)',
'polygon(6% 0, 100% 8%, 96% 92%, 58% 100%, 0 95%, 3% 38%)',
'polygon(0 10%, 30% 2%, 100% 0, 94% 56%, 98% 100%, 12% 96%, 4% 62%)',
'polygon(4% 4%, 92% 0, 100% 34%, 95% 100%, 36% 94%, 0 100%)',
];
const VARIANTS = [0, 1, 3, 2, 4, 5, 1, 0, 2, 5, 3, 4, 0];
const TILT = [-5, 4, -2, 6, -7, 3, 5, -4, 2, -6, 7, -3, 4];
const LIFT = [2, -3, 4, -1, 3, -4, 1, 3, -2, 4, -3, 1, -2];
const css = `
.btn-ransom { flex: none; --paper: light-dark(#fbfcfd, #f1f2f3); --ink: light-dark(#373a40, #121417); --dark: light-dark(#42454c, #232529); --mid: light-dark(#5f626a, #393c41); display: inline-flex; flex-direction: column; align-items: center; gap: 5px; padding: 6px; border: 0; border-radius: 10px; background: none; cursor: pointer; transition: transform 0.12s; }
.btn-ransom:active { transform: scale(0.97); }
.btn-ransom:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
.btn-ransom__row { display: flex; align-items: center; justify-content: center; gap: 2px; font-size: 46px; line-height: 1; }
.btn-ransom__row--long { font-size: 30px; }
.btn-ransom__scrap { display: block; filter: drop-shadow(1px 2px 1px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.4))); transform: rotate(var(--r)) translateY(var(--y)); }
.btn-ransom__paper { display: block; min-width: 0.72em; padding: 0.1em 0.14em 0.08em; clip-path: var(--cut); text-align: center; }
.btn-ransom__scrap--0 .btn-ransom__paper { background: var(--paper); color: var(--ink); font-family: Georgia, 'Times New Roman', serif; font-weight: 700; }
.btn-ransom__scrap--1 .btn-ransom__paper { background: var(--dark); color: var(--paper); font-family: 'Arial Black', Impact, sans-serif; font-weight: 900; font-size: 0.86em; }
.btn-ransom__scrap--2 .btn-ransom__paper { background: radial-gradient(color-mix(in oklab, var(--ink) 34%, transparent) 1px, transparent 1.2px) 0 0 / 4px 4px, var(--paper); color: var(--ink); font-family: 'Times New Roman', Times, serif; font-style: italic; text-transform: lowercase; }
.btn-ransom__scrap--3 .btn-ransom__paper { background: var(--mid); color: var(--paper); font-family: Impact, 'Arial Narrow', sans-serif; font-weight: 400; }
.btn-ransom__scrap--4 .btn-ransom__paper { background: repeating-linear-gradient(to bottom, var(--paper) 0 7px, color-mix(in oklab, var(--ink) 22%, var(--paper)) 7px 8px); color: var(--ink); font-family: 'Courier New', Courier, monospace; font-weight: 700; text-transform: lowercase; }
.btn-ransom__scrap--5 .btn-ransom__paper { background: var(--paper); color: var(--ink); font-family: Didot, 'Bodoni 72', 'Times New Roman', serif; font-weight: 400; box-shadow: inset 0 0 0 2px var(--ink); }
/* Stop motion: the scraps snap between poses a few times a second. */
.btn-ransom:hover .btn-ransom__scrap, .btn-ransom:focus-visible .btn-ransom__scrap { animation: btn-ransom-boil 0.42s steps(1) calc(var(--i) * -70ms) infinite; }
@keyframes btn-ransom-boil {
0% { transform: rotate(var(--r)) translateY(var(--y)); }
33% { transform: rotate(calc(var(--r) + 3deg)) translate(1px, calc(var(--y) - 1.5px)); }
66% { transform: rotate(calc(var(--r) - 2deg)) translate(-1px, calc(var(--y) + 1px)); }
}
.btn-ransom[data-phase='out'] .btn-ransom__scrap { animation: btn-ransom-out 0.5s steps(6) calc(var(--i) * 30ms) forwards; }
@keyframes btn-ransom-out {
to { transform: translateY(150px) rotate(calc(var(--r) * 9)); opacity: 0; }
}
.btn-ransom[data-phase='in'] .btn-ransom__scrap { animation: btn-ransom-in 0.3s steps(4) calc(var(--i) * 45ms) backwards; }
@keyframes btn-ransom-in {
from { transform: rotate(calc(var(--r) * -3)) scale(2.1); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.btn-ransom .btn-ransom__scrap, .btn-ransom:hover .btn-ransom__scrap { animation: none !important; }
}
`;
export function RansomButton() {
const [note, setNote] = useState(0);
const [phase, setPhase] = useState<'rest' | 'out' | 'in'>('rest');
const timer = useRef(0);
useEffect(() => () => window.clearTimeout(timer.current), []);
const press = () => {
if (phase !== 'rest') return;
const next = (note + 1) % NOTES.length;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
setNote(next);
return;
}
const going = NOTES[note].join('').length;
const coming = NOTES[next].join('').length;
setPhase('out');
timer.current = window.setTimeout(() => {
setNote(next);
setPhase('in');
timer.current = window.setTimeout(() => setPhase('rest'), coming * 45 + 320);
}, going * 30 + 500);
};
let n = 0;
return (
<>
<style>
{css}
</style>
<button type="button" className="btn-ransom" aria-label={NOTES[note].join(' ')} data-phase={phase} onClick={press}>
{NOTES[note].map((word) => (
<span key={`${note}${word}`} className={`btn-ransom__row${word.length > 4 ? ' btn-ransom__row--long' : ''}`} aria-hidden="true">
{[...word].map((ch, i) => {
const k = n++;
return (
<span
key={i}
className={`btn-ransom__scrap btn-ransom__scrap--${VARIANTS[k % VARIANTS.length]}`}
style={{ '--i': k, '--r': `${TILT[k % TILT.length]}deg`, '--y': `${LIFT[k % LIFT.length]}px`, '--cut': CUTS[k % CUTS.length] } as CSSProperties}
>
<span className="btn-ransom__paper">{ch}</span>
</span>
);
})}
</span>
))}
</button>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initBtnRansom(el) on the mounted element instead.
<button type="button" class="btn-ransom" aria-label="JOIN NOW" data-phase="rest">
<span class="btn-ransom__row" aria-hidden="true">
<span class="btn-ransom__scrap btn-ransom__scrap--0" style="--i:0;--r:-5deg;--y:2px;--cut:polygon(3% 6%, 42% 0, 96% 5%, 100% 48%, 94% 97%, 52% 92%, 5% 100%, 0 44%)">
<span class="btn-ransom__paper">J</span>
</span>
<span class="btn-ransom__scrap btn-ransom__scrap--1" style="--i:1;--r:4deg;--y:-3px;--cut:polygon(0 3%, 97% 0, 92% 40%, 100% 96%, 44% 100%, 4% 94%, 8% 52%)">
<span class="btn-ransom__paper">O</span>
</span>
<span class="btn-ransom__scrap btn-ransom__scrap--3" style="--i:2;--r:-2deg;--y:4px;--cut:polygon(6% 0, 100% 8%, 96% 92%, 58% 100%, 0 95%, 3% 38%)">
<span class="btn-ransom__paper">I</span>
</span>
<span class="btn-ransom__scrap btn-ransom__scrap--2" style="--i:3;--r:6deg;--y:-1px;--cut:polygon(0 10%, 30% 2%, 100% 0, 94% 56%, 98% 100%, 12% 96%, 4% 62%)">
<span class="btn-ransom__paper">N</span>
</span>
</span>
<span class="btn-ransom__row" aria-hidden="true">
<span class="btn-ransom__scrap btn-ransom__scrap--4" style="--i:4;--r:-7deg;--y:3px;--cut:polygon(4% 4%, 92% 0, 100% 34%, 95% 100%, 36% 94%, 0 100%)">
<span class="btn-ransom__paper">N</span>
</span>
<span class="btn-ransom__scrap btn-ransom__scrap--5" style="--i:5;--r:3deg;--y:-4px;--cut:polygon(3% 6%, 42% 0, 96% 5%, 100% 48%, 94% 97%, 52% 92%, 5% 100%, 0 44%)">
<span class="btn-ransom__paper">O</span>
</span>
<span class="btn-ransom__scrap btn-ransom__scrap--1" style="--i:6;--r:5deg;--y:1px;--cut:polygon(0 3%, 97% 0, 92% 40%, 100% 96%, 44% 100%, 4% 94%, 8% 52%)">
<span class="btn-ransom__paper">W</span>
</span>
</span>
</button>.btn-ransom { flex: none; --paper: light-dark(#fbfcfd, #f1f2f3); --ink: light-dark(#373a40, #121417); --dark: light-dark(#42454c, #232529); --mid: light-dark(#5f626a, #393c41); display: inline-flex; flex-direction: column; align-items: center; gap: 5px; padding: 6px; border: 0; border-radius: 10px; background: none; cursor: pointer; transition: transform 0.12s; }
.btn-ransom:active { transform: scale(0.97); }
.btn-ransom:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
.btn-ransom__row { display: flex; align-items: center; justify-content: center; gap: 2px; font-size: 46px; line-height: 1; }
.btn-ransom__row--long { font-size: 30px; }
.btn-ransom__scrap { display: block; filter: drop-shadow(1px 2px 1px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.4))); transform: rotate(var(--r)) translateY(var(--y)); }
.btn-ransom__paper { display: block; min-width: 0.72em; padding: 0.1em 0.14em 0.08em; clip-path: var(--cut); text-align: center; }
.btn-ransom__scrap--0 .btn-ransom__paper { background: var(--paper); color: var(--ink); font-family: Georgia, 'Times New Roman', serif; font-weight: 700; }
.btn-ransom__scrap--1 .btn-ransom__paper { background: var(--dark); color: var(--paper); font-family: 'Arial Black', Impact, sans-serif; font-weight: 900; font-size: 0.86em; }
.btn-ransom__scrap--2 .btn-ransom__paper { background: radial-gradient(color-mix(in oklab, var(--ink) 34%, transparent) 1px, transparent 1.2px) 0 0 / 4px 4px, var(--paper); color: var(--ink); font-family: 'Times New Roman', Times, serif; font-style: italic; text-transform: lowercase; }
.btn-ransom__scrap--3 .btn-ransom__paper { background: var(--mid); color: var(--paper); font-family: Impact, 'Arial Narrow', sans-serif; font-weight: 400; }
.btn-ransom__scrap--4 .btn-ransom__paper { background: repeating-linear-gradient(to bottom, var(--paper) 0 7px, color-mix(in oklab, var(--ink) 22%, var(--paper)) 7px 8px); color: var(--ink); font-family: 'Courier New', Courier, monospace; font-weight: 700; text-transform: lowercase; }
.btn-ransom__scrap--5 .btn-ransom__paper { background: var(--paper); color: var(--ink); font-family: Didot, 'Bodoni 72', 'Times New Roman', serif; font-weight: 400; box-shadow: inset 0 0 0 2px var(--ink); }
/* Stop motion: the scraps snap between poses a few times a second. */
.btn-ransom:hover .btn-ransom__scrap, .btn-ransom:focus-visible .btn-ransom__scrap { animation: btn-ransom-boil 0.42s steps(1) calc(var(--i) * -70ms) infinite; }
@keyframes btn-ransom-boil {
0% { transform: rotate(var(--r)) translateY(var(--y)); }
33% { transform: rotate(calc(var(--r) + 3deg)) translate(1px, calc(var(--y) - 1.5px)); }
66% { transform: rotate(calc(var(--r) - 2deg)) translate(-1px, calc(var(--y) + 1px)); }
}
.btn-ransom[data-phase='out'] .btn-ransom__scrap { animation: btn-ransom-out 0.5s steps(6) calc(var(--i) * 30ms) forwards; }
@keyframes btn-ransom-out {
to { transform: translateY(150px) rotate(calc(var(--r) * 9)); opacity: 0; }
}
.btn-ransom[data-phase='in'] .btn-ransom__scrap { animation: btn-ransom-in 0.3s steps(4) calc(var(--i) * 45ms) backwards; }
@keyframes btn-ransom-in {
from { transform: rotate(calc(var(--r) * -3)) scale(2.1); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.btn-ransom .btn-ransom__scrap, .btn-ransom:hover .btn-ransom__scrap { animation: none !important; }
}function initBtnRansom(root) {
var NOTES = [['JOIN', 'NOW'], ['WELCOME', 'ABOARD']];
// Every scrap is cut differently, none of them square.
var CUTS = [
'polygon(3% 6%, 42% 0, 96% 5%, 100% 48%, 94% 97%, 52% 92%, 5% 100%, 0 44%)',
'polygon(0 3%, 97% 0, 92% 40%, 100% 96%, 44% 100%, 4% 94%, 8% 52%)',
'polygon(6% 0, 100% 8%, 96% 92%, 58% 100%, 0 95%, 3% 38%)',
'polygon(0 10%, 30% 2%, 100% 0, 94% 56%, 98% 100%, 12% 96%, 4% 62%)',
'polygon(4% 4%, 92% 0, 100% 34%, 95% 100%, 36% 94%, 0 100%)',
];
var VARIANTS = [0, 1, 3, 2, 4, 5, 1, 0, 2, 5, 3, 4, 0];
var TILT = [-5, 4, -2, 6, -7, 3, 5, -4, 2, -6, 7, -3, 4];
var LIFT = [2, -3, 4, -1, 3, -4, 1, 3, -2, 4, -3, 1, -2];
var note = 0;
var phase = 'rest';
var timer = 0;
function build(index) {
var n = 0;
root.textContent = '';
NOTES[index].forEach(function (word) {
var row = document.createElement('span');
row.className = 'btn-ransom__row' + (word.length > 4 ? ' btn-ransom__row--long' : '');
row.setAttribute('aria-hidden', 'true');
word.split('').forEach(function (ch) {
var k = n++;
var scrap = document.createElement('span');
var paper = document.createElement('span');
scrap.className = 'btn-ransom__scrap btn-ransom__scrap--' + VARIANTS[k % VARIANTS.length];
scrap.style.setProperty('--i', k);
scrap.style.setProperty('--r', TILT[k % TILT.length] + 'deg');
scrap.style.setProperty('--y', LIFT[k % LIFT.length] + 'px');
scrap.style.setProperty('--cut', CUTS[k % CUTS.length]);
paper.className = 'btn-ransom__paper';
paper.textContent = ch;
scrap.appendChild(paper);
row.appendChild(scrap);
});
root.appendChild(row);
});
root.setAttribute('aria-label', NOTES[index].join(' '));
}
function set(next) {
phase = next;
root.setAttribute('data-phase', next);
}
// The old scraps fall off first, then the new ones slap on one by one.
function onClick() {
if (phase !== 'rest') return;
var next = (note + 1) % NOTES.length;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
note = next;
build(note);
return;
}
set('out');
timer = setTimeout(function () {
var going = NOTES[next].join('').length;
note = next;
build(note);
set('in');
timer = setTimeout(function () {
set('rest');
}, going * 45 + 320);
}, NOTES[note].join('').length * 30 + 500);
}
root.addEventListener('click', onClick);
return function () {
clearTimeout(timer);
root.removeEventListener('click', onClick);
};
}
document.querySelectorAll('.btn-ransom').forEach(initBtnRansom);Goal: Create a "Ransom note" button.
Visual behavior: Letters cut out of different newspapers, every scrap a different typeface and torn shape. They jitter in stop motion on hover; press and they fall off and slap back on as a new message.
Technique:
- Each letter is a scrap with its own font, paper and rotation, cut by a clip-path polygon. The drop shadow sits on a parent, since a clip-path would cut off a shadow on the scrap itself.
- All the motion uses steps() timing, so it snaps between poses like stop motion: a looping boil on hover, then a stagger for falling off and another for slapping back on once the word has changed.
Accessibility: A real button whose aria-label is the current message; the scraps are aria-hidden. Under prefers-reduced-motion the message changes at once and the scraps hold still.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Ransom note" button.
Visual behavior: Letters cut out of different newspapers, every scrap a different typeface and torn shape. They jitter in stop motion on hover; press and they fall off and slap back on as a new message.
Technique:
- Each letter is a scrap with its own font, paper and rotation, cut by a clip-path polygon. The drop shadow sits on a parent, since a clip-path would cut off a shadow on the scrap itself.
- All the motion uses steps() timing, so it snaps between poses like stop motion: a looping boil on hover, then a stagger for falling off and another for slapping back on once the word has changed.
Accessibility: A real button whose aria-label is the current message; the scraps are aria-hidden. Under prefers-reduced-motion the message changes at once and the scraps hold still.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
Letters cut out of different newspapers, every scrap a different typeface and torn shape. They jitter in stop motion on hover; press and they fall off and slap back on as a new message.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, hover, state change, delay stagger
- Techniques
- keyframes, clip path, css variables, state machine, text animation
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A real button whose aria-label is the current message; the scraps are aria-hidden. Under prefers-reduced-motion the message changes at once and the scraps hold still.