시간 선택 ui 9종 — 시각 하나가 아니라 구간까지
시간 선택 ui 는 사용자가 몇 시 몇 분을 고르게 하는 화면 부품입니다. 여기 모은 아홉 개는 시각 한 점을 찍는 데서 그치지 않고, 언제부터 언제까지인지, 얼마나 걸리는지까지 손가락과 키보드로 정하게 합니다.
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 시침을 돌려 고르는 둥근 다이얼
- 02 시와 분이 따로 도는 드럼 롤러
- 03 예약 가능한 칸만 눌리는 슬롯 격자
- 04 두 손잡이가 서로를 밀어내는 구간 막대
- 05 요일마다 한 줄씩 늘리는 근무 시간 바
- 06 칩을 눌러 문장이 되는 반복 규칙
- 07 누르면 결과 시각까지 보여주는 프리셋
- 08 분과 초를 올리면 링이 도는 카운트다운
- 09 한 시각을 세 도시가 동시에 읽는 표
아홉 개는 인기순이 아니라 질문이 무거워지는 순서로 놓았습니다. 앞의 셋은 시각 한 점입니다. 판을 돌리고(01), 드럼을 굴리고(02), 이미 깔린 칸 가운데 하나를 누릅니다(03). 가운데 둘은 점이 아니라 구간입니다. 손잡이 둘로 시작과 끝을 잡고(04), 같은 구간을 요일 다섯 줄로 늘립니다(05). 다음 둘은 시각을 직접 말하지 않습니다. 06 은 언제마다인지를 문장으로 만들고, 07 은 지금으로부터 얼마나 떨어졌는지로 고릅니다. 마지막 둘은 시각이 아니라 길이와 차이입니다. 08 은 얼마 동안인지를 정하고, 09 는 같은 순간이 도시마다 다른 숫자로 읽히는 모습을 보여 줍니다. 첫 칸에서 마지막 칸으로 갈수록 묻는 말이 언제에서 얼마 동안으로 옮겨 갑니다.
01시침을 돌려 고르는 둥근 다이얼
둥근 판의 바늘을 끌면 손가락 각도가 곧바로 시각이 되고, 시를 놓는 순간 같은 판이 분 눈금으로 갈리며 두 번째 단계가 시작됩니다. 각도를 값으로 바꾸는 계산은 Math.atan2 한 줄이고, 나온 값은 판의 회전각과 aria-valuetext 로 동시에 나갑니다. 알람이나 기상 시각처럼 대략적인 시각을 손가락으로 빨리 잡는 모바일 설정 화면에 맞습니다.
function fromPointer(e) {
var r = dial.getBoundingClientRect();
var a = Math.atan2(e.clientX - r.left - r.width / 2, r.top + r.height / 2 - e.clientY) * 180 / Math.PI;
a = (a + 360) % 360;
if (s.hour) { s.h = Math.round(a / 30) || 12; } else { s.m = (Math.round(a / 30) * 5) % 60; }
paint();
}
02시와 분이 따로 도는 드럼 롤러
세로로 굴리면 숫자 줄이 관성으로 미끄러지다가 가장 가까운 칸에 붙고, 가운데 창에 걸린 값만 확정으로 읽힙니다. 확정값을 스크롤 위치에서 읽어 내므로 스크롤 상자 자체에는 이동 효과를 걸지 않습니다. 폼 한 줄 안에서 시각만 바꾸는 모바일 예약·알림 설정에 씁니다.
// 스크롤 상자 자체에는 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; }
03예약 가능한 칸만 눌리는 슬롯 격자
30분 단위 칸 열둘이 격자로 깔리고 이미 찬 자리는 진짜 disabled 라 눌러도 아무 일이 없습니다. 고른 칸은 aria-pressed 한 곳에만 적히고, 확정 배지는 상자에 붙는 클래스 하나로 한 프레임에 나타납니다. 병원이나 미용실처럼 남은 시간대를 한눈에 보여야 하는 예약 화면에 맞습니다.
function pick(b) {
sg.classList.remove('is-demo');
slots.forEach(function (s) { if (!s.disabled) s.setAttribute('aria-pressed', String(s === b)); });
sg.classList.add('is-picked');
live.textContent = out.dataset.fmt.replace('{t}', b.dataset.t);
}
slots.forEach(function (s) { s.addEventListener('click', function () { pick(s); }); });
04두 손잡이가 서로를 밀어내는 구간 막대
시작과 끝 손잡이가 0시부터 24시까지 한 축을 나눠 쓰고, 최소 간격인 한 시간 안으로 다가오면 상대를 밀어냅니다. 가운데 띠를 잡으면 길이를 유지한 채 구간이 통째로 옮겨 가며, 축 밖으로는 나가지 않습니다. 회의나 대여처럼 시작과 끝을 함께 정해야 하는 폼에 씁니다.
// 값이 뒤집히지 않는다 — 최소 간격 안으로 들어오면 상대 손잡이를 밀어낸다
function set(i, t) {
t = Math.min(Math.max(t, 0), 96);
if (i) { v[1] = Math.max(t, MIN); if (v[1] - v[0] < MIN) v[0] = v[1] - MIN; }
else { v[0] = Math.min(t, 96 - MIN); if (v[1] - v[0] < MIN) v[1] = v[0] + MIN; }
draw();
}
function shift(a) { var L = sv[1] - sv[0]; a = Math.min(Math.max(a, 0), 96 - L); v[0] = a; v[1] = a + L; draw(); }
05요일마다 한 줄씩 늘리는 근무 시간 바
요일 다섯 줄에 가로 막대가 하나씩 놓이고 양 끝을 끌면 30분 한 칸씩 붙습니다. 막대 위 숫자와 두 손잡이의 aria-valuetext 는 같은 그리기 함수 한 번으로 함께 다시 쓰이고, 시작이 종료를 넘지 못하도록 clamp 가 양쪽을 한 칸씩 막습니다. 가게 영업시간이나 상담 가능 시간을 요일별로 등록하는 관리자 화면이 이 모양입니다.
function clamp(x, lo, hi) { return Math.min(Math.max(x, lo), hi); } // 축 밖으로 못 나간다
function fmt(t) { var h = t / 2 | 0; return (h < 10 ? '0' : '') + h + ':' + (t % 2 ? '30' : '00'); }
function draw(r) {
var s = +r.dataset.s, e = +r.dataset.e, g = r.querySelectorAll('.wh__grip');
r.style.setProperty('--s', s / 48); r.style.setProperty('--e', e / 48);
g[0].setAttribute('aria-valuenow', s * 30); g[0].setAttribute('aria-valuetext', fmt(s));
g[1].setAttribute('aria-valuenow', e * 30); g[1].setAttribute('aria-valuetext', fmt(e));
r.querySelector('.wh__read [data-k="1"]').textContent = fmt(s) + '–' + fmt(e);
}
function set(r, i, t) { // 30분 한 칸
if (i) r.dataset.e = clamp(t, +r.dataset.s + 1, 48);
else r.dataset.s = clamp(t, 0, +r.dataset.e - 1);
draw(r);
}
06칩을 눌러 문장이 되는 반복 규칙
요일 칩을 켜고 끌 때마다 아래 문장이 매주 월·수·금처럼 다시 쓰입니다. 매월로 바꾸면 같은 칩이 몇째 주 무슨 요일이라는 뜻으로 읽히는데, 그 주차는 기준일에서 해당 요일이 처음 오는 날짜를 세어 구합니다. 반복 일정이나 정기 알림처럼 규칙을 말로 확인시켜야 하는 설정 창에 씁니다.
// 매월 모드의 몇째 주는 실제로 센다 — 기준일에서 그 요일이 처음 오는 날짜로 주차를 구한다
function ord(w) {
var d = new Date(2026, 8, 12);
d.setDate(d.getDate() + ((w - d.getDay() + 7) % 7));
return OR[Math.ceil(d.getDate() / 7) - 1];
}
function render() {
var m = seg.dataset.m, p = chips.filter(on).map(function (c) { return +c.dataset.w; });
if (!p.length) { sum.textContent = m === 'm' ? '매월 반복 없음' : '매주 반복 없음'; return; }
sum.textContent = m === 'm'
? '매월 ' + ord(p[0]) + ' 주 ' + SH[p[0]] + '요일 오전 9시'
: p.length === 1
? '매주 ' + SH[p[0]] + '요일 오전 9시'
: '매주 ' + p.map(function (w) { return SH[w]; }).join('·') + ' 오전 9시';
}
07누르면 결과 시각까지 보여주는 프리셋
한 시간 뒤처럼 기준 시각에 더할 분이 적힌 칩과 내일 아침처럼 시각이 그대로 적힌 칩이 섞여 있고, 어느 쪽을 눌러도 결과 시각이 아래에서 굴러 올라옵니다. 오늘·내일이라는 말투는 HTML 안 템플릿에 있고 분 계산만 자바스크립트가 맡아, 같은 결과가 화면과 읽히는 영역으로 함께 나갑니다. 다시 알림이나 나중에 보내기처럼 정확한 시각보다 거리를 고르는 자리에 맞습니다.
function clock(m) {
var day = m >= 1440 ? 1 : 0, t = m % 1440;
var h = Math.floor(t / 60), i = t % 60;
var tpl = root.querySelector('.rp__tpl[data-day="' + day + '"]').textContent; // 말투는 HTML 에, 계산은 JS 에
return tpl.replace('{t}', (h < 10 ? '0' : '') + h + ':' + (i < 10 ? '0' : '') + i);
}
function pick(btn) {
root.classList.remove('is-demo');
for (var k = 0; k < chips.length; k++) chips[k].setAttribute('aria-pressed', String(chips[k] === btn));
var m = btn.hasAttribute('data-add') ? BASE + Number(btn.getAttribute('data-add')) : Number(btn.getAttribute('data-at'));
var t = clock(m);
live.textContent = t;
if (t === cur.textContent) return;
nxt.textContent = t;
slot.classList.remove('is-roll');
void slot.offsetWidth; // 같은 값이 아니면 언제나 한 번 더 굴러야 한다
slot.classList.add('is-roll');
}
08분과 초를 올리면 링이 도는 카운트다운
분과 초 칸은 진짜 spinbutton 이라 위아래 화살표로 분은 1씩, 초는 5씩 오르내립니다. 시작을 누르면 1초마다 남은 비율이 둘레 고리의 각도로 나가고, 보조기기가 읽는 값은 숫자 하나가 아니라 5분처럼 단위가 붙은 말입니다. 타이머나 휴식처럼 지금부터 얼마 동안인지를 정하는 화면에 씁니다.
function show(s) { num.textContent = pad(Math.floor(s / 60)) + ':' + pad(s % 60); }
function ring(f) { ringEl.style.setProperty('--ring-angle', (360 * f).toFixed(1) + 'deg'); }
function stop() { if (timer) { clearInterval(timer); timer = null; } }
function paint() {
['min', 'sec'].forEach(function (k) {
var el = document.getElementById('cd-' + k);
el.setAttribute('aria-valuenow', String(V[k]));
el.setAttribute('aria-valuetext', tpl(k, V[k])); // 읽히는 값은 숫자가 아니라 사람 말이다
document.getElementById('cd-' + k + '-n').textContent = pad(V[k]);
});
show(V.min * 60 + V.sec);
ring(1);
}
09한 시각을 세 도시가 동시에 읽는 표
기준 도시의 시각을 30분씩 옮기면 세 줄이 같은 함수 한 번으로 함께 다시 그려집니다. 시차는 기준에서 몇 분 떨어졌는지 적은 배열이라 한 값에서 세 줄이 파생되고, 자정을 넘긴 줄에만 다음날 표시가 따로 붙습니다. 해외 팀과 회의를 잡을 때처럼 시차를 같이 봐야 하는 일정 화면에 씁니다.
function apply() {
root.classList.remove('is-demo');
var say = [];
for (var i = 0; i < rows.length; i++) {
var m = base + OFF[i], t = m % 1440;
var next = m >= 1440 ? '1' : '0';
rows[i].querySelector('.tz__t').textContent = pad(Math.floor(t / 60)) + ':' + pad(t % 60);
rows[i].setAttribute('data-next', next);
var b = rows[i].querySelector('.tz__badge');
say.push(rows[i].querySelector('.tz__name').textContent + ' ' + rows[i].querySelector('.tz__t').textContent
+ (next === '1' && b ? ' ' + b.textContent : ''));
}
rail.style.setProperty('--p', (base / MAX).toFixed(4));
rail.setAttribute('aria-valuenow', String(base));
rail.setAttribute('aria-valuetext', say[0]);
live.textContent = say.join(' · ');
}
어디서 깨지는가 — 함정
세 군데서 걸렸습니다.
첫째는 02번 드럼의 스냅이었습니다. 스크롤 상자에 scroll-snap-type: y mandatory 를 걸어 두면 손으로 굴릴 때는 숫자가 칸에 정확히 물리지만, 미리보기 루프가 안쪽 트랙을 transform 으로 밀어 올리는 동안에는 브라우저가 그 움직임을 좇아 스크롤 위치를 다시 스냅해 버립니다. 트랙은 위로 가는데 스크롤이 같은 양만큼 따라와, 화면에서는 숫자가 거의 제자리에 있었습니다. 해결은 미리보기 상태에만 걸리는 .dr.is-demo .dr__col { scroll-snap-type: none; } 한 줄이고, 사람이 손을 대는 순간 .is-demo 가 떨어지면서 스냅이 되살아납니다. 자동 재생과 진짜 스크롤이 한 요소를 두고 겨루면 거의 언제나 이런 식으로 상쇄됩니다.
둘째는 08번 고리의 각도입니다. conic-gradient 의 경계 각도를 커스텀 속성으로 빼면 코드는 깔끔해지지만, 등록하지 않은 커스텀 속성은 브라우저에 그냥 글자라서 360도에서 72도로 가는 중간값이 아예 생기지 않습니다. 고리가 부드럽게 줄지 않고 툭 끊기던 것이 이 이유였고, 선언 하나를 먼저 두고 나서야 각도가 이어졌습니다.
// 등록하지 않은 커스텀 속성은 보간되지 않는다 — 각도를 애니메이션하려면 이 선언이 먼저다
@property --ring-angle {
syntax: "<angle>";
inherits: false;
initial-value: 360deg;
}
여기서 한 줄 더 주의할 곳이 inherits: false 입니다. 상속을 끊어 두었으니 바깥 상자에 각도를 걸어도 안으로 내려오지 않고, 값은 conic-gradient 를 실제로 그리는 그 요소에 직접 걸어야 합니다. 데모의 자바스크립트도 고리 요소 자체에 --ring-angle 을 씁니다. 문법과 보간 조건은 MDN 의 @property 문서에 그대로 적혀 있습니다.
셋째는 컷과 프레임 수가 서로 부딪힌 문제입니다. 값이 갈리는 순간은 steps(1, end) 로 끊어야 두 숫자가 반투명하게 겹친 유령 프레임이 생기지 않는데, 컷만 남기면 스물넉 장 가운데 실제로 달라지는 장이 몇 장 되지 않습니다. 그래서 컷 옆에 이동으로 채우는 보조 동작을 하나씩 붙였습니다. 09번은 기준 줄 막대의 scaleX 와 다음날 배지의 translateY 팝을 컷과 같은 키프레임 표에 얹어, 값이 갈리는 프레임과 배지가 뜨는 프레임이 어긋나지 않게 했습니다. 최종 렌더에서 달라진 장 수를 세어 보면 항목마다 폭이 꽤 큽니다. 바늘이 쉬지 않고 도는 01번은 스물넉 장 가운데 스물세 장이, 고리가 이어서 줄어드는 08번은 스물한 장이 달라졌습니다. 반면 칩 넷과 결과 한 줄만 갈리는 07번은 열한 장에, 세 줄 표인 09번은 열두 장에 그칩니다. 나머지는 열여섯에서 열아홉 장 사이에 모여 있는데, 02번 드럼이 열여섯 장으로 가장 낮고 05번 근무 시간 바가 열아홉 장으로 가장 높습니다. 컷 사이를 이동으로 메웠는지 아닌지가 이 숫자에 그대로 드러납니다. 그렇게 채우고도 09번의 누적 면적은 1.799%로 아홉 개 가운데 가장 작은데, 표 세 줄의 숫자만 갈리는 화면이라 원래 움직일 자리가 적기 때문입니다. 반대쪽 끝은 03번 슬롯 격자의 17.651%로, 칸 열둘이 차례로 떠오르는 동안 화면 대부분이 한 번씩 바뀝니다. 아홉 벌 소스가 든 압축 파일의 비밀번호는 6meczddf 이고, 여기 적은 수정이 전부 반영된 판입니다.
접근성
prefers-reduced-motion: reduce 가 켜진 브라우저에서는 아홉 개 모두 미리보기 루프만 꺼지고 값 한 벌이 화면에 남습니다. 01번은 시 눈금 판을, 02번은 가운데 창에 걸린 값을 남기고, 08번과 09번은 뒤따르는 프레임을 표시 자체에서 지웁니다. 06번은 애니메이션뿐 아니라 세그먼트 표시자의 transition 까지 함께 끄는데, 이 표시자는 루프가 아니라 상태 변화로 움직이므로 따로 꺼 주어야 합니다. 손으로 누르고 끄는 조작은 어느 쪽에서도 살아 있고, 상태를 적는 속성도 그대로입니다.
| 항목 | 마우스 | 키보드 | 상태가 적히는 곳 |
|---|---|---|---|
| 01 둥근 다이얼 | 판 위를 끌기 | ← → ↑ ↓ · Home · End · Enter 로 시와 분 전환 | role="slider" · aria-valuetext |
| 02 드럼 롤러 | 세로 스크롤 · 휠 | ↑ ↓ · Home · End | 열마다 aria-valuenow · aria-valuetext |
| 03 슬롯 격자 | 칸 클릭 | ← → ↑ ↓ · Home · End (찬 칸 건너뜀) | aria-pressed · disabled · aria-live="polite" |
| 04 구간 막대 | 손잡이와 가운데 띠 끌기 | ← → ↑ ↓ · Home · End | 손잡이마다 aria-valuenow · aria-valuetext |
| 05 근무 시간 바 | 양 끝 끌기 | ← → · Home · End | role="group" · aria-labelledby · aria-valuetext |
| 06 반복 규칙 | 칩과 세그먼트 클릭 | ← → | role="checkbox" · aria-checked · aria-live="polite" |
| 07 프리셋 | 칩 클릭 | ← → | aria-pressed · aria-live="polite" |
| 08 카운트다운 | 위아래 버튼 클릭 | ↑ ↓ · Home · End | role="spinbutton" · aria-valuetext |
| 09 세 도시 표 | 막대 클릭 · 30분 버튼 | ← → ↑ ↓ · Home · End | role="slider" · aria-valuetext · aria-live="polite" |
다섯 항목이 role="slider" 를 쓰는데, 이 역할이 요구하는 속성과 키 조합은 MDN 의 slider role 문서에 정리돼 있습니다. 날짜를 먼저 고른 뒤 시각으로 넘어가는 흐름은 달력 월 격자 편에 이어 붙이면 되고, 폼 안에서 값을 받는 부품은 폼 카테고리에, 일정과 반복을 다루는 부품은 일정 카테고리에 모여 있습니다.
FAQ
브라우저 기본 <input type="time"> 을 두고 왜 직접 만드나요?
한 시각만 받으면 기본 입력이 낫습니다. 손댈 것도 없고 모바일에서는 운영체제 선택기가 뜹니다. 직접 만드는 이유는 기본 입력이 한 점밖에 담지 못해서입니다. 04·05번처럼 시작과 끝을 한 축에서 같이 잡거나, 03번처럼 이미 찬 시간대를 눌리지 않게 막거나, 06번처럼 반복 규칙을 문장으로 확인시키는 일은 기본 입력에는 자리가 없습니다.
시간대는 서버와 어떻게 맞추나요?
09번 데모는 기준 도시에서 몇 분 떨어졌는지를 적은 고정 배열을 씁니다. 실제 서비스라면 저장은 UTC 한 가지로, 표시는 도시나 지역 이름으로 하고 변환은 표시할 때 한 번만 합니다. 09번 구조가 그 연습에 가깝습니다. 기준값 하나에서 모든 줄이 파생되므로 서버와 맞출 지점도 하나뿐이고, 줄끼리 어긋날 틈이 없습니다.
30분 스냅 단위는 어떻게 정하나요?
예약 한 건이 실제로 차지하는 시간에서 나옵니다. 03번은 30분 칸을 열둘 깔았고, 05번은 하루를 48칸으로 나눠 30분마다 붙으며, 04번은 96칸이라 15분마다 붙되 최소 간격을 네 칸, 곧 한 시간으로 잡았습니다. 단위를 잘게 쪼갤수록 손가락으로 정확히 짚기 어려워지니, 끌어서 고르는 부품은 30분, 눌러서 고르는 칸은 15분까지를 바닥으로 두면 무난합니다.