Rewind
import { useEffect, useRef, useState } from 'react';
const HISTORY = ['Moved logo', 'Renamed frame', 'Deleted layer'];
const css = `
@keyframes btn-rewind-spin-a { from { transform: rotate(0deg); } to { transform: rotate(-360deg); } }
@keyframes btn-rewind-spin-b { from { transform: rotate(0deg); } to { transform: rotate(-360deg); } }
@keyframes btn-rewind-ghost { from { opacity: 0.5; } to { opacity: 0; transform: rotate(-120deg); } }
/* The undone action runs back out of the label: fast, skewed, smeared. */
@keyframes btn-rewind-out-a { 0% { transform: none; opacity: 1; filter: none; } 100% { transform: translateX(-60px) skewX(18deg); opacity: 0; filter: blur(2px); } }
@keyframes btn-rewind-out-b { 0% { transform: none; opacity: 1; filter: none; } 100% { transform: translateX(-60px) skewX(18deg); opacity: 0; filter: blur(2px); } }
.btn-rewind {
display: inline-flex;
align-items: center;
gap: 12px;
height: 48px;
padding: 0 18px 0 14px;
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;
}
.btn-rewind:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.btn-rewind:disabled { cursor: default; color: light-dark(#5f626a, #a7abb2); }
.btn-rewind__icon { position: relative; width: 20px; height: 20px; }
.btn-rewind__icon svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.btn-rewind__ghost { opacity: 0; }
.btn-rewind[data-flip='a'] .btn-rewind__arrow { animation: btn-rewind-spin-a 0.6s cubic-bezier(0.2, 0.8, 0.3, 1); }
.btn-rewind[data-flip='b'] .btn-rewind__arrow { animation: btn-rewind-spin-b 0.6s cubic-bezier(0.2, 0.8, 0.3, 1); }
.btn-rewind[data-flip] .btn-rewind__ghost { animation: btn-rewind-ghost 0.5s ease-out; }
.btn-rewind__text { position: relative; display: grid; width: 168px; text-align: left; }
.btn-rewind__text > span { grid-area: 1 / 1; white-space: nowrap; }
.btn-rewind__left { color: light-dark(#5f626a, #a7abb2); font-weight: 400; }
.btn-rewind__gone { color: light-dark(#50535b, #c6c8cd); font-weight: 400; text-decoration: line-through; opacity: 0; pointer-events: none; }
.btn-rewind[data-flip='a'] .btn-rewind__gone { animation: btn-rewind-out-a 0.55s ease-in; }
.btn-rewind[data-flip='b'] .btn-rewind__gone { animation: btn-rewind-out-b 0.55s ease-in; }
@media (prefers-reduced-motion: reduce) {
.btn-rewind * { animation: none !important; }
}
`;
export function RewindButton() {
const [left, setLeft] = useState(HISTORY.length);
const [flip, setFlip] = useState(0);
const [msg, setMsg] = useState('');
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => () => clearTimeout(timer.current), []);
const undo = () => {
if (left === 0) return;
setMsg(`Undid ${HISTORY[left - 1]!.toLowerCase()}`);
setLeft((n) => n - 1);
setFlip((f) => f + 1);
clearTimeout(timer.current);
// Everything undone: after a pause the history comes back, so the demo can run again.
if (left === 1) timer.current = setTimeout(() => setLeft(HISTORY.length), 2400);
};
const gone = left < HISTORY.length ? HISTORY[left] : '';
return (
<>
<style>
{css}
</style>
<button type="button" className="btn-rewind" data-flip={flip ? (flip % 2 ? 'a' : 'b') : undefined} onClick={undo} disabled={left === 0}>
<span className="btn-rewind__icon" aria-hidden="true">
<svg className="btn-rewind__ghost" viewBox="0 0 24 24" fill="none">
<path d="M4 12a8 8 0 1 0 2.3-5.6" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
<svg className="btn-rewind__arrow" viewBox="0 0 24 24" fill="none">
<path d="M4 12a8 8 0 1 0 2.3-5.6M4 4v4.5h4.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
<span className="btn-rewind__text">
<span className="btn-rewind__now">
{left ? 'Undo' : 'All undone'} <span className="btn-rewind__left">{left ? `· ${HISTORY[left - 1]}` : ''}</span>
</span>
<span className="btn-rewind__gone" key={flip} aria-hidden="true">
{gone}
</span>
</span>
<span className="btn-rewind__sr" style={{ position: 'absolute', width: 1, height: 1, overflow: 'hidden', clip: 'rect(0 0 0 0)' }} aria-live="polite">
{msg}
</span>
</button>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initBtnRewind(el) on the mounted element instead.
<button type="button" class="btn-rewind">
<span class="btn-rewind__icon" aria-hidden="true">
<svg class="btn-rewind__ghost" viewBox="0 0 24 24" fill="none">
<path d="M4 12a8 8 0 1 0 2.3-5.6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"></path>
</svg>
<svg class="btn-rewind__arrow" viewBox="0 0 24 24" fill="none">
<path d="M4 12a8 8 0 1 0 2.3-5.6M4 4v4.5h4.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
</span>
<span class="btn-rewind__text">
<span class="btn-rewind__now">Undo <span class="btn-rewind__left">· Deleted layer</span></span>
<span class="btn-rewind__gone" aria-hidden="true"></span>
</span>
<span class="btn-rewind__sr" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)" aria-live="polite"></span>
</button>@keyframes btn-rewind-spin-a { from { transform: rotate(0deg); } to { transform: rotate(-360deg); } }
@keyframes btn-rewind-spin-b { from { transform: rotate(0deg); } to { transform: rotate(-360deg); } }
@keyframes btn-rewind-ghost { from { opacity: 0.5; } to { opacity: 0; transform: rotate(-120deg); } }
/* The undone action runs back out of the label: fast, skewed, smeared. */
@keyframes btn-rewind-out-a { 0% { transform: none; opacity: 1; filter: none; } 100% { transform: translateX(-60px) skewX(18deg); opacity: 0; filter: blur(2px); } }
@keyframes btn-rewind-out-b { 0% { transform: none; opacity: 1; filter: none; } 100% { transform: translateX(-60px) skewX(18deg); opacity: 0; filter: blur(2px); } }
.btn-rewind {
display: inline-flex;
align-items: center;
gap: 12px;
height: 48px;
padding: 0 18px 0 14px;
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;
}
.btn-rewind:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.btn-rewind:disabled { cursor: default; color: light-dark(#5f626a, #a7abb2); }
.btn-rewind__icon { position: relative; width: 20px; height: 20px; }
.btn-rewind__icon svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.btn-rewind__ghost { opacity: 0; }
.btn-rewind[data-flip='a'] .btn-rewind__arrow { animation: btn-rewind-spin-a 0.6s cubic-bezier(0.2, 0.8, 0.3, 1); }
.btn-rewind[data-flip='b'] .btn-rewind__arrow { animation: btn-rewind-spin-b 0.6s cubic-bezier(0.2, 0.8, 0.3, 1); }
.btn-rewind[data-flip] .btn-rewind__ghost { animation: btn-rewind-ghost 0.5s ease-out; }
.btn-rewind__text { position: relative; display: grid; width: 168px; text-align: left; }
.btn-rewind__text > span { grid-area: 1 / 1; white-space: nowrap; }
.btn-rewind__left { color: light-dark(#5f626a, #a7abb2); font-weight: 400; }
.btn-rewind__gone { color: light-dark(#50535b, #c6c8cd); font-weight: 400; text-decoration: line-through; opacity: 0; pointer-events: none; }
.btn-rewind[data-flip='a'] .btn-rewind__gone { animation: btn-rewind-out-a 0.55s ease-in; }
.btn-rewind[data-flip='b'] .btn-rewind__gone { animation: btn-rewind-out-b 0.55s ease-in; }
@media (prefers-reduced-motion: reduce) {
.btn-rewind * { animation: none !important; }
}function initBtnRewind(root) {
var HISTORY = ['Moved logo', 'Renamed frame', 'Deleted layer'];
var now = root.querySelector('.btn-rewind__now').firstChild;
var last = root.querySelector('.btn-rewind__left');
var gone = root.querySelector('.btn-rewind__gone');
var sr = root.querySelector('.btn-rewind__sr');
var left = HISTORY.length;
var flip = 0;
var timer;
function render() {
root.disabled = left === 0;
now.nodeValue = (left ? 'Undo' : 'All undone') + ' ';
last.textContent = left ? '· ' + HISTORY[left - 1] : '';
gone.textContent = left < HISTORY.length ? HISTORY[left] : '';
}
function onClick() {
if (left === 0) return;
sr.textContent = 'Undid ' + HISTORY[left - 1].toLowerCase();
left -= 1;
// Each undo flips data-flip between a and b, so the spin and the strike-out restart.
flip += 1;
root.setAttribute('data-flip', flip % 2 ? 'a' : 'b');
clearTimeout(timer);
// Everything undone: after a pause the history comes back, so the demo can run again.
if (left === 0) timer = setTimeout(function () { left = HISTORY.length; render(); }, 2400);
render();
}
root.addEventListener('click', onClick);
return function () {
clearTimeout(timer);
root.removeEventListener('click', onClick);
};
}
document.querySelectorAll('.btn-rewind').forEach(initBtnRewind);Goal: Create a "Rewind" button.
Visual behavior: An undo button: each press spins its arrow backwards with a fading motion trail, and the undone action runs back out of the label, skewed and smeared like tape, revealing the one before.
Technique:
- The label shows the last action in a small history; each press takes one off and shows the next.
- The arrow spins a full turn backwards, a ghost copy fades behind it, and the undone action slides out to the left with a skew and blur.
- Every press flips data-flip between a and b, picking one of two identically written keyframes, so the effects restart each time; once everything is undone the button disables and the history returns after a pause.
Accessibility: A real <button> with a visible label; decoration is aria-hidden. A visually hidden aria-live region announces what was undone. Under prefers-reduced-motion the arrow and label change without moving.
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 "Rewind" button.
Visual behavior: An undo button: each press spins its arrow backwards with a fading motion trail, and the undone action runs back out of the label, skewed and smeared like tape, revealing the one before.
Technique:
- The label shows the last action in a small history; each press takes one off and shows the next.
- The arrow spins a full turn backwards, a ghost copy fades behind it, and the undone action slides out to the left with a skew and blur.
- Every press flips data-flip between a and b, picking one of two identically written keyframes, so the effects restart each time; once everything is undone the button disables and the history returns after a pause.
Accessibility: A real <button> with a visible label; decoration is aria-hidden. A visually hidden aria-live region announces what was undone. Under prefers-reduced-motion the arrow and label change without moving.
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
An undo button: each press spins its arrow backwards with a fading motion trail, and the undone action runs back out of the label, skewed and smeared like tape, revealing the one before.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, state change
- Techniques
- keyframes, filter, state machine
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A real <button> with a visible label; decoration is aria-hidden. A visually hidden aria-live region announces what was undone. Under prefers-reduced-motion the arrow and label change without moving.