07指を離した速度で残りを決めるドラッグスライダー
pointerdown からの移動量が指にそのまま追従し、setPointerCapture でポインタを掴んだまま、離した瞬間の速度が次へ送るか元へ戻すかを決めます。
使いどころモバイルでの写真送り、タッチ端末の商品画像
pointerdownsetPointerCapturetranslate3d

<main class="stage">
<section class="dg is-demo" id="dg" role="region" aria-roledescription="carousel" aria-label="ドラッグで送る写真スライダー" tabindex="0">
<header class="dg__bar">
<span class="dg__title">ドラッグで送る</span>
<span class="dg__speed" id="speed">0.00 px/ms</span>
</header>
<div class="dg__frame" id="frame">
<div class="dg__track" id="track">
<article class="dg__slide" role="group" aria-roledescription="slide" aria-label="4枚中1枚目">
<span class="dg__ph dg__ph--a" role="img" aria-label="夜明けの埠頭のコンテナ"></span>
<span class="dg__cap" aria-hidden="true">夜明けの埠頭のコンテナ</span>
</article>
<article class="dg__slide" role="group" aria-roledescription="slide" aria-label="4枚中2枚目">
<span class="dg__ph dg__ph--e" role="img" aria-label="真昼のプールの水面"></span>
<span class="dg__cap" aria-hidden="true">真昼のプールの水面</span>
</article>
<article class="dg__slide" role="group" aria-roledescription="slide" aria-label="4枚中3枚目">
<span class="dg__ph dg__ph--c" role="img" aria-label="雨上がりの街の路地"></span>
<span class="dg__cap" aria-hidden="true">雨上がりの街の路地</span>
</article>
<article class="dg__slide" role="group" aria-roledescription="slide" aria-label="4枚中4枚目">
<span class="dg__ph dg__ph--d" role="img" aria-label="雪をかぶった白樺林"></span>
<span class="dg__cap" aria-hidden="true">雪をかぶった白樺林</span>
</article>
</div>
</div>
<nav class="dg__dots" aria-label="写真を選ぶ">
<button class="dg__dot" type="button" aria-current="true" aria-label="1枚目の写真を表示"></button>
<button class="dg__dot" type="button" aria-current="false" aria-label="2枚目の写真を表示"></button>
<button class="dg__dot" type="button" aria-current="false" aria-label="3枚目の写真を表示"></button>
<button class="dg__dot" type="button" aria-current="false" aria-label="4枚目の写真を表示"></button>
</nav>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 손을 뗀 속도로 남은 거리를 정하는 드래그 슬라이더.
// 트랙은 슬라이드를 한 줄로 이어 붙이고 translate3d 로만 움직인다 — 끄는 동안에는 JS 가
// transition 을 꺼서 손가락 위치가 곧 트랙 위치가 되고, 놓는 순간 다시 켜서 남은 거리를 잇는다.
// touch-action: pan-y 를 트랙에 걸어야 가로로 끌 때도 페이지 세로 스크롤이 살아 있다.
// 사진 자리는 FLUX 이미지가 들어올 자리다 (run/082/_이미지후보.07.json) — 지금은 토큰 그라디언트 합성.
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-blue-deep;
@include stage($stage-orange);
// 장면 하나 = 빛 + 아래로 깔리는 그늘 + 결 + 바탕 네 겹. 색만 바꿔 장면을 가른다
@mixin scene($top, $mid, $low, $angle, $sunx, $suny) {
background:
radial-gradient(40% 32% at $sunx $suny, rgba(255, 255, 255, .68), rgba(255, 255, 255, 0) 72%),
repeating-linear-gradient($angle, rgba(255, 255, 255, .07) 0 5px, rgba(255, 255, 255, 0) 5px 13px),
linear-gradient(180deg, rgba(255, 255, 255, 0) 44%, rgba($stage-ink, .46) 100%),
linear-gradient($angle, $top 0%, $mid 54%, $low 100%);
}
.dg {
box-sizing: border-box;
display: flex; flex-direction: column; gap: $sp-2;
width: min(100%, 320px);
}
.dg:focus-visible { outline: 2px solid $subject-ink; outline-offset: 4px; border-radius: $r-card; }
.dg__bar { display: flex; align-items: center; gap: $sp-2; }
.dg__title {
flex: 1 1 auto; color: $subject-ink;
font-weight: 700; font-size: 13px; line-height: 1.3;
}
.dg__speed {
flex: 0 0 auto; padding: $sp-1 $sp-2; border-radius: $r-pill;
background: $stage-ink; color: $subject-cream;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-weight: 700; font-size: 11px; line-height: 1.3;
}
.dg__frame {
overflow: hidden; border-radius: $r-card;
box-shadow: $shadow-raised;
}
.dg__track {
display: flex;
touch-action: pan-y; // 가로는 우리가 받고 세로 스크롤은 브라우저에 남긴다
cursor: grab;
transform: translate3d(0, 0, 0); // JS 가 처음 놓는 값과 같은 항등 변환 — 첫 칠에 헛 transition 이 안 생긴다
transition: transform $duration $easing;
will-change: transform;
}
.dg__track:active { cursor: grabbing; }
.dg__slide { position: relative; flex: 0 0 100%; }
.dg__ph {
display: block; width: 100%; aspect-ratio: 16 / 9;
-webkit-user-drag: none; user-select: none;
}
.dg__ph--a { @include scene($subject-blue-deep, $subject-sky, $stage-ink, 196deg, 74%, 22%); }
// 무대가 주황이라 주황 계열 장면(사막 노을)은 07 에 쓰지 않는다 — 사진과 무대가 붙어 보인다
.dg__ph--e { @include scene($subject-mint, $subject-sky, $chart-1, 160deg, 66%, 34%); }
.dg__ph--c { @include scene($stage-ink, $subject-blue-light, $tick-panel, 208deg, 58%, 18%); }
.dg__ph--d { @include scene($subject-sky, $subject-cream, $chart-3, 184deg, 44%, 30%); }
.dg__cap {
position: absolute; left: $sp-2; bottom: $sp-2;
max-width: calc(100% - #{$sp-4});
padding: $sp-1 $sp-2; border-radius: $r-sm;
background: $color; color: $subject-cream;
font-weight: 700; font-size: 11px; line-height: 1.3;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.dg__dots { display: flex; justify-content: center; gap: $sp-2; }
.dg__dot {
flex: 0 0 auto; width: 8px; height: 8px; padding: 0;
border: 0; border-radius: 50%; background: rgba($subject-ink, .42);
cursor: pointer; transform-origin: 50% 50%;
transition: transform $dur-quick $easing, background $dur-quick $easing;
}
.dg__dot[aria-current="true"] { background: $subject-ink; transform: scale(1.5); }
.dg__dot:focus-visible { outline: 2px solid $subject-ink; outline-offset: 3px; }
// 좁은 화면 — 프레임을 좁혀 16:9 사진 높이를 무대 안에 넣는다 (320×200 에서 세로 넘침 0)
@media (max-width: 340px) {
.dg { width: min(100%, 216px); gap: $sp-1; }
.dg__title { font-size: 11px; }
.dg__speed { font-size: 10px; padding: 0 $sp-1; }
.dg__cap { font-size: 10px; left: $sp-1; bottom: $sp-1; }
.dg__dots { gap: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.dg__track, .dg__dot { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.dg {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 8px;
width: min(100%, 320px);
}
.dg:focus-visible {
outline: 2px solid #17141a;
outline-offset: 4px;
border-radius: 16px;
}
.dg__bar {
display: flex;
align-items: center;
gap: 8px;
}
.dg__title {
flex: 1 1 auto;
color: #17141a;
font-weight: 700;
font-size: 13px;
line-height: 1.3;
}
.dg__speed {
flex: 0 0 auto;
padding: 4px 8px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-weight: 700;
font-size: 11px;
line-height: 1.3;
}
.dg__frame {
overflow: hidden;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.dg__track {
display: flex;
touch-action: pan-y;
cursor: grab;
transform: translate3d(0, 0, 0);
transition: transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
will-change: transform;
}
.dg__track:active {
cursor: grabbing;
}
.dg__slide {
position: relative;
flex: 0 0 100%;
}
.dg__ph {
display: block;
width: 100%;
aspect-ratio: 16/9;
-webkit-user-drag: none;
user-select: none;
}
.dg__ph--a {
background: radial-gradient(40% 32% at 74% 22%, rgba(255, 255, 255, 0.68), rgba(255, 255, 255, 0) 72%), repeating-linear-gradient(196deg, rgba(255, 255, 255, 0.07) 0 5px, rgba(255, 255, 255, 0) 5px 13px), linear-gradient(180deg, rgba(255, 255, 255, 0) 44%, rgba(23, 20, 26, 0.46) 100%), linear-gradient(196deg, #0a4fe8 0%, #6ec8ff 54%, #17141a 100%);
}
.dg__ph--e {
background: radial-gradient(40% 32% at 66% 34%, rgba(255, 255, 255, 0.68), rgba(255, 255, 255, 0) 72%), repeating-linear-gradient(160deg, rgba(255, 255, 255, 0.07) 0 5px, rgba(255, 255, 255, 0) 5px 13px), linear-gradient(180deg, rgba(255, 255, 255, 0) 44%, rgba(23, 20, 26, 0.46) 100%), linear-gradient(160deg, #4cd4a6 0%, #6ec8ff 54%, #2f6df6 100%);
}
.dg__ph--c {
background: radial-gradient(40% 32% at 58% 18%, rgba(255, 255, 255, 0.68), rgba(255, 255, 255, 0) 72%), repeating-linear-gradient(208deg, rgba(255, 255, 255, 0.07) 0 5px, rgba(255, 255, 255, 0) 5px 13px), linear-gradient(180deg, rgba(255, 255, 255, 0) 44%, rgba(23, 20, 26, 0.46) 100%), linear-gradient(208deg, #17141a 0%, #5b8cf8 54%, #231f28 100%);
}
.dg__ph--d {
background: radial-gradient(40% 32% at 44% 30%, rgba(255, 255, 255, 0.68), rgba(255, 255, 255, 0) 72%), repeating-linear-gradient(184deg, rgba(255, 255, 255, 0.07) 0 5px, rgba(255, 255, 255, 0) 5px 13px), linear-gradient(180deg, rgba(255, 255, 255, 0) 44%, rgba(23, 20, 26, 0.46) 100%), linear-gradient(184deg, #6ec8ff 0%, #fff7e6 54%, #2b9d74 100%);
}
.dg__cap {
position: absolute;
left: 8px;
bottom: 8px;
max-width: calc(100% - 16px);
padding: 4px 8px;
border-radius: 8px;
background: #0a4fe8;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dg__dots {
display: flex;
justify-content: center;
gap: 8px;
}
.dg__dot {
flex: 0 0 auto;
width: 8px;
height: 8px;
padding: 0;
border: 0;
border-radius: 50%;
background: rgba(23, 20, 26, 0.42);
cursor: pointer;
transform-origin: 50% 50%;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), background 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.dg__dot[aria-current=true] {
background: #17141a;
transform: scale(1.5);
}
.dg__dot:focus-visible {
outline: 2px solid #17141a;
outline-offset: 3px;
}
@media (max-width: 340px) {
.dg {
width: min(100%, 216px);
gap: 4px;
}
.dg__title {
font-size: 11px;
}
.dg__speed {
font-size: 10px;
padding: 0 4px;
}
.dg__cap {
font-size: 10px;
left: 4px;
bottom: 4px;
}
.dg__dots {
gap: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.dg__track, .dg__dot {
transition: none !important;
}
}var root = document.getElementById('dg');
var frame = document.getElementById('frame');
var track = document.getElementById('track');
var speed = document.getElementById('speed');
var slides = [].slice.call(track.children);
var dots = [].slice.call(document.querySelectorAll('.dg__dot'));
var index = 0, dragging = false, startX = 0, lastX = 0, lastT = 0, velocity = 0, width = 0, timer = 0;
function measure() { width = frame.clientWidth; }
// 한 칸 = 프레임 폭. 끄는 동안은 transition 을 꺼서 손가락이 곧 트랙 위치가 된다
function place(offset, animate) {
track.style.transition = animate ? '' : 'none';
track.style.transform = 'translate3d(' + (offset - index * width) + 'px, 0, 0)';
}
function show(next, animate) {
index = Math.max(0, Math.min(slides.length - 1, next));
place(0, animate);
dots.forEach(function (dot, i) { dot.setAttribute('aria-current', i === index ? 'true' : 'false'); });
}
function stopDemo() { root.classList.remove('is-demo'); clearInterval(timer); }
function onDown(e) {
stopDemo(); measure();
dragging = true; startX = e.clientX; lastX = e.clientX; lastT = e.timeStamp; velocity = 0;
track.setPointerCapture(e.pointerId);
}
function onMove(e) {
if (!dragging) { return; }
var dt = e.timeStamp - lastT;
if (dt > 0) { velocity = (e.clientX - lastX) / dt; }
lastX = e.clientX; lastT = e.timeStamp;
speed.textContent = Math.abs(velocity).toFixed(2) + ' px/ms';
place(e.clientX - startX, false);
}
// 놓는 순간의 속도(px/ms)가 먼저고, 느리게 끌었을 땐 이동 거리(28%)가 두 번째 기준이다
function onUp(e) {
if (!dragging) { return; }
dragging = false;
if (track.hasPointerCapture(e.pointerId)) { track.releasePointerCapture(e.pointerId); }
var moved = e.clientX - startX;
var carry = Math.abs(velocity) > 0.4 || Math.abs(moved) > width * 0.28;
show(index + (carry && moved < 0 ? 1 : 0) - (carry && moved > 0 ? 1 : 0), true);
}
track.addEventListener('pointerdown', onDown);
track.addEventListener('pointermove', onMove);
track.addEventListener('pointerup', onUp); track.addEventListener('pointercancel', onUp);
dots.forEach(function (dot, i) { dot.addEventListener('click', function () { stopDemo(); measure(); show(i, true); }); });
root.addEventListener('keydown', function (e) {
if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') { return; }
stopDemo(); measure(); show(index + (e.key === 'ArrowRight' ? 1 : -1), true);
});
window.addEventListener('resize', function () { measure(); place(0, false); });
measure(); show(0, false);
if (root.classList.contains('is-demo')) {
timer = setInterval(function () {
measure();
var last = index === slides.length - 1;
speed.textContent = (last ? 0.52 : 0.84).toFixed(2) + ' px/ms';
place(last ? 64 : -64, true);
setTimeout(function () { show(last ? 0 : index + 1, true); }, 180);
}, 760);
}// DragInertiaSlider — vanilla 판(run/082/demo/07-slider-drag-swipe-inertia/index.html) 의 React 포트.
// pointerdown 부터의 이동량이 트랙을 그대로 따라오고, setPointerCapture 로 포인터를 붙잡아 둔 채
// 놓는 순간의 속도(px/ms)와 이동 거리가 다음 장으로 넘길지 제자리로 돌릴지를 정한다.
// 판정 기준(0.4 px/ms · 폭의 28%)과 미리보기 주기(760ms · 180ms)는 vanilla 와 같은 값이다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./DragInertiaSlider.module.scss";
export type DragInertiaSliderProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef<T> 는 T | null 을 돌려준다 — ambient 타입에 맞춰 상자 타입을 따로 둔다
type Box<T> = { current: T | null };
type DragState = { on: boolean; startX: number; lastX: number; lastT: number; velocity: number; width: number };
const REGION_LABEL = "드래그로 넘기는 사진 슬라이더";
const TITLE = "끌어서 넘기기";
const DOTS_LABEL = "사진 고르기";
const PHOTOS = [
{ tone: "a", alt: "새벽 부두의 컨테이너", slide: "4장 중 1장", dot: "1번째 사진 보기" },
{ tone: "e", alt: "한낮의 수영장 물결", slide: "4장 중 2장", dot: "2번째 사진 보기" },
{ tone: "c", alt: "비 온 뒤 도심 골목", slide: "4장 중 3장", dot: "3번째 사진 보기" },
{ tone: "d", alt: "눈 덮인 자작나무 숲", slide: "4장 중 4장", dot: "4번째 사진 보기" },
];
export default function DragInertiaSlider(props: DragInertiaSliderProps) {
const duration = props.duration ?? 600;
const easing = props.easing ?? "cubic-bezier(.2, .8, .2, 1)";
const color = props.color ?? "#0a4fe8";
const [index, setIndex] = useState<number>(0);
const [offset, setOffset] = useState<number>(0);
const [animate, setAnimate] = useState<boolean>(true);
const [speed, setSpeed] = useState<string>("0.00");
const [preview, setPreview] = useState<boolean>(true);
const [width, setWidth] = useState<number>(0);
const frameRef = useRef<HTMLDivElement>(null) as Box<HTMLDivElement>;
const indexRef = useRef<number>(0) as Box<number>;
const dragRef = useRef<DragState>({ on: false, startX: 0, lastX: 0, lastT: 0, velocity: 0, width: 0 }) as Box<DragState>;
useEffect(() => {
indexRef.current = index;
}, [index]);
useEffect(() => {
const measure = () => {
const frame = frameRef.current;
if (frame) {
setWidth(frame.clientWidth);
}
};
measure();
window.addEventListener("resize", measure);
return () => {
window.removeEventListener("resize", measure);
};
}, []);
// 미리보기 — 한 번 끌었다 놓는 손짓을 흉내 낸다. 먼저 64px 당기고 180ms 뒤에 남은 거리를 잇는다
useEffect(() => {
if (!preview) {
return;
}
const id = window.setInterval(() => {
const at = indexRef.current ?? 0;
const last = at === PHOTOS.length - 1;
setSpeed(last ? "0.52" : "0.84");
setAnimate(true);
setOffset(last ? 64 : -64);
window.setTimeout(() => {
setOffset(0);
setIndex(last ? 0 : at + 1);
}, 180);
}, 760);
return () => {
window.clearInterval(id);
};
}, [preview]);
const go = (next: number) => {
setAnimate(true);
setOffset(0);
setIndex(Math.max(0, Math.min(PHOTOS.length - 1, next)));
};
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
const frame = frameRef.current;
const drag = dragRef.current;
if (!frame || !drag) {
return;
}
setPreview(false);
setAnimate(false);
setWidth(frame.clientWidth);
drag.on = true;
drag.startX = e.clientX;
drag.lastX = e.clientX;
drag.lastT = e.timeStamp;
drag.velocity = 0;
drag.width = frame.clientWidth;
e.currentTarget.setPointerCapture(e.pointerId);
};
const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (!drag || !drag.on) {
return;
}
const dt = e.timeStamp - drag.lastT;
if (dt > 0) {
drag.velocity = (e.clientX - drag.lastX) / dt;
}
drag.lastX = e.clientX;
drag.lastT = e.timeStamp;
setSpeed(Math.abs(drag.velocity).toFixed(2));
setOffset(e.clientX - drag.startX);
};
// 놓는 순간의 속도가 먼저고, 느리게 끌었을 땐 이동 거리(28%)가 두 번째 기준이다
const onPointerUp = (e: PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (!drag || !drag.on) {
return;
}
drag.on = false;
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
const moved = e.clientX - drag.startX;
const carry = Math.abs(drag.velocity) > 0.4 || Math.abs(moved) > drag.width * 0.28;
go((indexRef.current ?? 0) + (carry && moved < 0 ? 1 : 0) - (carry && moved > 0 ? 1 : 0));
};
const vars: Vars = { "--duration": duration + "ms", "--easing": easing, "--color": color };
const shift = offset - index * width;
return (
<main className={styles.stage}>
<section
className={styles.root}
style={vars}
role="region"
aria-roledescription="carousel"
aria-label={REGION_LABEL}
tabIndex={0}
onKeyDown={(e: { key: string }) => {
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") {
return;
}
setPreview(false);
go(index + (e.key === "ArrowRight" ? 1 : -1));
}}
>
<header className={styles.bar}>
<span className={styles.title}>{TITLE}</span>
<span className={styles.speed}>{speed} px/ms</span>
</header>
<div className={styles.frame} ref={frameRef}>
<div
className={styles.track}
style={{ transform: "translate3d(" + shift + "px, 0, 0)", transition: animate ? "" : "none" }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
{PHOTOS.map((photo) => (
<article
className={styles.slide}
key={photo.tone}
role="group"
aria-roledescription="slide"
aria-label={photo.slide}
>
<span className={styles.ph + " " + styles["ph--" + photo.tone]} role="img" aria-label={photo.alt} />
<span className={styles.cap} aria-hidden="true">{photo.alt}</span>
</article>
))}
</div>
</div>
<nav className={styles.dots} aria-label={DOTS_LABEL}>
{PHOTOS.map((photo, i) => (
<button
className={styles.dot}
key={photo.tone}
type="button"
aria-current={i === index ? "true" : "false"}
aria-label={photo.dot}
onClick={() => {
setPreview(false);
go(i);
}}
/>
))}
</nav>
</section>
</main>
);
}07 指を離した速度で残りを決めるドラッグスライダー — 計測 累積面積 40.266% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · 画像 スライダー css 9種 — GSAPなしでレスポンシブ対応
このデモが属する記事
画像 スライダー css 9種 — GSAPなしでレスポンシブ対応画像 スライダー css とは、写真の枠を1列に並べて矢印・ドット・指で1枚ずつ見せる部品のことです。GSAPなしで9つ組んであり、JavaScript が扱うのはインデックスの数字ひとつだけ、距離と色は calc() と transiti この記事のデモをすべて見る


