02Slide-to-unlock track
The handle must be pushed all the way to the end before the lock releases, springs back if released midway, and the padlock icon flips open the moment it lands.
Best forDestructive actions (delete all, cancel subscription) that need one extra deliberate push
setPointerCapturetranslateXArrowRight

<main class="stage">
<div class="su is-demo" id="su-root">
<div class="su__track" id="su-track">
<span class="su__label" aria-hidden="true"><span class="su__t1">Slide to unlock</span><span class="su__t2" id="su-lit">Slide to unlock</span></span>
<span class="su__lock" id="su-lock" aria-hidden="true">
<svg class="su__ic su__ic--closed" viewBox="0 0 256 256" width="15" height="15"><g fill="currentColor"><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 80h-32V56a48 48 0 0 0-96 0v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16M96 56a32 32 0 0 1 64 0v24H96Zm112 152H48V96h160z"/></g></svg>
<svg class="su__ic su__ic--open" viewBox="0 0 256 256" width="15" height="15"><g fill="currentColor"><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 80H96V56a32 32 0 0 1 32-32c15.37 0 29.2 11 32.16 25.59a8 8 0 0 0 15.68-3.18C171.32 24.15 151.2 8 128 8a48.05 48.05 0 0 0-48 48v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16m0 128H48V96h160z"/></g></svg>
<svg class="su__ic su__ic--done" viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
</span>
<button class="su__handle" type="button" id="su-handle" aria-label="Slide all the way right to unlock">
<svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="m216 128l-72 72V56Z" opacity=".2"/><path d="m221.66 122.34l-72-72A8 8 0 0 0 136 56v64H40a8 8 0 0 0 0 16h96v64a8 8 0 0 0 13.66 5.66l72-72a8 8 0 0 0 0-11.32M152 180.69V75.31L204.69 128Z"/></g></svg>
</button>
</div>
<p class="su__state" id="su-state" aria-live="polite">Locked — slide all the way</p>
<span hidden id="su-msg-done">Unlocked — relocks in 1.6s</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-ink;
@include stage($stage-yellow);
.su { user-select: none; -webkit-user-select: none; }
.su__track {
position: relative;
box-sizing: border-box;
width: min(248px, 84vw);
height: 48px;
background: $color;
border-radius: $r-pill;
box-shadow: $shadow-press;
}
.su__label {
position: absolute;
inset: 0;
display: flex;
align-items: center;
padding-left: 48px;
font-size: 12px; line-height: 14px; font-weight: 700;
letter-spacing: .02em;
}
.su__t1 { color: rgba(255, 247, 230, .72); } // 먹 트랙 위 흐린 크림 2.79:1 → 9.2:1
.su__t2 {
position: absolute;
left: 48px; top: 50%;
transform: translateY(-50%);
width: 36px;
overflow: hidden;
white-space: nowrap;
color: $subject-cream;
}
.su__lock {
position: absolute;
right: $sp-3;
top: 50%;
transform: translateY(-50%);
display: inline-flex;
align-items: center; justify-content: center;
width: 28px; height: 28px;
border-radius: 50%;
background: rgba(255, 247, 230, .14);
color: rgba(255, 247, 230, .6);
transition: background $duration $easing, color $duration $easing, transform $duration $easing;
}
.su__ic { position: absolute; transition: opacity $dur-quick steps(1, end), transform $duration $easing; }
.su__ic--open, .su__ic--done { opacity: 0; }
.su__track.is-done .su__lock { background: $stage-yellow; color: $color; transform: translateY(-50%) scale(1.12); }
.su__track.is-done .su__ic--closed { opacity: 0; }
.su__track.is-done .su__ic--open { opacity: 0; }
.su__track.is-done .su__ic--done { opacity: 1; }
.su__handle {
position: absolute;
left: $sp-2;
top: 50%;
transform: translateY(-50%) translateX(0);
display: inline-flex; align-items: center; justify-content: center;
width: 36px; height: 36px;
border: 0;
background: $subject-cream;
color: $color;
border-radius: 50%;
box-shadow: 0 2px 6px rgba(23, 20, 26, .35);
cursor: grab;
touch-action: none;
transition: transform $duration $easing, box-shadow $duration $easing;
}
.su__handle.is-drag { box-shadow: 0 4px 10px rgba(23, 20, 26, .45); }
.su__handle:active { cursor: grabbing; }
.su__handle:focus-visible { outline: 3px solid rgba(23, 20, 26, .55); outline-offset: 2px; }
.su__state {
margin: $sp-3 0 0;
font-size: 11px; line-height: 14px; font-weight: 600;
color: rgba(23, 20, 26, .72);
}
// ── 자동 루프: 밀기 → 잠금 해제 → 유지 → 페이드아웃(보이지 않을 때 초기화) → 복귀 ──
.su.is-demo .su__handle { animation: su-slide $dur-loop $easing infinite; }
@keyframes su-slide {
0% { transform: translateY(-50%) translateX(0); }
34% { transform: translateY(-50%) translateX(204px); }
38% { transform: translateY(-50%) translateX(204px) scale(1.1, .9); }
42%, 66% { transform: translateY(-50%) translateX(204px); }
72% { transform: translateY(-50%) translateX(204px); opacity: 1; }
78% { transform: translateY(-50%) translateX(204px); opacity: 0; }
79% { transform: translateY(-50%) translateX(0); opacity: 0; }
88%, 100% { transform: translateY(-50%) translateX(0); opacity: 1; }
}
.su.is-demo .su__t2 { animation: su-lit $dur-loop linear infinite; }
@keyframes su-lit {
0% { width: 36px; }
34%, 78% { width: 240px; }
79% { width: 36px; }
100% { width: 36px; }
}
.su.is-demo .su__lock { animation: su-lock $dur-loop steps(1, end) infinite; }
@keyframes su-lock {
0%, 41% { background: rgba(255, 247, 230, .14); color: rgba(255, 247, 230, .6); }
42%, 72% { background: $stage-yellow; color: $subject-ink; }
73%, 100% { background: rgba(255, 247, 230, .14); color: rgba(255, 247, 230, .6); }
}
@media (prefers-reduced-motion: reduce) {
.su.is-demo .su__handle,
.su.is-demo .su__t2,
.su.is-demo .su__lock { animation: none !important; }
.su__handle, .su__lock { transition: none; }
}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);
}
.su {
user-select: none;
-webkit-user-select: none;
}
.su__track {
position: relative;
box-sizing: border-box;
width: min(248px, 84vw);
height: 48px;
background: #17141a;
border-radius: 999px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.su__label {
position: absolute;
inset: 0;
display: flex;
align-items: center;
padding-left: 48px;
font-size: 12px;
line-height: 14px;
font-weight: 700;
letter-spacing: 0.02em;
}
.su__t1 {
color: rgba(255, 247, 230, 0.72);
}
.su__t2 {
position: absolute;
left: 48px;
top: 50%;
transform: translateY(-50%);
width: 36px;
overflow: hidden;
white-space: nowrap;
color: #fff7e6;
}
.su__lock {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: rgba(255, 247, 230, 0.14);
color: rgba(255, 247, 230, 0.6);
transition: background 300ms cubic-bezier(0.2, 0.8, 0.2, 1), color 300ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.su__ic {
position: absolute;
transition: opacity 220ms steps(1, end), transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.su__ic--open, .su__ic--done {
opacity: 0;
}
.su__track.is-done .su__lock {
background: #ffd23f;
color: #17141a;
transform: translateY(-50%) scale(1.12);
}
.su__track.is-done .su__ic--closed {
opacity: 0;
}
.su__track.is-done .su__ic--open {
opacity: 0;
}
.su__track.is-done .su__ic--done {
opacity: 1;
}
.su__handle {
position: absolute;
left: 8px;
top: 50%;
transform: translateY(-50%) translateX(0);
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: 0;
background: #fff7e6;
color: #17141a;
border-radius: 50%;
box-shadow: 0 2px 6px rgba(23, 20, 26, 0.35);
cursor: grab;
touch-action: none;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.su__handle.is-drag {
box-shadow: 0 4px 10px rgba(23, 20, 26, 0.45);
}
.su__handle:active {
cursor: grabbing;
}
.su__handle:focus-visible {
outline: 3px solid rgba(23, 20, 26, 0.55);
outline-offset: 2px;
}
.su__state {
margin: 12px 0 0;
font-size: 11px;
line-height: 14px;
font-weight: 600;
color: rgba(23, 20, 26, 0.72);
}
.su.is-demo .su__handle {
animation: su-slide 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes su-slide {
0% {
transform: translateY(-50%) translateX(0);
}
34% {
transform: translateY(-50%) translateX(204px);
}
38% {
transform: translateY(-50%) translateX(204px) scale(1.1, 0.9);
}
42%, 66% {
transform: translateY(-50%) translateX(204px);
}
72% {
transform: translateY(-50%) translateX(204px);
opacity: 1;
}
78% {
transform: translateY(-50%) translateX(204px);
opacity: 0;
}
79% {
transform: translateY(-50%) translateX(0);
opacity: 0;
}
88%, 100% {
transform: translateY(-50%) translateX(0);
opacity: 1;
}
}
.su.is-demo .su__t2 {
animation: su-lit 2s linear infinite;
}
@keyframes su-lit {
0% {
width: 36px;
}
34%, 78% {
width: 240px;
}
79% {
width: 36px;
}
100% {
width: 36px;
}
}
.su.is-demo .su__lock {
animation: su-lock 2s steps(1, end) infinite;
}
@keyframes su-lock {
0%, 41% {
background: rgba(255, 247, 230, 0.14);
color: rgba(255, 247, 230, 0.6);
}
42%, 72% {
background: #ffd23f;
color: #17141a;
}
73%, 100% {
background: rgba(255, 247, 230, 0.14);
color: rgba(255, 247, 230, 0.6);
}
}
@media (prefers-reduced-motion: reduce) {
.su.is-demo .su__handle,
.su.is-demo .su__t2,
.su.is-demo .su__lock {
animation: none !important;
}
.su__handle, .su__lock {
transition: none;
}
}// 실제 동작: 손잡이를 끝까지 밀면 잠금 해제, 중간에 떼면 스프링으로 복귀. 화살표 키·Enter 도 같은 검사를 밟는다.
var root = document.getElementById('su-root');
var track = document.getElementById('su-track');
var handle = document.getElementById('su-handle');
var lit = document.getElementById('su-lit');
var state = document.getElementById('su-state');
var MAX, x = 0, dragging = false, sx = 0, x0 = 0, done = false;
var MSG_DONE = document.getElementById('su-msg-done').textContent;
var MSG_LOCKED = state.textContent;
function end() { MAX = track.clientWidth - handle.clientWidth - 8; }
end();
window.addEventListener('resize', end);
function paint(px) {
x = Math.max(0, Math.min(px, MAX));
handle.style.transform = 'translateX(' + x + 'px)';
handle.classList.toggle('is-drag', x > 0 && x < MAX);
lit.style.width = (x + 36) + 'px';
if (x >= MAX - 1 && !done) unlock();
}
function unlock() {
done = true;
track.classList.add('is-done');
state.textContent = MSG_DONE;
setTimeout(relock, 1600);
}
function relock() {
done = false;
track.classList.remove('is-done');
handle.style.transition = 'none';
paint(0);
void handle.offsetWidth;
handle.style.transition = '';
state.textContent = MSG_LOCKED;
}
handle.addEventListener('pointerdown', function (e) {
if (done) return;
root.classList.remove('is-demo');
dragging = true; sx = e.clientX; x0 = x;
handle.setPointerCapture(e.pointerId);
handle.style.transition = 'none';
e.preventDefault();
});
handle.addEventListener('pointermove', function (e) {
if (!dragging) return;
paint(x0 + (e.clientX - sx));
});
function drop() {
if (!dragging) return;
dragging = false;
handle.style.transition = '';
if (!done) paint(0);
}
handle.addEventListener('pointerup', drop);
handle.addEventListener('pointercancel', drop);
handle.addEventListener('keydown', function (e) {
if (done) return;
if (e.key === 'ArrowRight') { root.classList.remove('is-demo'); handle.style.transition = 'none'; paint(x + 12); window.setTimeout(function () { handle.style.transition = ''; }, 60); e.preventDefault(); }
else if (e.key === 'Enter' && x >= MAX - 14) { root.classList.remove('is-demo'); paint(MAX); e.preventDefault(); }
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SlideUnlock.module.scss";
export type SlideUnlockProps = {
label?: string;
doneLabel?: string;
duration?: number;
easing?: string;
color?: string;
};
type PEvt = { clientX: number; clientY: number; pointerId: number; preventDefault(): void; currentTarget: { setPointerCapture(id: number): void } };
type KeyEvt = { key: string; preventDefault(): void };
type Box<T> = { current: T };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const IconArrow = () => (
<svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="m216 128l-72 72V56Z" opacity=".2"/><path d="m221.66 122.34l-72-72A8 8 0 0 0 136 56v64H40a8 8 0 0 0 0 16h96v64a8 8 0 0 0 13.66 5.66l72-72a8 8 0 0 0 0-11.32M152 180.69V75.31L204.69 128Z"/></g></svg>
);
const IconDone = () => (
<svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
);
const IconLock = () => (
<svg viewBox="0 0 256 256" width="15" height="15"><g fill="currentColor"><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 80h-32V56a48 48 0 0 0-96 0v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16M96 56a32 32 0 0 1 64 0v24H96Zm112 152H48V96h160z"/></g></svg>
);
export default function SlideUnlock({
label = "Slide to unlock",
doneLabel = "Unlocked — relocks in 1.6s",
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#17141a",
}: SlideUnlockProps) {
const trackRef = useRef<HTMLDivElement>(null);
const handleRef = useRef<HTMLButtonElement>(null);
const [x, setX] = useState(0);
const [done, setDone] = useState(false);
const drag = useRef({ on: false, sx: 0, x0: 0 }) as Box<{ on: boolean; sx: number; x0: number }>;
const maxRef = useRef(204) as Box<number>;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
function measure() {
const t = trackRef.current, h = handleRef.current;
if (t && h) maxRef.current = t.clientWidth - h.clientWidth - 8;
}
measure();
window.addEventListener("resize", measure);
return () => window.removeEventListener("resize", measure);
}, []);
useEffect(() => {
if (!done) return;
const t = window.setTimeout(() => { setDone(false); setX(0); }, 1600);
return () => window.clearTimeout(t);
}, [done]);
function paint(px: number) {
const max = maxRef.current;
const nx = Math.max(0, Math.min(px, max));
setX(nx);
if (nx >= max - 1 && !done) setDone(true);
}
function onDown(e: PEvt) {
if (done) return;
drag.current = { on: true, sx: e.clientX, x0: x };
e.currentTarget.setPointerCapture(e.pointerId);
e.preventDefault();
}
function onMove(e: PEvt) {
if (!drag.current.on) return;
paint(drag.current.x0 + (e.clientX - drag.current.sx));
}
function onUp() { drag.current.on = false; if (!done) paint(0); }
function onKey(e: KeyEvt) {
if (done) return;
if (e.key === "ArrowRight") { paint(x + 12); e.preventDefault(); }
else if (e.key === "Enter" && x >= maxRef.current - 14) { paint(maxRef.current); e.preventDefault(); }
}
return (
<div className={styles.su} style={vars}>
<div ref={trackRef} className={styles.track}>
<span className={styles.t1} aria-hidden="true">{label}</span>
<span className={styles.t2} aria-hidden="true" style={{ width: x + 36 }}>{label}</span>
<span className={done ? styles.lockDone : styles.lock} aria-hidden="true">
{done ? <IconDone /> : <IconLock />}
</span>
<button
ref={handleRef}
type="button"
className={styles.handle}
aria-label="Slide all the way right to unlock"
style={{ transform: `translate(-50%, -50%) translateX(${8 + x}px)` }}
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
onKeyDown={onKey}
>
<IconArrow />
</button>
</div>
<p className={styles.state} aria-live="polite">{done ? doneLabel : `${label} — hold and push right`}</p>
</div>
);
}02 Slide-to-unlock track — Measured area 3.585% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · 9 Mobile Touch Gestures That Follow Your Finger
The post this demo lives in
9 Mobile Touch Gestures That Follow Your FingerMobile touch gestures turn the distance and speed of a finger on glass into a change on screen. Nine of them are below, See all demos in this post

