Buttons
PeelSticker
A die-cut sticker with one corner lifted. Hover lifts it more, a click peels it off the paper to show what is printed under it, then it lands back.
Download
- React
- Tailwind CSS v4
- motion
components/ui/peel-sticker.tsx
"use client";
import { useEffect, useRef, type ReactNode } from "react";
import { animate, motion, useMotionTemplate, useMotionValue, useReducedMotion, useTransform } from "motion/react";
type PeelStickerProps = {
children: ReactNode;
className?: string;
onClick?: () => void;
/** Label printed on the paper under the sticker. */
under?: ReactNode;
/** Seconds before a peeled sticker lands back. */
resetAfter?: number;
};
const REST = 12;
const HOVER = 17;
/* PeelSticker: a die-cut sticker with one corner lifted. Hover lifts it more, a click peels it right off the paper, then it lands back. */
export function PeelSticker({
children,
className = "bg-[#ff5d73] px-10 py-5 text-[16px] font-bold text-white",
onClick,
under = "Stuck on you",
resetAfter = 1.6,
}: PeelStickerProps) {
const reduce = useReducedMotion();
const box = useRef<HTMLButtonElement>(null);
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
const peeled = useRef(false);
const size = useMotionValue(0);
const peel = useMotionValue(REST);
const keep = useTransform(peel, (p) => 100 - p);
// A line at 135deg is x + y = constant, so the fold stays true on any button size.
const stickerMask = useMotionTemplate`linear-gradient(135deg, #000 ${keep}%, transparent ${keep}%)`;
const flapMask = useMotionTemplate`linear-gradient(135deg, transparent ${keep}%, #000 ${keep}%)`;
// Mirror across that same line: the lifted part folds over the sticker.
const flapTransform = useTransform([peel, size], ([p, s]: number[]) => {
const c = s * (1 - p / 100);
return `matrix(0, -1, -1, 0, ${c}, ${c})`;
});
const flapOpacity = useTransform(peel, [96, 100], [1, 0]);
useEffect(() => {
const el = box.current;
if (!el) return;
const ro = new ResizeObserver(() => size.set(el.offsetWidth + el.offsetHeight));
ro.observe(el);
return () => {
ro.disconnect();
clearTimeout(timer.current);
};
}, [size]);
const go = (to: number, seconds = 0.3) => {
if (reduce) peel.set(to);
else animate(peel, to, { duration: seconds, ease: "easeOut" });
};
return (
<button
ref={box}
type="button"
onClick={() => {
onClick?.();
peeled.current = true;
go(100, 0.55);
clearTimeout(timer.current);
timer.current = setTimeout(() => {
peeled.current = false;
go(REST, 0.4);
}, resetAfter * 1000);
}}
onPointerEnter={() => !peeled.current && go(HOVER)}
onPointerLeave={() => !peeled.current && go(REST)}
onFocus={() => !peeled.current && go(HOVER)}
onBlur={() => !peeled.current && go(REST)}
className="relative isolate inline-block rounded-2xl"
>
<span
aria-hidden
className="absolute inset-0 -z-10 grid place-items-center rounded-2xl border-2 border-dashed border-white/25 bg-[#17161f] text-[13px] text-white/70"
>
{under}
</span>
<span className="block [filter:drop-shadow(0_3px_4px_rgba(0,0,0,0.45))]">
<motion.span
className={`block rounded-2xl border-4 border-white ${className}`}
style={{ maskImage: stickerMask, WebkitMaskImage: stickerMask }}
>
{children}
</motion.span>
</span>
<span aria-hidden className="pointer-events-none absolute inset-0 [filter:drop-shadow(-3px_-3px_4px_rgba(0,0,0,0.4))]">
<motion.span
className="absolute inset-0 origin-top-left rounded-2xl bg-[linear-gradient(135deg,#ffffff,#e6e3dc_55%,#c9c5bb)]"
style={{ transform: flapTransform, opacity: flapOpacity, maskImage: flapMask, WebkitMaskImage: flapMask }}
/>
</span>
</button>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/peel-sticker.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/peel-sticker-demo.tsx
import { PeelSticker } from "@/components/ui/peel-sticker"; export default function PeelStickerExample() { return ( <div className="grid h-full place-items-center bg-[radial-gradient(circle_at_50%_60%,#2b1d2a,#0d0c14_70%)]"> <PeelSticker under="50% off, kept for you">Peel for the offer</PeelSticker> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| children | ReactNode | — | Label on the sticker. |
| onClick | () => void | — | Click handler. |
| under | ReactNode | "Stuck on you" | Label on the paper under the sticker. Hidden from screen readers. |
| resetAfter | number | 1.6 | Seconds before the sticker lands back. |
| className | string | pink sticker | Colours, padding and text. The white die-cut border stays. |