02Drum roller with hours and minutes on separate tracks
Flick a column vertically and the numbers coast before snapping to the nearest slot, and only the value framed by the centre window counts as chosen.
Best forMobile booking and reminder settings that change a time inside one form row
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="Hour drum" data-vt="{v} o'clock"
aria-valuemin="1" aria-valuemax="12" aria-valuenow="9" aria-valuetext="9 o'clock">
<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="Minute drum" data-vt="{v} minutes"
aria-valuemin="0" aria-valuemax="55" aria-valuenow="30" aria-valuetext="30 minutes">
<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} AM">
<span class="dr__ov" data-live>9:30 AM</span>
<span class="dr__ov" data-v="2">11:35 AM</span>
<span class="dr__ov" data-v="3">11:45 AM</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 Drum roller with hours and minutes on separate tracks — Measured area 3.245% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · 9 time picker ui patterns, from a point to a span
The post this demo lives in
9 time picker ui patterns, from a point to a spanA time picker ui is the part of a screen where someone sets a time without typing it. These nine go past a single time o See all demos in this post


