02スライドロック解除トラック
ハンドルをトラックの端まで押し切ってはじめてロックが解け、途中で離すとバネで戻り、端に達した瞬間に鍵アイコンが開いた表示に変わります。
使いどころ誤って押せない破壊的な操作(すべて削除・解約)にもう一段の押し切りを求めたいとき
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">スライドしてロック解除</span><span class="su__t2" id="su-lit">スライドしてロック解除</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="右端までスライドしてロック解除">
<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">ロック中 — 端までスライドしてください</p>
<span hidden id="su-msg-done">ロック解除 — 1.6秒後に再度ロックします</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 スライドロック解除トラック — 計測 累積面積 3.585% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · スマホ ジェスチャー操作 9選 — 指で本当に動くUI
このデモが属する記事
スマホ ジェスチャー操作 9選 — 指で本当に動くUIスマホ ジェスチャー操作とは、画面に触れた指が動いた距離と速さを読み取って表示を変える操作方法です。9つのうち2つは指を離した瞬間にはじめて結果が分かれ、1つは最後まで押し切らないと何も起きません。 この記事のデモをすべて見る

