02時と分が別々に回るドラムローラー
縦に転がすと数字の列が惰性で滑ってから最も近いマスへ磁石のように吸い付き、中央の窓に入った値だけが確定として読まれます。
使いどころフォーム一行で時刻を変えるモバイルの予約・通知設定
scroll-snap-typescroll-snap-aligntranslateY

<main class="stage">
<div class="dr is-demo">
<div class="dr__box">
<span class="dr__band" aria-hidden="true"></span>
<div class="dr__col" role="slider" tabindex="0" aria-label="時のドラム" data-vt="{v}時"
aria-valuemin="1" aria-valuemax="12" aria-valuenow="9" aria-valuetext="9時">
<div class="dr__track" data-track="h">
<span class="dr__cell">01</span><span class="dr__cell">02</span><span class="dr__cell">03</span>
<span class="dr__cell">04</span><span class="dr__cell">05</span><span class="dr__cell">06</span>
<span class="dr__cell">07</span><span class="dr__cell">08</span><span class="dr__cell">09</span>
<span class="dr__cell">10</span><span class="dr__cell">11</span><span class="dr__cell">12</span>
</div>
</div>
<div class="dr__col" role="slider" tabindex="0" aria-label="分のドラム" data-vt="{v}分"
aria-valuemin="0" aria-valuemax="55" aria-valuenow="30" aria-valuetext="30分">
<div class="dr__track" data-track="m">
<span class="dr__cell">00</span><span class="dr__cell">05</span><span class="dr__cell">10</span>
<span class="dr__cell">15</span><span class="dr__cell">20</span><span class="dr__cell">25</span>
<span class="dr__cell">30</span><span class="dr__cell">35</span><span class="dr__cell">40</span>
<span class="dr__cell">45</span><span class="dr__cell">50</span><span class="dr__cell">55</span>
</div>
</div>
</div>
<p class="dr__out" data-fmt="午前{h}時{m}分">
<span class="dr__ov" data-live>午前9時30分</span>
<span class="dr__ov" data-v="2">午前11時35分</span>
<span class="dr__ov" data-v="3">午前11時45分</span>
</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-ink;
@include stage($stage-yellow);
.dr {
box-sizing: border-box;
display: grid;
justify-items: center;
gap: $sp-3;
width: min(208px, 66vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.dr__box {
--h: 40px;
position: relative;
box-sizing: border-box;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: $sp-2;
width: 100%;
height: calc(var(--h) * 3);
}
// 가운데 창 — 여기 걸린 값만 확정이다
.dr__band {
position: absolute;
left: 0;
right: 0;
top: 50%;
z-index: 1;
height: var(--h);
border-radius: $r-control;
background-color: rgba(23, 20, 26, .1);
box-shadow: inset 0 0 0 2px $color;
pointer-events: none;
transform: translateY(-50%);
}
// 스크롤 상자 자체에는 transform 을 걸지 않는다 (텔레포트 함정) — real scroll 전용
.dr__col {
position: relative;
height: 100%;
overflow-y: auto;
overscroll-behavior: contain;
scroll-snap-type: y mandatory;
scrollbar-width: none;
touch-action: pan-y;
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 30%, #000 70%, transparent 100%);
mask-image: linear-gradient(to bottom, transparent 0%, #000 30%, #000 70%, transparent 100%);
}
.dr__col::-webkit-scrollbar { display: none; }
.dr__col:focus-visible { outline: 3px solid $color; outline-offset: 2px; }
// 위아래 여백이 딱 한 칸이라 첫 칸과 끝 칸도 가운데 창까지 온다
.dr__track { padding-block: var(--h); }
.dr__cell {
display: flex;
align-items: center;
justify-content: center;
height: var(--h);
scroll-snap-align: center;
font-size: clamp(15px, 4.4vw, 19px);
line-height: 1;
font-weight: 800;
color: $color;
}
.dr__out {
position: relative;
margin: 0;
width: 100%;
height: 20px;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 20px;
font-weight: 700;
color: $color;
}
.dr__ov { position: absolute; left: 0; right: 0; top: 0; text-align: center; white-space: nowrap; opacity: 0; }
.dr__ov[data-live] { opacity: 1; }
// ── 자동 루프: 두 열이 서로 다른 속도로 구른다. transform 은 안쪽 트랙에만 ──
// mandatory 스냅은 트랙이 transform 으로 움직이면 스크롤을 따라 옮겨 움직임을 상쇄한다 — 미리보기 동안만 끈다
.dr.is-demo .dr__col { scroll-snap-type: none; }
.dr.is-demo .dr__track {
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.dr.is-demo .dr__track[data-track="h"] { animation-name: dr-h; }
.dr.is-demo .dr__track[data-track="m"] { animation-name: dr-m; }
@keyframes dr-h {
0% { transform: translateY(0); }
8% { transform: translateY(0); }
34% { transform: translateY(calc(var(--h) * -2)); }
76% { transform: translateY(calc(var(--h) * -2)); }
88% { transform: translateY(0); }
100% { transform: translateY(0); }
}
@keyframes dr-m {
0% { transform: translateY(0); }
8% { transform: translateY(0); }
34% { transform: translateY(calc(var(--h) * -1)); }
42% { transform: translateY(calc(var(--h) * -1)); }
68% { transform: translateY(calc(var(--h) * -3)); }
76% { transform: translateY(calc(var(--h) * -3)); }
88% { transform: translateY(0); }
100% { transform: translateY(0); }
}
// 문장은 구르는 도중(각 구간의 한가운데)에 한 프레임으로 갈린다 —
// 멎은 뒤에 갈리면 "이미 멈춘 드럼 + 옛 문장" 프레임이 생겨 그 장이 poster 로 뽑힌다 (실측)
.dr.is-demo .dr__ov {
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.dr.is-demo .dr__ov[data-live] { animation-name: dr-v1; }
.dr.is-demo .dr__ov[data-v="2"] { animation-name: dr-v2; }
.dr.is-demo .dr__ov[data-v="3"] { animation-name: dr-v3; }
@keyframes dr-v1 { 0% { opacity: 1; } 22% { opacity: 0; } 82% { opacity: 1; } 100% { opacity: 1; } }
@keyframes dr-v2 { 0% { opacity: 0; } 22% { opacity: 1; } 56% { opacity: 0; } 100% { opacity: 0; } }
@keyframes dr-v3 { 0% { opacity: 0; } 56% { opacity: 1; } 82% { opacity: 0; } 100% { opacity: 0; } }
@media (max-width: 340px) {
.dr { gap: $sp-2; }
.dr__box { --h: 32px; }
.dr__out { height: 18px; line-height: 18px; }
}
@media (prefers-reduced-motion: reduce) {
.dr.is-demo .dr__track,
.dr.is-demo .dr__ov { animation: none !important; }
.dr.is-demo .dr__ov[data-live] { 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);
}
.dr {
box-sizing: border-box;
display: grid;
justify-items: center;
gap: 12px;
width: min(208px, 66vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.dr__box {
--h: 40px;
position: relative;
box-sizing: border-box;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
width: 100%;
height: calc(var(--h) * 3);
}
.dr__band {
position: absolute;
left: 0;
right: 0;
top: 50%;
z-index: 1;
height: var(--h);
border-radius: 12px;
background-color: rgba(23, 20, 26, 0.1);
box-shadow: inset 0 0 0 2px #17141a;
pointer-events: none;
transform: translateY(-50%);
}
.dr__col {
position: relative;
height: 100%;
overflow-y: auto;
overscroll-behavior: contain;
scroll-snap-type: y mandatory;
scrollbar-width: none;
touch-action: pan-y;
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 30%, #000 70%, transparent 100%);
mask-image: linear-gradient(to bottom, transparent 0%, #000 30%, #000 70%, transparent 100%);
}
.dr__col::-webkit-scrollbar {
display: none;
}
.dr__col:focus-visible {
outline: 3px solid #17141a;
outline-offset: 2px;
}
.dr__track {
padding-block: var(--h);
}
.dr__cell {
display: flex;
align-items: center;
justify-content: center;
height: var(--h);
scroll-snap-align: center;
font-size: clamp(15px, 4.4vw, 19px);
line-height: 1;
font-weight: 800;
color: #17141a;
}
.dr__out {
position: relative;
margin: 0;
width: 100%;
height: 20px;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 20px;
font-weight: 700;
color: #17141a;
}
.dr__ov {
position: absolute;
left: 0;
right: 0;
top: 0;
text-align: center;
white-space: nowrap;
opacity: 0;
}
.dr__ov[data-live] {
opacity: 1;
}
.dr.is-demo .dr__col {
scroll-snap-type: none;
}
.dr.is-demo .dr__track {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.dr.is-demo .dr__track[data-track=h] {
animation-name: dr-h;
}
.dr.is-demo .dr__track[data-track=m] {
animation-name: dr-m;
}
@keyframes dr-h {
0% {
transform: translateY(0);
}
8% {
transform: translateY(0);
}
34% {
transform: translateY(calc(var(--h) * -2));
}
76% {
transform: translateY(calc(var(--h) * -2));
}
88% {
transform: translateY(0);
}
100% {
transform: translateY(0);
}
}
@keyframes dr-m {
0% {
transform: translateY(0);
}
8% {
transform: translateY(0);
}
34% {
transform: translateY(calc(var(--h) * -1));
}
42% {
transform: translateY(calc(var(--h) * -1));
}
68% {
transform: translateY(calc(var(--h) * -3));
}
76% {
transform: translateY(calc(var(--h) * -3));
}
88% {
transform: translateY(0);
}
100% {
transform: translateY(0);
}
}
.dr.is-demo .dr__ov {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.dr.is-demo .dr__ov[data-live] {
animation-name: dr-v1;
}
.dr.is-demo .dr__ov[data-v="2"] {
animation-name: dr-v2;
}
.dr.is-demo .dr__ov[data-v="3"] {
animation-name: dr-v3;
}
@keyframes dr-v1 {
0% {
opacity: 1;
}
22% {
opacity: 0;
}
82% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes dr-v2 {
0% {
opacity: 0;
}
22% {
opacity: 1;
}
56% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes dr-v3 {
0% {
opacity: 0;
}
56% {
opacity: 1;
}
82% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.dr {
gap: 8px;
}
.dr__box {
--h: 32px;
}
.dr__out {
height: 18px;
line-height: 18px;
}
}
@media (prefers-reduced-motion: reduce) {
.dr.is-demo .dr__track,
.dr.is-demo .dr__ov {
animation: none !important;
}
.dr.is-demo .dr__ov[data-live] {
opacity: 1;
}
}// 확정값은 스크롤 위치에서 읽는다. 사람이 굴리면 .is-demo 를 떼 트랙 transform 을 0 으로 되돌린다
(function () {
var dr = document.querySelector('.dr');
var cols = dr.querySelectorAll('.dr__col');
var out = dr.querySelector('.dr__out'), live = dr.querySelector('[data-live]');
var H = cols[0].querySelector('.dr__cell').getBoundingClientRect().height;
var human = false, tid = 0;
function at(c) { return Math.round(c.scrollTop / H); }
function read() {
var v = [];
for (var i = 0; i < cols.length; i++) {
var c = cols[i], el = c.querySelector('.dr__track').children[at(c)];
var t = el ? el.textContent.trim() : '00';
c.setAttribute('aria-valuenow', String(Number(t)));
c.setAttribute('aria-valuetext', c.dataset.vt.replace('{v}', String(Number(t))));
v.push(t);
}
live.textContent = out.dataset.fmt.replace('{h}', String(Number(v[0]))).replace('{m}', String(Number(v[1])));
}
function mark() { human = true; dr.classList.remove('is-demo'); }
for (var i = 0; i < cols.length; i++) (function (c) {
c.addEventListener('pointerdown', mark);
c.addEventListener('wheel', mark, { passive: true });
c.addEventListener('scroll', function () {
if (!human) return;
clearTimeout(tid); tid = setTimeout(read, 90); // 스크롤이 멎은 뒤에 확정값을 읽는다
});
c.addEventListener('keydown', function (e) {
var k = e.key, d = k === 'ArrowDown' ? 1 : k === 'ArrowUp' ? -1 : 0;
if (!d && k !== 'Home' && k !== 'End') return;
e.preventDefault(); mark();
var n = c.querySelector('.dr__track').children.length;
var j = k === 'Home' ? 0 : k === 'End' ? n - 1 : Math.min(n - 1, Math.max(0, at(c) + d));
c.scrollTo({ top: j * H, behavior: 'smooth' });
});
})(cols[i]);
cols[0].scrollTop = 8 * H; // 09
cols[1].scrollTop = 6 * H; // 30
read();
})();import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./DrumRollerPicker.module.scss";
export type DrumRollerPickerProps = {
labels?: { hour: string; minute: string; meridiem: string };
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault(): void };
const HOURS = ["01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12"];
const MINS = ["00", "05", "10", "15", "20", "25", "30", "35", "40", "45", "50", "55"];
export default function DrumRollerPicker({
labels = { hour: "Hour drum", minute: "Minute drum", meridiem: "AM" },
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#17141a",
}: DrumRollerPickerProps) {
const [demo, setDemo] = useState(true);
const [hi, setHi] = useState(8); // 09
const [mi, setMi] = useState(6); // 30
const cols = useRef<(HTMLDivElement | null)[]>([]);
const human = useRef(false);
const timer = useRef<number>(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function cell(c: HTMLDivElement) {
const s = c.querySelector("span");
return s ? s.getBoundingClientRect().height || 40 : 40;
}
function col(k: number) {
const arr = cols.current;
return arr ? arr[k] : null;
}
// 처음에 09 와 30 이 가운데 창에 오도록 자리를 잡는다
useEffect(() => {
[8, 6].forEach((i, k) => {
const c = col(k);
if (c) c.scrollTop = i * cell(c);
});
}, []);
function mark() {
human.current = true;
setDemo(false); // 사람이 굴리면 트랙 transform 도 클래스와 함께 사라진다
}
function read(k: number) {
const c = col(k);
if (!c) return;
const i = Math.round(c.scrollTop / cell(c));
if (k === 0) setHi(i);
else setMi(i);
}
function onScroll(k: number) {
if (!human.current) return;
if (timer.current !== null) clearTimeout(timer.current);
timer.current = setTimeout(() => read(k), 90); // 스크롤이 멎은 뒤에 확정값을 읽는다
}
function onKey(e: KeyEvt, k: number) {
const d = e.key === "ArrowDown" ? 1 : e.key === "ArrowUp" ? -1 : 0;
if (!d && e.key !== "Home" && e.key !== "End") return;
e.preventDefault();
mark();
const c = col(k);
if (!c) return;
const n = k === 0 ? HOURS.length : MINS.length;
const cur = k === 0 ? hi : mi;
const j = e.key === "Home" ? 0 : e.key === "End" ? n - 1 : Math.min(n - 1, Math.max(0, cur + d));
c.scrollTo({ top: j * cell(c), behavior: "smooth" });
}
const hv = HOURS[hi] || "01";
const mv = MINS[mi] || "00";
const track = (k: number, items: string[], label: string, now: number, max: number, vt: string) => (
<div
className={styles.col}
role="slider"
tabIndex={0}
aria-label={label}
aria-valuemin={k === 0 ? 1 : 0}
aria-valuemax={max}
aria-valuenow={now}
aria-valuetext={vt}
ref={(el: HTMLDivElement | null) => { const a = cols.current; if (a) a[k] = el; }}
onPointerDown={mark}
onWheel={mark}
onScroll={() => onScroll(k)}
onKeyDown={(e: KeyEvt) => onKey(e, k)}
>
<div className={styles.track} data-track={k === 0 ? "h" : "m"}>
{items.map((t) => <span key={t} className={styles.cell}>{t}</span>)}
</div>
</div>
);
return (
<div className={`${styles.wrap} ${demo ? styles.isDemo : ""}`} style={vars}>
<div className={styles.box}>
<span className={styles.band} aria-hidden="true" />
{track(0, HOURS, labels.hour, Number(hv), 12, `${Number(hv)} o'clock`)}
{track(1, MINS, labels.minute, Number(mv), 55, `${Number(mv)} minutes`)}
</div>
<p className={styles.out}>
<span className={styles.ov} data-live>{`${Number(hv)}:${mv} ${labels.meridiem}`}</span>
<span className={styles.ov} data-v="2">{`11:35 ${labels.meridiem}`}</span>
<span className={styles.ov} data-v="3">{`11:45 ${labels.meridiem}`}</span>
</p>
</div>
);
}02 時と分が別々に回るドラムローラー — 計測 累積面積 3.245% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · タイムピッカー ui 9選 — 一点から区間まで
このデモが属する記事
タイムピッカー ui 9選 — 一点から区間までタイムピッカー ui とは、何時何分という一点だけでなく、いつからいつまでという長さまで画面上で決めるための部品のことです。ダイヤル・ドラム・枠の格子から、区間バー・繰り返しルール・時差表まで、実際に動く9つを置きました。 この記事のデモをすべて見る


