08Bộ chọn giờ kiểu odometer
Nhấn nút lên/xuống ở ô giờ/phút thì số hiện tại trượt ra bằng translateY, số kế tiếp trượt vào đúng chỗ đó, giống đồng hồ công-tơ-mét ô tô lật số.
Dùng choChọn giờ đặt lịch, đặt giờ báo thức
translateY 롤오버data-dir 버튼0.18초

<main class="stage">
<div class="tp is-demo">
<div class="tp__col" data-target="h">
<button type="button" class="tp__btn" data-dir="1" aria-label="Tăng giờ">+</button>
<div class="tp__win"><span class="tp__val is-cur">09</span><span class="tp__val is-next">10</span></div>
<button type="button" class="tp__btn" data-dir="-1" aria-label="Giảm giờ">−</button>
<span class="tp__unit">giờ</span>
</div>
<span class="tp__colon" aria-hidden="true">:</span>
<div class="tp__col" data-target="m">
<button type="button" class="tp__btn" data-dir="1" aria-label="Tăng phút">+</button>
<div class="tp__win"><span class="tp__val is-cur">05</span><span class="tp__val is-next">06</span></div>
<button type="button" class="tp__btn" data-dir="-1" aria-label="Giảm phút">−</button>
<span class="tp__unit">phút</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-ink;
@include stage($stage-paper);
.tp { display: flex; align-items: center; gap: $sp-2; }
.tp__col { display: flex; flex-direction: column; align-items: center; gap: $sp-1; }
.tp__btn {
width: 28px; height: 24px;
border: 0; border-radius: $r-xs;
background: rgba($color, .1);
color: $color;
font-weight: 700; font-size: 15px; font-family: inherit;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.tp__win {
position: relative;
overflow: hidden;
width: 2.4ch; height: 1.5em;
font-variant-numeric: tabular-nums;
}
.tp__val {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
font-size: 26px; font-weight: 700; font-family: inherit; color: $color;
transition: transform $duration $easing;
}
.tp__val.is-cur { transform: translateY(0); }
.tp__val.is-next { transform: translateY(100%); }
.tp__val.is-leaving { transform: translateY(-100%); }
.tp__val.is-arriving {
transform: translateY(0);
transform-origin: top;
animation: tp-arrive-jelly $duration $easing;
}
// 도착 젤리: translateY(0) 에 도착한 뒤 scaleY 로 눌림-늘림을 얹는다 (translateY 는 유지해 점프 없이)
@keyframes tp-arrive-jelly {
0% { transform: translateY(0) scaleY(1); }
50% { transform: translateY(0) scaleY(1.15); }
75% { transform: translateY(0) scaleY(.95); }
100% { transform: translateY(0) scaleY(1); }
}
.tp__unit { font-size: 12px; color: rgba($color, .72); }
.tp__colon { font-size: 22px; font-weight: 700; color: $color; align-self: flex-start; margin-top: $sp-2; }
@media (prefers-reduced-motion: reduce) {
.tp__val { transition: none; }
.tp__val.is-arriving { animation: none !important; transform: translateY(0); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tp {
display: flex;
align-items: center;
gap: 8px;
}
.tp__col {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.tp__btn {
width: 28px;
height: 24px;
border: 0;
border-radius: 4px;
background: rgba(23, 20, 26, 0.1);
color: #17141a;
font-weight: 700;
font-size: 15px;
font-family: inherit;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.tp__win {
position: relative;
overflow: hidden;
width: 2.4ch;
height: 1.5em;
font-variant-numeric: tabular-nums;
}
.tp__val {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 26px;
font-weight: 700;
font-family: inherit;
color: #17141a;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tp__val.is-cur {
transform: translateY(0);
}
.tp__val.is-next {
transform: translateY(100%);
}
.tp__val.is-leaving {
transform: translateY(-100%);
}
.tp__val.is-arriving {
transform: translateY(0);
transform-origin: top;
animation: tp-arrive-jelly 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
@keyframes tp-arrive-jelly {
0% {
transform: translateY(0) scaleY(1);
}
50% {
transform: translateY(0) scaleY(1.15);
}
75% {
transform: translateY(0) scaleY(0.95);
}
100% {
transform: translateY(0) scaleY(1);
}
}
.tp__unit {
font-size: 12px;
color: rgba(23, 20, 26, 0.72);
}
.tp__colon {
font-size: 22px;
font-weight: 700;
color: #17141a;
align-self: flex-start;
margin-top: 8px;
}
@media (prefers-reduced-motion: reduce) {
.tp__val {
transition: none;
}
.tp__val.is-arriving {
animation: none !important;
transform: translateY(0);
}
}// 실제 동작: +/- 를 누르면 현재 숫자가 밀려나가고 다음 숫자가 그 자리로 굴러 들어온다 (odometer). 23줄
var tp = document.querySelector('.tp');
function roll(col, dir) {
var win = col.querySelector('.tp__win');
var cur = win.querySelector('.is-cur'), next = win.querySelector('.is-next');
var v = (parseInt(cur.textContent, 10) + dir + 60) % 60;
next.textContent = String(v).padStart(2, '0');
cur.classList.add('is-leaving'); next.classList.add('is-arriving');
next.addEventListener('transitionend', function done() {
next.removeEventListener('transitionend', done);
cur.classList.remove('is-cur', 'is-leaving'); cur.classList.add('is-next');
cur.style.transition = 'none'; void cur.offsetWidth; cur.style.transition = '';
next.classList.remove('is-arriving'); next.classList.add('is-cur');
}, { once: true });
}
tp.querySelectorAll('.tp__btn').forEach(function (btn) {
btn.addEventListener('pointerdown', function () {
tp.classList.remove('is-demo'); clearInterval(loop);
roll(btn.closest('.tp__col'), parseInt(btn.dataset.dir, 10));
});
});
// .is-demo 자동 순환 — 두 칸을 번갈아 굴린다 (실제 클릭이 오면 멈춘다)
var cols = tp.querySelectorAll('.tp__col'), j = 0;
var loop = setInterval(function () { roll(cols[j % cols.length], 1); j++; }, 900);import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TimePickerOdometerScrub.module.scss";
export type TimePickerOdometerScrubProps = {
initialHour?: number;
initialMinute?: number;
duration?: number;
easing?: string;
digitColor?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--digit"?: string };
function pad(n: number) {
return String((n + 60) % 60).padStart(2, "0");
}
export default function TimePickerOdometerScrub({
initialHour = 9,
initialMinute = 5,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
digitColor = "#17141a",
}: TimePickerOdometerScrubProps) {
const [hour, setHour] = useState(initialHour);
const [minute, setMinute] = useState(initialMinute);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--digit": digitColor };
function Column({ value, unit, onChange, incLabel, decLabel }: { value: number; unit: string; onChange: (d: number) => void; incLabel: string; decLabel: string }) {
return (
<div className={styles.col}>
<button type="button" className={styles.btn} aria-label={incLabel} onClick={() => onChange(1)}>+</button>
<div className={styles.win} key={value}>
<span className={styles.val}>{pad(value)}</span>
</div>
<button type="button" className={styles.btn} aria-label={decLabel} onClick={() => onChange(-1)}>−</button>
<span className={styles.unit}>{unit}</span>
</div>
);
}
return (
<div className={styles.tp} style={vars}>
<Column value={hour} unit="hr" incLabel="Increase hour" decLabel="Decrease hour" onChange={(d) => setHour((h) => Number(pad(h + d)))} />
<span className={styles.colon} aria-hidden="true">:</span>
<Column value={minute} unit="min" incLabel="Increase minute" decLabel="Decrease minute" onChange={(d) => setMinute((m) => Number(pad(m + d)))} />
</div>
);
}08 Bộ chọn giờ kiểu odometer — Diện tích đo được 0.499% · khung hình chuyển động 7/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · 9 Segmented Control UI — dán là chạy
Bài viết chứa demo này
9 Segmented Control UI — dán là chạySegmented control là nút chuyển tab hình viên thuốc mà bạn hay thấy trong màn hình cài đặt iOS, chọn một trong vài lựa c Xem tất cả demo trong bài này
Nếu bạn thích hiệu ứng này - demo tương tự
Giao diện nhập liệu01 Tự chuyển sang ô kế tiếpOTP Input UI: 9 tình huống kèm validation
Giao diện đăng nhập · xác thực01 Thẻ đăng nhập kiểu kính mờ (glassmorphism)9 mẫu form đăng ký & đăng nhập copy-paste được
Giao diện danh sách · bảng01 Vuốt để xóa (reveal)9 mẫu List UI: swipe to delete css và phân trang