07Trash-Shake Warning That Trembles Over the Delete Zone
The time the dragged item spends inside the delete zone is calculated, and once it passes a threshold the item trembles side to side to warn it's about to be deleted.
Best forDropping a file onto a file-manager trash icon
TRASH_DWELL_SHAKE_MSTRASH_SHAKE_MAX_PXTRASH_DELETE_DWELL_MS

<main class="stage">
<div class="ts is-demo" id="ts">
<div class="ts__trash" id="tsTrash" aria-hidden="true"></div>
<button type="button" class="ts__icon" id="tsIcon" aria-label="A card that shakes and disappears when left over the trash. Press Enter to grab, arrow keys to move it, Enter to drop and delete"></button>
<p class="ts__caption" id="tsCaption">Stay over the trash too long and it shakes, then disappears</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.ts {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
max-width: 400px;
height: 200px;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
gap: $sp-4;
}
.ts__trash {
position: absolute;
top: 10%;
right: 14%;
width: 60px;
height: 60px;
border-radius: $r-card;
border: 2px dashed rgba($subject-ink, .3);
transition: border-color $dur-quick $ease-out, background-color $dur-quick $ease-out;
}
.ts__trash.is-armed {
border-color: $subject-orange-deep;
border-style: solid;
background: rgba($subject-orange-deep, .12);
}
.ts__icon {
position: absolute;
top: 62%;
left: 24%;
appearance: none;
border: 0;
width: 56px;
height: 56px;
margin: -28px 0 0 -28px;
border-radius: $r-control;
background: $subject-blue;
box-shadow: $shadow-raised;
cursor: grab;
touch-action: none;
will-change: transform, opacity;
}
.ts.is-armed .ts__icon {
background: $subject-orange-deep;
}
.ts__icon.is-grabbed,
.ts__icon:focus-visible {
outline: 2px solid $subject-blue-light;
outline-offset: 3px;
}
.ts.is-dragging .ts__icon {
cursor: grabbing;
}
.ts__caption {
position: relative;
margin: 0;
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
text-align: center;
}
@media (max-width: 340px) {
.ts { height: 160px; }
.ts__trash { width: 46px; height: 46px; }
.ts__icon { width: 42px; height: 42px; margin: -21px 0 0 -21px; }
.ts__caption { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.ts__icon { transition: none; }
.ts__trash { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ts {
position: relative;
width: min(92vw, 160vh - 12.8vw);
max-width: 400px;
height: 200px;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
gap: 16px;
}
.ts__trash {
position: absolute;
top: 10%;
right: 14%;
width: 60px;
height: 60px;
border-radius: 16px;
border: 2px dashed rgba(23, 20, 26, 0.3);
transition: border-color 220ms ease-out, background-color 220ms ease-out;
}
.ts__trash.is-armed {
border-color: #c93000;
border-style: solid;
background: rgba(201, 48, 0, 0.12);
}
.ts__icon {
position: absolute;
top: 62%;
left: 24%;
appearance: none;
border: 0;
width: 56px;
height: 56px;
margin: -28px 0 0 -28px;
border-radius: 12px;
background: #2f6df6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
cursor: grab;
touch-action: none;
will-change: transform, opacity;
}
.ts.is-armed .ts__icon {
background: #c93000;
}
.ts__icon.is-grabbed,
.ts__icon:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 3px;
}
.ts.is-dragging .ts__icon {
cursor: grabbing;
}
.ts__caption {
position: relative;
margin: 0;
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
text-align: center;
}
@media (max-width: 340px) {
.ts {
height: 160px;
}
.ts__trash {
width: 46px;
height: 46px;
}
.ts__icon {
width: 42px;
height: 42px;
margin: -21px 0 0 -21px;
}
.ts__caption {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.ts__icon {
transition: none;
}
.ts__trash {
transition: none;
}
}(function () {
// 기법: 드래그 중인 카드 중심이 휴지통 영역과 겹치는 동안 머문 시간(dwell)을 매
// 프레임 누적하고, 그 시간에 비례해 흔들림 진폭을 사인 파형으로 키운다. 머문
// 시간이 TRASH_DELETE_DWELL_MS 를 넘긴 채 놓으면 scale 을 0 으로 줄이며 삭제한다.
var root = document.getElementById('ts');
var trash = document.getElementById('tsTrash');
var icon = document.getElementById('tsIcon');
var caption = document.getElementById('tsCaption');
var TRASH_DWELL_SHAKE_MS = 700;
var TRASH_SHAKE_MAX_PX = 5;
var TRASH_DELETE_DWELL_MS = 550;
var KEY_STEP_PX = 16;
var pos = { x: 0, y: 0 };
var basePos = { x: 0, y: 0 };
var dragging = false;
var grabbed = false;
var dwellStart = null;
var overTrash = false;
var raf = 0;
var deleted = false;
function overlapsTrash() {
var ir = icon.getBoundingClientRect();
var tr = trash.getBoundingClientRect();
var icx = ir.left + ir.width / 2, icy = ir.top + ir.height / 2;
return icx > tr.left && icx < tr.right && icy > tr.top && icy < tr.bottom;
}
function applyIcon(shakeX, shakeY, scale) {
icon.style.transform = 'translate(' + (pos.x + shakeX).toFixed(1) + 'px,' + (pos.y + shakeY).toFixed(1) + 'px) scale(' + scale.toFixed(3) + ')';
}
function shakeTick(ts) {
if (deleted) return;
var isOver = overlapsTrash();
if (isOver && !overTrash) { dwellStart = ts; }
overTrash = isOver;
trash.classList.toggle('is-armed', overTrash);
var shakeX = 0, shakeY = 0;
var dwellMs = 0;
if (overTrash && dwellStart !== null) {
dwellMs = ts - dwellStart;
var amp = Math.min(1, dwellMs / TRASH_DWELL_SHAKE_MS) * TRASH_SHAKE_MAX_PX;
shakeX = Math.sin(ts / 40) * amp;
shakeY = Math.cos(ts / 55) * amp * 0.6;
if (dwellMs > TRASH_DELETE_DWELL_MS) {
caption.textContent = 'Release now and it deletes';
root.classList.add('is-armed');
} else {
caption.textContent = 'Stay longer and it gets ready to delete';
root.classList.remove('is-armed');
}
} else {
caption.textContent = dragging ? 'Try moving it over the trash' : 'Stay over the trash too long and it shakes, then disappears';
root.classList.remove('is-armed');
}
applyIcon(shakeX, shakeY, 1);
if (dragging) raf = requestAnimationFrame(shakeTick);
}
function respawn() {
deleted = false;
pos.x = 0; pos.y = 0;
icon.style.opacity = '1';
applyIcon(0, 0, 1);
caption.textContent = 'Stay over the trash too long and it shakes, then disappears';
}
function deleteIcon() {
deleted = true;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
caption.textContent = 'Deleted';
if (reduce) {
icon.style.opacity = '0';
requestAnimationFrame(function waitReduce(ts) {
if (waitReduce.start === undefined) waitReduce.start = ts;
if (ts - waitReduce.start < 260) { requestAnimationFrame(waitReduce); } else { respawn(); }
});
return;
}
var start = null;
function shrink(ts) {
if (start === null) start = ts;
var p = Math.min(1, (ts - start) / 260);
applyIcon(0, 0, 1 - p);
icon.style.opacity = String(1 - p);
if (p < 1) { requestAnimationFrame(shrink); return; }
requestAnimationFrame(function waitRespawn(ts2) {
if (waitRespawn.start === undefined) waitRespawn.start = ts2;
if (ts2 - waitRespawn.start < 260) { requestAnimationFrame(waitRespawn); } else { respawn(); }
});
}
requestAnimationFrame(shrink);
}
function beginDrag(clientX, clientY) {
if (deleted) return;
dragging = true;
overTrash = false;
dwellStart = null;
basePos = { x: clientX - pos.x, y: clientY - pos.y };
root.classList.add('is-dragging');
cancelAnimationFrame(raf);
raf = requestAnimationFrame(shakeTick);
}
function moveDrag(clientX, clientY) {
pos.x = clientX - basePos.x;
pos.y = clientY - basePos.y;
}
function endDrag() {
dragging = false;
root.classList.remove('is-dragging');
var isOver = overlapsTrash();
var dwellMs = (isOver && dwellStart !== null) ? performance.now() - dwellStart : 0;
if (isOver && dwellMs > TRASH_DELETE_DWELL_MS) {
deleteIcon();
} else {
caption.textContent = 'Stay over the trash too long and it shakes, then disappears';
root.classList.remove('is-armed');
trash.classList.remove('is-armed');
applyIcon(0, 0, 1);
}
}
icon.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
icon.setPointerCapture(e.pointerId);
beginDrag(e.clientX, e.clientY);
e.preventDefault();
});
icon.addEventListener('pointermove', function (e) {
if (!dragging) return;
moveDrag(e.clientX, e.clientY);
e.preventDefault();
});
icon.addEventListener('pointerup', function (e) {
if (!dragging) return;
endDrag();
e.preventDefault();
});
icon.addEventListener('keydown', function (e) {
root.classList.remove('is-demo');
if (deleted) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (!grabbed) {
grabbed = true;
icon.classList.add('is-grabbed');
var r = icon.getBoundingClientRect();
beginDrag(r.left + r.width / 2, r.top + r.height / 2);
caption.textContent = 'Grabbed. Move it over the trash with arrow keys';
} else {
grabbed = false;
icon.classList.remove('is-grabbed');
dragging = false;
endDrag();
}
return;
}
if (!grabbed) return;
var dx = 0, dy = 0;
if (e.key === 'ArrowLeft') dx = -KEY_STEP_PX;
else if (e.key === 'ArrowRight') dx = KEY_STEP_PX;
else if (e.key === 'ArrowUp') dy = -KEY_STEP_PX;
else if (e.key === 'ArrowDown') dy = KEY_STEP_PX;
else if (e.key === 'Escape') {
e.preventDefault();
grabbed = false;
icon.classList.remove('is-grabbed');
dragging = false;
root.classList.remove('is-dragging', 'is-armed');
trash.classList.remove('is-armed');
applyIcon(0, 0, 1);
caption.textContent = 'Stay over the trash too long and it shakes, then disappears';
return;
} else { return; }
e.preventDefault();
pos.x += dx; pos.y += dy;
});
applyIcon(0, 0, 1);
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
root.classList.remove('is-demo');
} else {
var iconRect0 = icon.getBoundingClientRect();
var ox0 = iconRect0.left + iconRect0.width / 2;
var oy0 = iconRect0.top + iconRect0.height / 2;
var trashRect0 = trash.getBoundingClientRect();
var tx0 = trashRect0.left + trashRect0.width / 2;
var ty0 = trashRect0.top + trashRect0.height / 2;
var demoStart = null;
function demoTick(ts) {
if (demoStart === null) demoStart = ts;
var t = ts - demoStart;
if (t < 200) {
// 대기
} else if (t < 700) {
var p = (t - 200) / 500;
var ease = 1 - Math.pow(1 - p, 2);
var cx = ox0 + (tx0 - ox0) * ease;
var cy = oy0 + (ty0 - oy0) * ease;
if (t < 220) beginDrag(ox0, oy0);
moveDrag(cx, cy);
} else if (t < 1550) {
// 휴지통 위에서 대기 → 떨림 누적
moveDrag(tx0, ty0);
} else if (t < 1600) {
endDrag();
} else {
if (t > 2600) demoStart = ts - 60;
}
requestAnimationFrame(demoTick);
}
requestAnimationFrame(demoTick);
}
})();import { useEffect, useRef } from "react";
import styles from "./DragPhysTrashShake.module.scss";
type Box<T> = { current: T };
type Pt = { x: number; y: number };
export default function DragPhysTrashShake() {
const rootRef = useRef<HTMLDivElement>(null);
const trashRef = useRef<HTMLDivElement>(null);
const iconRef = useRef<HTMLButtonElement>(null);
const captionRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
const root = rootRef.current;
const trash = trashRef.current;
const icon = iconRef.current;
const caption = captionRef.current;
if (!root || !trash || !icon || !caption) return;
const TRASH_DWELL_SHAKE_MS = 700;
const TRASH_SHAKE_MAX_PX = 5;
const TRASH_DELETE_DWELL_MS = 550;
const KEY_STEP_PX = 16;
const pos: Pt = { x: 0, y: 0 };
let basePos: Pt = { x: 0, y: 0 };
let dragging = false;
let grabbed = false;
let dwellStart: number | null = null;
let overTrash = false;
let raf = 0;
let deleted = false;
function overlapsTrash() {
const ir = icon!.getBoundingClientRect();
const tr = trash!.getBoundingClientRect();
const icx = ir.left + ir.width / 2, icy = ir.top + ir.height / 2;
return icx > tr.left && icx < tr.right && icy > tr.top && icy < tr.bottom;
}
function applyIcon(shakeX: number, shakeY: number, scale: number) {
icon!.style.transform =
"translate(" + (pos.x + shakeX).toFixed(1) + "px," + (pos.y + shakeY).toFixed(1) + "px) scale(" + scale.toFixed(3) + ")";
}
function shakeTick(ts: number) {
if (deleted) return;
const isOver = overlapsTrash();
if (isOver && !overTrash) { dwellStart = ts; }
overTrash = isOver;
trash!.classList.toggle(styles.isArmed, overTrash);
let shakeX = 0, shakeY = 0;
if (overTrash && dwellStart !== null) {
const dwellMs = ts - dwellStart;
const amp = Math.min(1, dwellMs / TRASH_DWELL_SHAKE_MS) * TRASH_SHAKE_MAX_PX;
shakeX = Math.sin(ts / 40) * amp;
shakeY = Math.cos(ts / 55) * amp * 0.6;
if (dwellMs > TRASH_DELETE_DWELL_MS) {
caption!.textContent = "지금 놓으면 삭제돼요";
root!.classList.add(styles.isArmed);
} else {
caption!.textContent = "더 있으면 삭제 준비돼요";
root!.classList.remove(styles.isArmed);
}
} else {
caption!.textContent = dragging ? "휴지통 위로 옮겨보세요" : "휴지통 위에 오래 있으면 떨리다 사라져요";
root!.classList.remove(styles.isArmed);
}
applyIcon(shakeX, shakeY, 1);
if (dragging) raf = requestAnimationFrame(shakeTick);
}
function respawn() {
deleted = false;
pos.x = 0; pos.y = 0;
icon!.style.opacity = "1";
applyIcon(0, 0, 1);
caption!.textContent = "휴지통 위에 오래 있으면 떨리다 사라져요";
}
function deleteIcon() {
deleted = true;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
caption!.textContent = "삭제됐어요";
if (reduce) {
icon!.style.opacity = "0";
let waitStart: number | null = null;
function waitReduce(ts: number) {
if (waitStart === null) waitStart = ts;
if (ts - waitStart < 260) { requestAnimationFrame(waitReduce); } else { respawn(); }
}
requestAnimationFrame(waitReduce);
return;
}
let start: number | null = null;
function shrink(ts: number) {
if (start === null) start = ts;
const p = Math.min(1, (ts - start!) / 260);
applyIcon(0, 0, 1 - p);
icon!.style.opacity = String(1 - p);
if (p < 1) { requestAnimationFrame(shrink); return; }
let waitStart: number | null = null;
function waitRespawn(ts2: number) {
if (waitStart === null) waitStart = ts2;
if (ts2 - waitStart < 260) { requestAnimationFrame(waitRespawn); } else { respawn(); }
}
requestAnimationFrame(waitRespawn);
}
requestAnimationFrame(shrink);
}
function beginDrag(clientX: number, clientY: number) {
if (deleted) return;
dragging = true;
overTrash = false;
dwellStart = null;
basePos = { x: clientX - pos.x, y: clientY - pos.y };
root!.classList.add(styles.isDragging);
cancelAnimationFrame(raf);
raf = requestAnimationFrame(shakeTick);
}
function moveDrag(clientX: number, clientY: number) {
pos.x = clientX - basePos.x;
pos.y = clientY - basePos.y;
}
function endDrag() {
dragging = false;
root!.classList.remove(styles.isDragging);
const isOver = overlapsTrash();
const dwellMs = isOver && dwellStart !== null ? performance.now() - dwellStart : 0;
if (isOver && dwellMs > TRASH_DELETE_DWELL_MS) {
deleteIcon();
} else {
caption!.textContent = "휴지통 위에 오래 있으면 떨리다 사라져요";
root!.classList.remove(styles.isArmed);
trash!.classList.remove(styles.isArmed);
applyIcon(0, 0, 1);
}
}
function onPointerDown(e: PointerEvent) {
root!.classList.remove("is-demo");
icon!.setPointerCapture(e.pointerId);
beginDrag(e.clientX, e.clientY);
e.preventDefault();
}
function onPointerMove(e: PointerEvent) {
if (!dragging) return;
moveDrag(e.clientX, e.clientY);
e.preventDefault();
}
function onPointerUp(e: PointerEvent) {
if (!dragging) return;
endDrag();
e.preventDefault();
}
icon.addEventListener("pointerdown", onPointerDown);
icon.addEventListener("pointermove", onPointerMove);
icon.addEventListener("pointerup", onPointerUp);
function onKeyDown(e: KeyboardEvent) {
root!.classList.remove("is-demo");
if (deleted) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (!grabbed) {
grabbed = true;
icon!.classList.add(styles.isGrabbed);
const r = icon!.getBoundingClientRect();
beginDrag(r.left + r.width / 2, r.top + r.height / 2);
caption!.textContent = "잡았어요. 화살표로 휴지통 위로 옮기세요";
} else {
grabbed = false;
icon!.classList.remove(styles.isGrabbed);
dragging = false;
endDrag();
}
return;
}
if (!grabbed) return;
let dx = 0, dy = 0;
if (e.key === "ArrowLeft") dx = -KEY_STEP_PX;
else if (e.key === "ArrowRight") dx = KEY_STEP_PX;
else if (e.key === "ArrowUp") dy = -KEY_STEP_PX;
else if (e.key === "ArrowDown") dy = KEY_STEP_PX;
else if (e.key === "Escape") {
e.preventDefault();
grabbed = false;
icon!.classList.remove(styles.isGrabbed);
dragging = false;
root!.classList.remove(styles.isDragging, styles.isArmed);
trash!.classList.remove(styles.isArmed);
applyIcon(0, 0, 1);
caption!.textContent = "휴지통 위에 오래 있으면 떨리다 사라져요";
return;
} else { return; }
e.preventDefault();
pos.x += dx; pos.y += dy;
}
icon.addEventListener("keydown", onKeyDown);
applyIcon(0, 0, 1);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demoRaf = 0;
if (reduce) {
root.classList.remove("is-demo");
} else {
const iconRect0 = icon.getBoundingClientRect();
const ox0 = iconRect0.left + iconRect0.width / 2;
const oy0 = iconRect0.top + iconRect0.height / 2;
const trashRect0 = trash.getBoundingClientRect();
const tx0 = trashRect0.left + trashRect0.width / 2;
const ty0 = trashRect0.top + trashRect0.height / 2;
let demoStart: number | null = null;
function demoTick(ts: number) {
if (demoStart === null) demoStart = ts;
const t = ts - demoStart;
if (t < 200) {
// 대기
} else if (t < 700) {
const p = (t - 200) / 500;
const ease = 1 - Math.pow(1 - p, 2);
const cx = ox0 + (tx0 - ox0) * ease;
const cy = oy0 + (ty0 - oy0) * ease;
if (t < 220) beginDrag(ox0, oy0);
moveDrag(cx, cy);
} else if (t < 1550) {
moveDrag(tx0, ty0);
} else if (t < 1600) {
endDrag();
} else {
if (t > 2600) demoStart = ts - 60;
}
demoRaf = requestAnimationFrame(demoTick);
}
demoRaf = requestAnimationFrame(demoTick);
}
return () => {
icon.removeEventListener("pointerdown", onPointerDown);
icon.removeEventListener("pointermove", onPointerMove);
icon.removeEventListener("pointerup", onPointerUp);
icon.removeEventListener("keydown", onKeyDown);
cancelAnimationFrame(raf);
cancelAnimationFrame(demoRaf);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.ts} is-demo`} ref={rootRef}>
<div className={styles.trash} ref={trashRef} aria-hidden="true"></div>
<button
type="button"
className={styles.icon}
ref={iconRef}
aria-label="휴지통 위에 놓으면 떨리다 사라지는 카드. Enter 로 잡고 화살표로 옮긴 뒤 Enter 로 놓아 삭제합니다"
></button>
<p className={styles.caption} ref={captionRef}>휴지통 위에 오래 있으면 떨리다 사라져요</p>
</div>
</main>
);
}07 Trash-Shake Warning That Trembles Over the Delete Zone — Measured area 17.508% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · Drag and Drop Animation CSS: 9 Physics Patterns
The post this demo lives in
Drag and Drop Animation CSS: 9 Physics PatternsNine drag and drop animation css builds: A magnetic grid catches the icon inside a 24px radius, a rubber-band edge lets See all demos in this post
If you like this effect - similar demos
drag01 FLIP list — computing the before/after offset and releasing it into a transitionDrag to Reorder UI: 9 Lists, One Drop Index
drag03 Multi-file — a group that counts entries and computes filled slots versus the remainderDrag and Drop File Upload UI: 9 Computed Dropzones
drag01 A Box That Follows the Corner Grip Like a SpringCSS Resize Handle: 9 Demos That Follow Your Grip