Dissolve
import { useEffect, useRef, useState } from 'react';
const LABEL = 'Hold to delete';
const HOLD = 1300;
// Fixed scatter per letter: how far it drifts and how much it turns, so nothing is random.
const DRIFT = [...LABEL].map((_, i) => ({ x: ((i * 37) % 21) - 6, y: 18 + ((i * 53) % 17), r: ((i * 29) % 70) - 35 }));
const css = `
.btn-dissolve {
position: relative;
display: inline-grid;
place-items: center;
min-width: 190px;
height: 48px;
padding: 0 22px;
border: none;
border-radius: 12px;
background: light-dark(#ebedf0, #232529);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035), 0 8px 20px -12px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.6));
color: light-dark(#373a40, #f1f2f3);
font: inherit;
font-size: 14px;
font-weight: 500;
cursor: pointer;
user-select: none;
touch-action: none;
}
.btn-dissolve:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.btn-dissolve__fill {
position: absolute;
inset: 0;
border-radius: inherit;
background: color-mix(in oklab, light-dark(#373a40, #f1f2f3) 10%, transparent);
transform: scaleX(0);
transform-origin: 100% 50%;
transition: transform 0.3s ease-out;
}
.btn-dissolve[data-holding] .btn-dissolve__fill { transform: scaleX(1); transition: transform ${HOLD}ms linear; }
.btn-dissolve__label, .btn-dissolve__done { grid-area: 1 / 1; position: relative; white-space: pre; }
.btn-dissolve__ch {
display: inline-block;
transition: transform 0.35s cubic-bezier(0.3, 1.3, 0.5, 1), opacity 0.3s, filter 0.3s;
}
/* Letters crumble from the right end first; on release they all come back together. */
.btn-dissolve[data-holding] .btn-dissolve__ch,
.btn-dissolve[data-done] .btn-dissolve__ch {
transform: translate(var(--x), var(--y)) rotate(var(--r)) scale(0.4);
opacity: 0;
filter: blur(2px);
transition: transform 0.6s cubic-bezier(0.5, 0, 0.9, 0.6) var(--d), opacity 0.6s ease-in var(--d), filter 0.6s var(--d);
}
.btn-dissolve__done { color: light-dark(#50535b, #c6c8cd); opacity: 0; transform: translateY(6px); transition: opacity 0.3s, transform 0.3s; }
.btn-dissolve[data-done] .btn-dissolve__done { opacity: 1; transform: none; transition-delay: 0.15s; }
.btn-dissolve__done b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; text-decoration: underline; text-underline-offset: 3px; }
@media (prefers-reduced-motion: reduce) {
.btn-dissolve__ch { transition: opacity 0.2s !important; transform: none !important; filter: none !important; }
}
`;
export function DissolveButton() {
const [holding, setHolding] = useState(false);
const [done, setDone] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => () => clearTimeout(timer.current), []);
const start = () => {
if (done) return;
setHolding(true);
timer.current = setTimeout(() => {
setHolding(false);
setDone(true);
}, HOLD);
};
const stop = () => {
clearTimeout(timer.current);
setHolding(false);
};
const n = LABEL.length;
return (
<>
<style>
{css}
</style>
<button
type="button"
className="btn-dissolve"
data-holding={holding || undefined}
data-done={done || undefined}
aria-label={done ? 'Deleted. Press to undo' : 'Hold to delete'}
onPointerDown={start}
onPointerUp={stop}
onPointerLeave={stop}
onKeyDown={(e) => {
if ((e.key === ' ' || e.key === 'Enter') && !e.repeat) {
e.preventDefault();
start();
}
}}
onKeyUp={(e) => (e.key === ' ' || e.key === 'Enter') && stop()}
onClick={() => done && setDone(false)}
>
<span className="btn-dissolve__fill" aria-hidden="true" />
<span className="btn-dissolve__label" aria-hidden="true">
{[...LABEL].map((c, i) => (
<span
key={i}
className="btn-dissolve__ch"
style={{ '--x': `${DRIFT[i]!.x}px`, '--y': `${DRIFT[i]!.y}px`, '--r': `${DRIFT[i]!.r}deg`, '--d': `${((n - 1 - i) / n) * (HOLD - 500)}ms` } as React.CSSProperties}
>
{c}
</span>
))}
</span>
<span className="btn-dissolve__done" aria-hidden="true">
Deleted · <b>Undo</b>
</span>
</button>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initBtnDissolve(el) on the mounted element instead.
<button type="button" class="btn-dissolve" aria-label="Hold to delete">
<span class="btn-dissolve__fill" aria-hidden="true"></span>
<span class="btn-dissolve__label" aria-hidden="true"><span class="btn-dissolve__ch" style="--x:-6px;--y:18px;--r:-35deg;--d:742.8571428571429ms">H</span><span class="btn-dissolve__ch" style="--x:10px;--y:20px;--r:-6deg;--d:685.7142857142857ms">o</span><span class="btn-dissolve__ch" style="--x:5px;--y:22px;--r:23deg;--d:628.5714285714286ms">l</span><span class="btn-dissolve__ch" style="--x:0px;--y:24px;--r:-18deg;--d:571.4285714285714ms">d</span><span class="btn-dissolve__ch" style="--x:-5px;--y:26px;--r:11deg;--d:514.2857142857143ms"> </span><span class="btn-dissolve__ch" style="--x:11px;--y:28px;--r:-30deg;--d:457.1428571428571ms">t</span><span class="btn-dissolve__ch" style="--x:6px;--y:30px;--r:-1deg;--d:400ms">o</span><span class="btn-dissolve__ch" style="--x:1px;--y:32px;--r:28deg;--d:342.85714285714283ms"> </span><span class="btn-dissolve__ch" style="--x:-4px;--y:34px;--r:-13deg;--d:285.7142857142857ms">d</span><span class="btn-dissolve__ch" style="--x:12px;--y:19px;--r:16deg;--d:228.57142857142856ms">e</span><span class="btn-dissolve__ch" style="--x:7px;--y:21px;--r:-25deg;--d:171.42857142857142ms">l</span><span class="btn-dissolve__ch" style="--x:2px;--y:23px;--r:4deg;--d:114.28571428571428ms">e</span><span class="btn-dissolve__ch" style="--x:-3px;--y:25px;--r:33deg;--d:57.14285714285714ms">t</span><span class="btn-dissolve__ch" style="--x:13px;--y:27px;--r:-8deg;--d:0ms">e</span></span>
<span class="btn-dissolve__done" aria-hidden="true">Deleted · <b>Undo</b></span>
</button>.btn-dissolve {
position: relative;
display: inline-grid;
place-items: center;
min-width: 190px;
height: 48px;
padding: 0 22px;
border: none;
border-radius: 12px;
background: light-dark(#ebedf0, #232529);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035), 0 8px 20px -12px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.6));
color: light-dark(#373a40, #f1f2f3);
font: inherit;
font-size: 14px;
font-weight: 500;
cursor: pointer;
user-select: none;
touch-action: none;
}
.btn-dissolve:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.btn-dissolve__fill {
position: absolute;
inset: 0;
border-radius: inherit;
background: color-mix(in oklab, light-dark(#373a40, #f1f2f3) 10%, transparent);
transform: scaleX(0);
transform-origin: 100% 50%;
transition: transform 0.3s ease-out;
}
.btn-dissolve[data-holding] .btn-dissolve__fill { transform: scaleX(1); transition: transform 1300ms linear; }
.btn-dissolve__label, .btn-dissolve__done { grid-area: 1 / 1; position: relative; white-space: pre; }
.btn-dissolve__ch {
display: inline-block;
transition: transform 0.35s cubic-bezier(0.3, 1.3, 0.5, 1), opacity 0.3s, filter 0.3s;
}
/* Letters crumble from the right end first; on release they all come back together. */
.btn-dissolve[data-holding] .btn-dissolve__ch,
.btn-dissolve[data-done] .btn-dissolve__ch {
transform: translate(var(--x), var(--y)) rotate(var(--r)) scale(0.4);
opacity: 0;
filter: blur(2px);
transition: transform 0.6s cubic-bezier(0.5, 0, 0.9, 0.6) var(--d), opacity 0.6s ease-in var(--d), filter 0.6s var(--d);
}
.btn-dissolve__done { color: light-dark(#50535b, #c6c8cd); opacity: 0; transform: translateY(6px); transition: opacity 0.3s, transform 0.3s; }
.btn-dissolve[data-done] .btn-dissolve__done { opacity: 1; transform: none; transition-delay: 0.15s; }
.btn-dissolve__done b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; text-decoration: underline; text-underline-offset: 3px; }
@media (prefers-reduced-motion: reduce) {
.btn-dissolve__ch { transition: opacity 0.2s !important; transform: none !important; filter: none !important; }
}function initBtnDissolve(root) {
var HOLD = 1300;
var holding = false;
var done = false;
var timer;
function render() {
root.toggleAttribute('data-holding', holding);
root.toggleAttribute('data-done', done);
root.setAttribute('aria-label', done ? 'Deleted. Press to undo' : 'Hold to delete');
}
function start() {
if (done) return;
holding = true;
render();
// Held long enough: the letters have crumbled away and the delete goes through.
timer = setTimeout(function () {
holding = false;
done = true;
render();
}, HOLD);
}
function stop() {
clearTimeout(timer);
holding = false;
render();
}
function onKeyDown(e) {
if ((e.key === ' ' || e.key === 'Enter') && !e.repeat) {
e.preventDefault();
start();
}
}
function onKeyUp(e) {
if (e.key === ' ' || e.key === 'Enter') stop();
}
function onClick() {
if (!done) return;
done = false;
render();
}
root.addEventListener('pointerdown', start);
root.addEventListener('pointerup', stop);
root.addEventListener('pointerleave', stop);
root.addEventListener('keydown', onKeyDown);
root.addEventListener('keyup', onKeyUp);
root.addEventListener('click', onClick);
return function () {
clearTimeout(timer);
root.removeEventListener('pointerdown', start);
root.removeEventListener('pointerup', stop);
root.removeEventListener('pointerleave', stop);
root.removeEventListener('keydown', onKeyDown);
root.removeEventListener('keyup', onKeyUp);
root.removeEventListener('click', onClick);
};
}
document.querySelectorAll('.btn-dissolve').forEach(initBtnDissolve);Goal: Create a "Dissolve" button.
Visual behavior: A hold to delete button: while you hold it, a bar fills and the label crumbles into sand from the right, each letter drifting and turning away. Let go early and the letters gather back; hold to the end and it offers Undo.
Technique:
- Pointer down (or Space and Enter held down) sets data-holding; the fill scales across on a linear transition exactly as long as the hold, and a timer completes the delete at the end.
- Each letter is a span with its own fixed --x, --y and --r drift (derived from its index, so nothing is random) and a --d delay that runs from the right end first; while holding they fall and fade on transitions with those delays, and on release the delays are dropped, so they all come straight back together.
- When the hold completes, data-done swaps the label for "Deleted · Undo"; a click restores it.
Accessibility: A real <button> with a visible label; decoration is aria-hidden. It works from the keyboard by holding Space or Enter, and its aria-label switches between "Hold to delete" and "Deleted. Press to undo". Under prefers-reduced-motion the letters fade instead of drifting.
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 "Dissolve" button.
Visual behavior: A hold to delete button: while you hold it, a bar fills and the label crumbles into sand from the right, each letter drifting and turning away. Let go early and the letters gather back; hold to the end and it offers Undo.
Technique:
- Pointer down (or Space and Enter held down) sets data-holding; the fill scales across on a linear transition exactly as long as the hold, and a timer completes the delete at the end.
- Each letter is a span with its own fixed --x, --y and --r drift (derived from its index, so nothing is random) and a --d delay that runs from the right end first; while holding they fall and fade on transitions with those delays, and on release the delays are dropped, so they all come straight back together.
- When the hold completes, data-done swaps the label for "Deleted · Undo"; a click restores it.
Accessibility: A real <button> with a visible label; decoration is aria-hidden. It works from the keyboard by holding Space or Enter, and its aria-label switches between "Hold to delete" and "Deleted. Press to undo". Under prefers-reduced-motion the letters fade instead of drifting.
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
A hold to delete button: while you hold it, a bar fills and the label crumbles into sand from the right, each letter drifting and turning away. Let go early and the letters gather back; hold to the end and it offers Undo.
- Dependencies
- None
- Difficulty
- Advanced
- Triggers
- click, state change, delay stagger
- Techniques
- css variables, css transition, state machine, stagger
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A real <button> with a visible label; decoration is aria-hidden. It works from the keyboard by holding Space or Enter, and its aria-label switches between "Hold to delete" and "Deleted. Press to undo". Under prefers-reduced-motion the letters fade instead of drifting.