06길게 눌러야 지워지는 삭제
누르고 있는 동안 버튼 둘레의 고리가 한 바퀴 그려지고 다 그려져야 삭제가 일어나며, 중간에 손을 떼거나 손가락이 밖으로 나가면 고리가 되감겨 아무 일도 없었던 일이 됩니다.
쓰임확인 창을 띄우기 싫은 삭제·초기화 같은 되돌릴 수 없는 동작
pointerdownstroke-dashoffsetsetPointerCapture

<main class="stage">
<div class="hd is-demo">
<div class="hd__dial">
<svg class="hd__ring" viewBox="0 0 88 88" width="88" height="88" aria-hidden="true">
<circle class="hd__track" cx="44" cy="44" r="40" />
<circle class="hd__arc" cx="44" cy="44" r="40" />
</svg>
<button class="hd__btn" id="hd-btn" type="button" aria-label="길게 눌러 삭제">
<span class="hd__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="26" height="26"><g fill="currentColor"><path d="M200 56v152a8 8 0 0 1-8 8H64a8 8 0 0 1-8-8V56Z" opacity=".2"/><path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16M96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0m48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0"/></g></svg></span>
</button>
</div>
<p class="hd__log" role="status" aria-live="polite">
<span class="hd__word" data-word="idle">길게 눌러 삭제</span>
<span class="hd__word" data-word="done">지웠습니다</span>
</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: 1200ms;
$easing: $ease-out;
$color: $stage-orange;
@include stage($stage-yellow);
$circumference: 251.33; // 2 × π × 40 — 고리를 한 바퀴로 만드는 길이
.hd {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.hd__dial {
position: relative;
width: 88px;
height: 88px;
}
.hd__ring {
position: absolute;
left: 0;
top: 0;
display: block;
transform: rotate(-90deg); // 12시 방향에서 그리기 시작한다
}
.hd__track {
fill: none;
stroke: $subject-cream;
stroke-width: 6;
}
.hd__arc {
fill: none;
stroke: $color;
stroke-width: 6;
stroke-linecap: round;
stroke-dasharray: $circumference;
stroke-dashoffset: $circumference;
}
.hd__btn {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
margin: 0;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
border: 0;
border-radius: 50%;
background-color: $subject-ink;
color: $subject-cream;
cursor: pointer;
touch-action: none; // 길게 누르는 동안 브라우저가 스크롤로 가로채지 않게
}
.hd__btn:focus-visible { outline: 3px solid $subject-ink; outline-offset: 3px; }
.hd__ic { display: inline-flex; }
.hd__ic svg { display: block; }
// 사람이 실제로 누른 경로 — 누르는 동안 차고, 떼면 같은 길로 되감긴다
.hd:not(.is-demo) .hd__arc { transition: stroke-dashoffset $duration $easing; }
.hd:not(.is-demo).is-holding .hd__arc { stroke-dashoffset: 0; }
.hd:not(.is-demo).is-holding .hd__btn { background-color: $color; }
.hd:not(.is-demo)[data-state="done"] .hd__btn { background-color: $color; }
.hd:not(.is-demo)[data-state="done"] .hd__arc { stroke-dashoffset: 0; }
.hd__log {
position: relative;
margin: 0;
width: 160px;
height: 18px;
}
.hd__word {
position: absolute;
left: 0;
right: 0;
top: 0;
text-align: center;
font-size: 12px;
line-height: 18px;
font-weight: 700;
color: $subject-ink;
opacity: 0;
}
.hd:not(.is-demo) .hd__word[data-word="idle"] { opacity: 1; }
.hd:not(.is-demo)[data-state="done"] .hd__word[data-word="idle"] { opacity: 0; }
.hd:not(.is-demo)[data-state="done"] .hd__word[data-word="done"] { opacity: 1; }
// ── 자동 루프: 10% 에 눌러 62% 까지 한 바퀴를 그리고, 68% 에 삭제로 끊었다가 86% 에 되감는다 ──
.hd.is-demo .hd__arc {
animation-name: hd-draw;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes hd-draw {
0% { stroke-dashoffset: $circumference; }
10% { stroke-dashoffset: $circumference; }
62% { stroke-dashoffset: 0; }
84% { stroke-dashoffset: 0; }
90% { stroke-dashoffset: $circumference; }
100% { stroke-dashoffset: $circumference; }
}
.hd.is-demo .hd__btn {
animation-name: hd-press;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes hd-press {
0% { background-color: $subject-ink; transform: scale(1); }
10% { background-color: $subject-ink; transform: scale(.92); }
68% { background-color: $color; transform: scale(1.06); }
86% { background-color: $subject-ink; transform: scale(1); }
100% { background-color: $subject-ink; transform: scale(1); }
}
.hd.is-demo .hd__word {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.hd.is-demo .hd__word[data-word="idle"] { animation-name: hd-idle; }
.hd.is-demo .hd__word[data-word="done"] { animation-name: hd-done; }
@keyframes hd-idle {
0% { opacity: 1; }
68% { opacity: 0; }
86% { opacity: 1; }
100% { opacity: 1; }
}
@keyframes hd-done {
0% { opacity: 0; }
68% { opacity: 1; }
86% { opacity: 0; }
100% { opacity: 0; }
}
@media (max-width: 340px) {
.hd { gap: $sp-2; }
.hd__word { font-size: 11px; line-height: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.hd.is-demo .hd__arc,
.hd.is-demo .hd__btn,
.hd.is-demo .hd__word { animation: none !important; }
.hd:not(.is-demo) .hd__arc { transition: none !important; }
.hd.is-demo .hd__word[data-word="idle"] { opacity: 1; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.hd {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.hd__dial {
position: relative;
width: 88px;
height: 88px;
}
.hd__ring {
position: absolute;
left: 0;
top: 0;
display: block;
transform: rotate(-90deg);
}
.hd__track {
fill: none;
stroke: #fff7e6;
stroke-width: 6;
}
.hd__arc {
fill: none;
stroke: #ff4d1f;
stroke-width: 6;
stroke-linecap: round;
stroke-dasharray: 251.33;
stroke-dashoffset: 251.33;
}
.hd__btn {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
margin: 0;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
border: 0;
border-radius: 50%;
background-color: #17141a;
color: #fff7e6;
cursor: pointer;
touch-action: none;
}
.hd__btn:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.hd__ic {
display: inline-flex;
}
.hd__ic svg {
display: block;
}
.hd:not(.is-demo) .hd__arc {
transition: stroke-dashoffset 1200ms ease-out;
}
.hd:not(.is-demo).is-holding .hd__arc {
stroke-dashoffset: 0;
}
.hd:not(.is-demo).is-holding .hd__btn {
background-color: #ff4d1f;
}
.hd:not(.is-demo)[data-state=done] .hd__btn {
background-color: #ff4d1f;
}
.hd:not(.is-demo)[data-state=done] .hd__arc {
stroke-dashoffset: 0;
}
.hd__log {
position: relative;
margin: 0;
width: 160px;
height: 18px;
}
.hd__word {
position: absolute;
left: 0;
right: 0;
top: 0;
text-align: center;
font-size: 12px;
line-height: 18px;
font-weight: 700;
color: #17141a;
opacity: 0;
}
.hd:not(.is-demo) .hd__word[data-word=idle] {
opacity: 1;
}
.hd:not(.is-demo)[data-state=done] .hd__word[data-word=idle] {
opacity: 0;
}
.hd:not(.is-demo)[data-state=done] .hd__word[data-word=done] {
opacity: 1;
}
.hd.is-demo .hd__arc {
animation-name: hd-draw;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes hd-draw {
0% {
stroke-dashoffset: 251.33;
}
10% {
stroke-dashoffset: 251.33;
}
62% {
stroke-dashoffset: 0;
}
84% {
stroke-dashoffset: 0;
}
90% {
stroke-dashoffset: 251.33;
}
100% {
stroke-dashoffset: 251.33;
}
}
.hd.is-demo .hd__btn {
animation-name: hd-press;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes hd-press {
0% {
background-color: #17141a;
transform: scale(1);
}
10% {
background-color: #17141a;
transform: scale(0.92);
}
68% {
background-color: #ff4d1f;
transform: scale(1.06);
}
86% {
background-color: #17141a;
transform: scale(1);
}
100% {
background-color: #17141a;
transform: scale(1);
}
}
.hd.is-demo .hd__word {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.hd.is-demo .hd__word[data-word=idle] {
animation-name: hd-idle;
}
.hd.is-demo .hd__word[data-word=done] {
animation-name: hd-done;
}
@keyframes hd-idle {
0% {
opacity: 1;
}
68% {
opacity: 0;
}
86% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes hd-done {
0% {
opacity: 0;
}
68% {
opacity: 1;
}
86% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.hd {
gap: 8px;
}
.hd__word {
font-size: 11px;
line-height: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.hd.is-demo .hd__arc,
.hd.is-demo .hd__btn,
.hd.is-demo .hd__word {
animation: none !important;
}
.hd:not(.is-demo) .hd__arc {
transition: none !important;
}
.hd.is-demo .hd__word[data-word=idle] {
opacity: 1;
}
}// 실제 동작: 누르는 동안만 고리가 그려진다. 손을 떼면 되감기고, 한 바퀴를 다 그렸을 때만 삭제가 일어난다
var root = document.querySelector('.hd');
var btn = document.getElementById('hd-btn');
var arc = document.querySelector('.hd__arc');
var doneTimer = 0;
btn.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
root.setAttribute('data-state', 'idle');
btn.setPointerCapture(e.pointerId); // 손가락이 버튼 밖으로 나가도 취소를 여기서 받는다
root.classList.add('is-holding');
clearTimeout(doneTimer);
doneTimer = setTimeout(function () { root.setAttribute('data-state', 'done'); root.classList.remove('is-holding'); }, 1200);
});
['pointerup', 'pointercancel', 'pointerleave'].forEach(function (t) {
btn.addEventListener(t, function () { clearTimeout(doneTimer); root.classList.remove('is-holding'); });
});import { useCallback, useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./HoldToDeleteButton.module.scss";
export type HoldToDeleteButtonProps = {
label?: string;
doneLabel?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// DOM 전역 타입을 끌어오지 않으려고 필요한 모양만 여기서 선언한다
type PtrEvt = { pointerId: number; currentTarget: { setPointerCapture(id: number): void } };
export default function HoldToDeleteButton({
label = "Hold to delete",
doneLabel = "Deleted",
duration = 1200,
easing = "ease-out",
color = "#ff4d1f",
}: HoldToDeleteButtonProps) {
const [demo, setDemo] = useState(true);
const [holding, setHolding] = useState(false);
const [done, setDone] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const stop = useCallback(() => {
if (timer.current !== null) {
clearTimeout(timer.current);
timer.current = null;
}
}, []);
useEffect(() => stop, [stop]);
// 누르는 동안만 고리가 그려진다 — 한 바퀴를 다 그렸을 때만 삭제가 일어난다
function down(e: PtrEvt) {
setDemo(false);
setDone(false);
e.currentTarget.setPointerCapture(e.pointerId); // 손가락이 버튼 밖으로 나가도 취소를 여기서 받는다
setHolding(true);
stop();
timer.current = setTimeout(() => {
setDone(true);
setHolding(false);
}, duration);
}
function release() {
stop();
setHolding(false);
}
return (
<div
className={`${styles.hd} ${demo ? styles.isDemo : ""} ${holding ? styles.isHolding : ""}`}
style={vars}
data-state={done ? "done" : "idle"}
>
<div className={styles.dial}>
<svg className={styles.ring} viewBox="0 0 88 88" width="88" height="88" aria-hidden="true">
<circle className={styles.track} cx="44" cy="44" r="40" />
<circle className={styles.arc} cx="44" cy="44" r="40" />
</svg>
<button
className={styles.btn}
type="button"
aria-label={label}
onPointerDown={down}
onPointerUp={release}
onPointerCancel={release}
onPointerLeave={release}
>
<span className={styles.ic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="26" height="26">
<g fill="currentColor">
<path d="M200 56v152a8 8 0 0 1-8 8H64a8 8 0 0 1-8-8V56Z" opacity=".2" />
<path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16M96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0m48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0" />
</g>
</svg>
</span>
</button>
</div>
<p className={styles.log} role="status" aria-live="polite">
<span className={styles.word} data-word="idle">{label}</span>
<span className={styles.word} data-word="done">{doneLabel}</span>
</p>
</div>
);
}06 길게 눌러야 지워지는 삭제 — 측정 누적면적 4.224% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · css 버튼 클릭 효과 9종 — 누른 다음이 진짜다
이 데모가 속한 글
css 버튼 클릭 효과 9종 — 누른 다음이 진짜다css 버튼 클릭 효과는 누른 순간부터 결과가 나올 때까지 버튼이 스스로 모습을 바꿔 알려 주는 방식입니다. 아홉 개 모두 색만 바뀌는 흉내가 아니라 실제로 잠기고 돌고 되돌아오며, 그 사실을 ARIA 속성 한 곳에 이 글의 데모 전부 보기

