시간 선택 ui 9종 — 시각 하나가 아니라 구간까지
시간 선택 ui 는 사용자가 몇 시 몇 분을 고르게 하는 화면 부품입니다. 여기 모은 아홉 개는 시각 한 점을 찍는 데서 그치지 않고, 언제부터 언제까지인지, 얼마나 걸리는지까지 손가락과 키보드로 정하게 합니다.
01 time-dial-analog-clock
02 time-drum-roller-hour-minute
03 time-slot-grid-booking
04 time-range-dual-handle-duration
05 time-working-hours-day-bars
06 time-recurrence-weekday-chips
07 time-relative-preset-chips
08 time-countdown-ring-setter
09 time-zone-three-city-convert
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 시침을 돌려 고르는 둥근 다이얼
- 02 시와 분이 따로 도는 드럼 롤러
- 03 예약 가능한 칸만 눌리는 슬롯 격자
- 04 두 손잡이가 서로를 밀어내는 구간 막대
- 05 요일마다 한 줄씩 늘리는 근무 시간 바
- 06 칩을 눌러 문장이 되는 반복 규칙
- 07 누르면 결과 시각까지 보여주는 프리셋
- 08 분과 초를 올리면 링이 도는 카운트다운
- 09 한 시각을 세 도시가 동시에 읽는 표
아홉 개는 인기순이 아니라 질문이 무거워지는 순서로 놓았습니다. 앞의 셋은 시각 한 점입니다. 판을 돌리고(01), 드럼을 굴리고(02), 이미 깔린 칸 가운데 하나를 누릅니다(03). 가운데 둘은 점이 아니라 구간입니다. 손잡이 둘로 시작과 끝을 잡고(04), 같은 구간을 요일 다섯 줄로 늘립니다(05). 다음 둘은 시각을 직접 말하지 않습니다. 06 은 언제마다인지를 문장으로 만들고, 07 은 지금으로부터 얼마나 떨어졌는지로 고릅니다. 마지막 둘은 시각이 아니라 길이와 차이입니다. 08 은 얼마 동안인지를 정하고, 09 는 같은 순간이 도시마다 다른 숫자로 읽히는 모습을 보여 줍니다. 첫 칸에서 마지막 칸으로 갈수록 묻는 말이 언제에서 얼마 동안으로 옮겨 갑니다. 웹 시간 선택 ui 가 달력보다 까다로운 것도 이 때문인데, 날짜는 한 점이면 끝나지만 시각은 점과 구간과 길이가 저마다 다른 모양을 요구하기 때문입니다. 고르는 대상이 장소라면 지도 ui 디자인이 앞칸이고, 색이라면 컬러 피커 직접 만들기가 그 자리를 대신합니다.
01시침을 돌려 고르는 둥근 다이얼
무엇이 보이나
둥근 판의 바늘을 끌면 손가락 각도가 곧바로 시각이 되고, 시를 놓는 순간 같은 판이 분 눈금으로 갈리며 두 번째 단계가 시작됩니다. 각도를 값으로 바꾸는 계산은 Math.atan2 한 줄이고, 나온 값은 판의 회전각과 aria-valuetext 로 동시에 나갑니다.

<main class="stage">
<div class="dl is-demo" data-stage="h">
<div class="dl__dial" id="dl-dial" role="slider" tabindex="0"
aria-label="시각 다이얼" aria-valuemin="1" aria-valuemax="12" aria-valuenow="9"
aria-valuetext="오전 9시 30분" data-fmt="오전 {h}시 {m}분">
<span class="dl__ring" data-ring="hour" aria-hidden="true">
<span class="dl__tick" style="--i:1">1</span><span class="dl__tick" style="--i:2">2</span>
<span class="dl__tick" style="--i:3">3</span><span class="dl__tick" style="--i:4">4</span>
<span class="dl__tick" style="--i:5">5</span><span class="dl__tick" style="--i:6">6</span>
<span class="dl__tick" style="--i:7">7</span><span class="dl__tick" style="--i:8">8</span>
<span class="dl__tick" style="--i:9">9</span><span class="dl__tick" style="--i:10">10</span>
<span class="dl__tick" style="--i:11">11</span><span class="dl__tick" style="--i:12">12</span>
</span>
<span class="dl__ring" data-ring="min" aria-hidden="true">
<span class="dl__tick" style="--i:1">05</span><span class="dl__tick" style="--i:2">10</span>
<span class="dl__tick" style="--i:3">15</span><span class="dl__tick" style="--i:4">20</span>
<span class="dl__tick" style="--i:5">25</span><span class="dl__tick" style="--i:6">30</span>
<span class="dl__tick" style="--i:7">35</span><span class="dl__tick" style="--i:8">40</span>
<span class="dl__tick" style="--i:9">45</span><span class="dl__tick" style="--i:10">50</span>
<span class="dl__tick" style="--i:11">55</span><span class="dl__tick" style="--i:12">00</span>
</span>
<span class="dl__hand" aria-hidden="true"><span class="dl__tip"></span></span>
<span class="dl__read" aria-hidden="true">
<span class="dl__val">
<span class="dl__num">
<span class="dl__d" data-live="h">09</span>
<span class="dl__d" data-d="h10">10</span>
<span class="dl__d" data-d="h11">11</span>
<span class="dl__d" data-d="h12">12</span>
</span>
<span class="dl__sep">:</span>
<span class="dl__num">
<span class="dl__d" data-live="m">30</span>
<span class="dl__d" data-d="m45">45</span>
<span class="dl__d" data-d="m00">00</span>
</span>
</span>
<span class="dl__step">
<span class="dl__sv" data-live="s" data-h="시 고르기" data-m="분 고르기">시 고르기</span>
<span class="dl__sv" data-s="m">분 고르기</span>
</span>
</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.dl {
box-sizing: border-box;
display: grid;
place-items: center;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.dl__dial {
--d: clamp(148px, 44vw, 176px);
--r: calc(var(--d) / 2 - 16px);
--ang: 270deg;
position: relative;
box-sizing: border-box;
width: var(--d);
height: var(--d);
border-radius: 50%;
background-color: rgba(255, 247, 230, .07);
box-shadow: inset 0 0 0 2px rgba(255, 247, 230, .2);
touch-action: none;
cursor: grab;
}
.dl__dial:focus-visible { outline: 3px solid $subject-cream; outline-offset: 3px; }
// ── 눈금: rotate 로 자리를 잡고 숫자 자체는 되돌려 세운다 ──
.dl__ring { position: absolute; inset: 0; opacity: 0; }
.dl__tick {
position: absolute;
left: 50%;
top: 50%;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1;
font-weight: 700;
color: $subject-cream;
transform:
translate(-50%, -50%)
rotate(calc(var(--i) * 30deg))
translateY(calc(var(--r) * -1))
rotate(calc(var(--i) * -30deg));
}
.dl__ring[data-ring="min"] .dl__tick { color: $subject-mint; }
// 정적 표시가 먼저 — 단계는 data-stage 한 곳에만 적힌다
.dl:not(.is-demo)[data-stage="h"] .dl__ring[data-ring="hour"],
.dl:not(.is-demo)[data-stage="m"] .dl__ring[data-ring="min"] { opacity: 1; }
// ── 바늘: 각도 한 개(--ang)가 시각이다 ──
.dl__hand {
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
transform: rotate(var(--ang));
}
.dl__hand::after {
content: "";
position: absolute;
left: calc(50% - 3px);
top: 18%;
width: 6px;
height: 26%;
border-radius: $r-pill;
background-color: $color;
}
.dl__tip {
position: absolute;
left: calc(50% - 9px);
top: calc(18% - 9px);
width: 18px;
height: 18px;
border-radius: 50%;
background-color: $color;
}
.dl[data-stage="m"] .dl__hand::after,
.dl[data-stage="m"] .dl__tip { background-color: $subject-mint; }
// ── 가운데 값 칩: 바늘은 이 뒤로 지나간다 ──
.dl__read {
position: absolute;
left: 50%;
top: 50%;
z-index: 2;
display: grid;
justify-items: center;
gap: $sp-1;
padding: $sp-1 $sp-2;
border-radius: $r-pill;
background-color: $stage-ink;
transform: translate(-50%, -50%);
}
.dl__val { display: flex; align-items: center; color: $subject-cream; }
.dl__num {
position: relative;
display: inline-block;
width: 1.35em;
height: 1em;
font-size: clamp(16px, 4.6vw, 20px);
line-height: 1;
font-weight: 800;
}
.dl__d { position: absolute; left: 0; right: 0; top: 0; text-align: center; opacity: 0; }
.dl__d[data-live] { opacity: 1; }
.dl__sep {
font-size: clamp(16px, 4.6vw, 20px);
line-height: 1;
font-weight: 800;
}
.dl__step {
position: relative;
width: 5em;
height: 13px;
font-size: clamp(9px, 2.6vw, 11px);
line-height: 13px;
font-weight: 600;
color: $subject-mint;
}
.dl__sv { position: absolute; left: 0; right: 0; top: 0; text-align: center; white-space: nowrap; opacity: 0; }
.dl__sv[data-live] { opacity: 1; }
// ── 자동 루프: 시를 몇 칸 돌다 → 분 눈금으로 한 프레임 컷 → 분을 돌고 → 90% 안에 복귀 ──
.dl.is-demo .dl__ring,
.dl.is-demo .dl__d,
.dl.is-demo .dl__sv {
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.dl.is-demo .dl__ring[data-ring="hour"] { animation-name: dl-ring-h; }
.dl.is-demo .dl__ring[data-ring="min"] { animation-name: dl-ring-m; }
@keyframes dl-ring-h { 0% { opacity: 1; } 46% { opacity: 0; } 90% { opacity: 1; } 100% { opacity: 1; } }
@keyframes dl-ring-m { 0% { opacity: 0; } 46% { opacity: 1; } 84% { opacity: 0; } 100% { opacity: 0; } }
.dl.is-demo .dl__d[data-live="h"] { animation-name: dl-h09; }
.dl.is-demo .dl__d[data-d="h10"] { animation-name: dl-h10; }
.dl.is-demo .dl__d[data-d="h11"] { animation-name: dl-h11; }
.dl.is-demo .dl__d[data-d="h12"] { animation-name: dl-h12; }
.dl.is-demo .dl__d[data-live="m"] { animation-name: dl-m30; }
.dl.is-demo .dl__d[data-d="m45"] { animation-name: dl-m45; }
.dl.is-demo .dl__d[data-d="m00"] { animation-name: dl-m00; }
@keyframes dl-h09 { 0% { opacity: 1; } 12% { opacity: 0; } 88% { opacity: 1; } 100% { opacity: 1; } }
@keyframes dl-h10 { 0% { opacity: 0; } 12% { opacity: 1; } 24% { opacity: 0; } 100% { opacity: 0; } }
@keyframes dl-h11 { 0% { opacity: 0; } 24% { opacity: 1; } 36% { opacity: 0; } 100% { opacity: 0; } }
@keyframes dl-h12 { 0% { opacity: 0; } 36% { opacity: 1; } 88% { opacity: 0; } 100% { opacity: 0; } }
@keyframes dl-m30 { 0% { opacity: 1; } 58% { opacity: 0; } 88% { opacity: 1; } 100% { opacity: 1; } }
@keyframes dl-m45 { 0% { opacity: 0; } 58% { opacity: 1; } 70% { opacity: 0; } 100% { opacity: 0; } }
@keyframes dl-m00 { 0% { opacity: 0; } 70% { opacity: 1; } 88% { opacity: 0; } 100% { opacity: 0; } }
.dl.is-demo .dl__sv[data-live] { animation-name: dl-step-h; }
.dl.is-demo .dl__sv[data-s="m"] { animation-name: dl-step-m; }
@keyframes dl-step-h { 0% { opacity: 1; } 46% { opacity: 0; } 90% { opacity: 1; } 100% { opacity: 1; } }
@keyframes dl-step-m { 0% { opacity: 0; } 46% { opacity: 1; } 90% { opacity: 0; } 100% { opacity: 0; } }
// 바늘은 눈금에 딱딱 붙는다 — steps 로 한 칸씩
.dl.is-demo .dl__hand {
animation-name: dl-hand;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes dl-hand {
0% { transform: rotate(270deg); }
12% { transform: rotate(300deg); }
24% { transform: rotate(330deg); }
36% { transform: rotate(360deg); }
46% { transform: rotate(540deg); }
58% { transform: rotate(630deg); }
70% { transform: rotate(720deg); }
88% { transform: rotate(270deg); }
100% { transform: rotate(270deg); }
}
// 컷 두 번만으로는 움직인 프레임이 안 차니 바늘 끝이 계속 숨쉰다
.dl.is-demo .dl__tip {
animation-name: dl-tip;
animation-duration: 500ms;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes dl-tip {
0% { transform: scale(1); }
50% { transform: scale(1.28); }
100% { transform: scale(1); }
}
// 분 눈금으로 갈리는 순간 판이 한 번 눌렸다 펴진다
.dl.is-demo .dl__dial {
animation-name: dl-pop;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes dl-pop {
0% { transform: scale(1); }
44% { transform: scale(1); }
48% { transform: scale(.94); }
54% { transform: scale(1.02); }
60% { transform: scale(1); }
100% { transform: scale(1); }
}
@media (max-width: 340px) {
.dl__read { gap: 0; padding: 0 $sp-1; }
.dl__step { height: 12px; line-height: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.dl.is-demo .dl__ring,
.dl.is-demo .dl__d,
.dl.is-demo .dl__sv,
.dl.is-demo .dl__hand,
.dl.is-demo .dl__tip,
.dl.is-demo .dl__dial { animation: none !important; }
.dl.is-demo .dl__ring[data-ring="hour"] { opacity: 1; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.dl {
box-sizing: border-box;
display: grid;
place-items: center;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.dl__dial {
--d: clamp(148px, 44vw, 176px);
--r: calc(var(--d) / 2 - 16px);
--ang: 270deg;
position: relative;
box-sizing: border-box;
width: var(--d);
height: var(--d);
border-radius: 50%;
background-color: rgba(255, 247, 230, 0.07);
box-shadow: inset 0 0 0 2px rgba(255, 247, 230, 0.2);
touch-action: none;
cursor: grab;
}
.dl__dial:focus-visible {
outline: 3px solid #fff7e6;
outline-offset: 3px;
}
.dl__ring {
position: absolute;
inset: 0;
opacity: 0;
}
.dl__tick {
position: absolute;
left: 50%;
top: 50%;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1;
font-weight: 700;
color: #fff7e6;
transform: translate(-50%, -50%) rotate(calc(var(--i) * 30deg)) translateY(calc(var(--r) * -1)) rotate(calc(var(--i) * -30deg));
}
.dl__ring[data-ring=min] .dl__tick {
color: #4cd4a6;
}
.dl:not(.is-demo)[data-stage=h] .dl__ring[data-ring=hour],
.dl:not(.is-demo)[data-stage=m] .dl__ring[data-ring=min] {
opacity: 1;
}
.dl__hand {
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
transform: rotate(var(--ang));
}
.dl__hand::after {
content: "";
position: absolute;
left: calc(50% - 3px);
top: 18%;
width: 6px;
height: 26%;
border-radius: 999px;
background-color: #2f6df6;
}
.dl__tip {
position: absolute;
left: calc(50% - 9px);
top: calc(18% - 9px);
width: 18px;
height: 18px;
border-radius: 50%;
background-color: #2f6df6;
}
.dl[data-stage=m] .dl__hand::after,
.dl[data-stage=m] .dl__tip {
background-color: #4cd4a6;
}
.dl__read {
position: absolute;
left: 50%;
top: 50%;
z-index: 2;
display: grid;
justify-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background-color: #17141a;
transform: translate(-50%, -50%);
}
.dl__val {
display: flex;
align-items: center;
color: #fff7e6;
}
.dl__num {
position: relative;
display: inline-block;
width: 1.35em;
height: 1em;
font-size: clamp(16px, 4.6vw, 20px);
line-height: 1;
font-weight: 800;
}
.dl__d {
position: absolute;
left: 0;
right: 0;
top: 0;
text-align: center;
opacity: 0;
}
.dl__d[data-live] {
opacity: 1;
}
.dl__sep {
font-size: clamp(16px, 4.6vw, 20px);
line-height: 1;
font-weight: 800;
}
.dl__step {
position: relative;
width: 5em;
height: 13px;
font-size: clamp(9px, 2.6vw, 11px);
line-height: 13px;
font-weight: 600;
color: #4cd4a6;
}
.dl__sv {
position: absolute;
left: 0;
right: 0;
top: 0;
text-align: center;
white-space: nowrap;
opacity: 0;
}
.dl__sv[data-live] {
opacity: 1;
}
.dl.is-demo .dl__ring,
.dl.is-demo .dl__d,
.dl.is-demo .dl__sv {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.dl.is-demo .dl__ring[data-ring=hour] {
animation-name: dl-ring-h;
}
.dl.is-demo .dl__ring[data-ring=min] {
animation-name: dl-ring-m;
}
@keyframes dl-ring-h {
0% {
opacity: 1;
}
46% {
opacity: 0;
}
90% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes dl-ring-m {
0% {
opacity: 0;
}
46% {
opacity: 1;
}
84% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.dl.is-demo .dl__d[data-live=h] {
animation-name: dl-h09;
}
.dl.is-demo .dl__d[data-d=h10] {
animation-name: dl-h10;
}
.dl.is-demo .dl__d[data-d=h11] {
animation-name: dl-h11;
}
.dl.is-demo .dl__d[data-d=h12] {
animation-name: dl-h12;
}
.dl.is-demo .dl__d[data-live=m] {
animation-name: dl-m30;
}
.dl.is-demo .dl__d[data-d=m45] {
animation-name: dl-m45;
}
.dl.is-demo .dl__d[data-d=m00] {
animation-name: dl-m00;
}
@keyframes dl-h09 {
0% {
opacity: 1;
}
12% {
opacity: 0;
}
88% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes dl-h10 {
0% {
opacity: 0;
}
12% {
opacity: 1;
}
24% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes dl-h11 {
0% {
opacity: 0;
}
24% {
opacity: 1;
}
36% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes dl-h12 {
0% {
opacity: 0;
}
36% {
opacity: 1;
}
88% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes dl-m30 {
0% {
opacity: 1;
}
58% {
opacity: 0;
}
88% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes dl-m45 {
0% {
opacity: 0;
}
58% {
opacity: 1;
}
70% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes dl-m00 {
0% {
opacity: 0;
}
70% {
opacity: 1;
}
88% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.dl.is-demo .dl__sv[data-live] {
animation-name: dl-step-h;
}
.dl.is-demo .dl__sv[data-s=m] {
animation-name: dl-step-m;
}
@keyframes dl-step-h {
0% {
opacity: 1;
}
46% {
opacity: 0;
}
90% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes dl-step-m {
0% {
opacity: 0;
}
46% {
opacity: 1;
}
90% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.dl.is-demo .dl__hand {
animation-name: dl-hand;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes dl-hand {
0% {
transform: rotate(270deg);
}
12% {
transform: rotate(300deg);
}
24% {
transform: rotate(330deg);
}
36% {
transform: rotate(360deg);
}
46% {
transform: rotate(540deg);
}
58% {
transform: rotate(630deg);
}
70% {
transform: rotate(720deg);
}
88% {
transform: rotate(270deg);
}
100% {
transform: rotate(270deg);
}
}
.dl.is-demo .dl__tip {
animation-name: dl-tip;
animation-duration: 500ms;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes dl-tip {
0% {
transform: scale(1);
}
50% {
transform: scale(1.28);
}
100% {
transform: scale(1);
}
}
.dl.is-demo .dl__dial {
animation-name: dl-pop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes dl-pop {
0% {
transform: scale(1);
}
44% {
transform: scale(1);
}
48% {
transform: scale(0.94);
}
54% {
transform: scale(1.02);
}
60% {
transform: scale(1);
}
100% {
transform: scale(1);
}
}
@media (max-width: 340px) {
.dl__read {
gap: 0;
padding: 0 4px;
}
.dl__step {
height: 12px;
line-height: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.dl.is-demo .dl__ring,
.dl.is-demo .dl__d,
.dl.is-demo .dl__sv,
.dl.is-demo .dl__hand,
.dl.is-demo .dl__tip,
.dl.is-demo .dl__dial {
animation: none !important;
}
.dl.is-demo .dl__ring[data-ring=hour] {
opacity: 1;
}
}// 각도 → 값은 Math.atan2 로 실제 계산한다. 시를 확정하면 같은 바늘이 분 눈금을 맡는다
(function () {
var dl = document.querySelector('.dl'), dial = document.getElementById('dl-dial');
var hv = dl.querySelector('[data-live="h"]'), mv = dl.querySelector('[data-live="m"]');
var sv = dl.querySelector('[data-live="s"]');
var s = { h: 9, m: 30, hour: true };
function pad(n) { return (n < 10 ? '0' : '') + n; }
function paint() {
dl.setAttribute('data-stage', s.hour ? 'h' : 'm');
dial.style.setProperty('--ang', (s.hour ? s.h * 30 : s.m * 6) + 'deg');
hv.textContent = pad(s.h); mv.textContent = pad(s.m);
sv.textContent = s.hour ? sv.dataset.h : sv.dataset.m;
dial.setAttribute('aria-valuemin', s.hour ? '1' : '0');
dial.setAttribute('aria-valuemax', s.hour ? '12' : '55');
dial.setAttribute('aria-valuenow', String(s.hour ? s.h : s.m));
dial.setAttribute('aria-valuetext', dial.dataset.fmt.replace('{h}', s.h).replace('{m}', s.m));
}
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();
}
dial.addEventListener('pointerdown', function (e) {
dl.classList.remove('is-demo'); dial.setPointerCapture(e.pointerId);
dial.dataset.drag = '1'; fromPointer(e); e.preventDefault();
});
dial.addEventListener('pointermove', function (e) { if (dial.dataset.drag) fromPointer(e); });
dial.addEventListener('pointerup', function () {
if (!dial.dataset.drag) return;
delete dial.dataset.drag;
if (s.hour) { s.hour = false; paint(); } // 시가 확정되면 판이 분 눈금으로 갈린다
});
dial.addEventListener('keydown', function (e) {
var k = e.key, d = (k === 'ArrowRight' || k === 'ArrowUp') ? 1 : (k === 'ArrowLeft' || k === 'ArrowDown') ? -1 : 0;
var jump = k === 'Home' || k === 'End', turn = k === 'Enter' || k === ' ';
if (!d && !jump && !turn) return;
e.preventDefault(); dl.classList.remove('is-demo');
if (turn) { s.hour = !s.hour; }
else if (s.hour) { s.h = k === 'Home' ? 1 : k === 'End' ? 12 : ((s.h - 1 + d + 12) % 12) + 1; }
else { s.m = k === 'Home' ? 0 : k === 'End' ? 55 : (s.m + d * 5 + 60) % 60; }
paint();
});
paint();
})();import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./AnalogDialPicker.module.scss";
export type AnalogDialPickerProps = {
labels?: { dial: string; hour: string; minute: string; meridiem: string };
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
"--ang"?: string;
};
type TickVars = CSSProperties & { "--i"?: number };
type KeyEvt = { key: string; preventDefault(): void };
type PtrEvt = {
clientX: number;
clientY: number;
pointerId: number;
preventDefault(): void;
currentTarget: {
setPointerCapture(id: number): void;
getBoundingClientRect(): { left: number; top: number; width: number; height: number };
};
};
const HOURS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
const MINS = ["05", "10", "15", "20", "25", "30", "35", "40", "45", "50", "55", "00"];
const pad = (n: number) => (n < 10 ? "0" : "") + n;
export default function AnalogDialPicker({
labels = { dial: "Time dial", hour: "Set hour", minute: "Set min", meridiem: "AM" },
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: AnalogDialPickerProps) {
const [demo, setDemo] = useState(true);
const [hour, setHour] = useState(9);
const [minute, setMinute] = useState(30);
const [pickHour, setPickHour] = useState(true);
const dragging = useRef(false);
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
"--ang": `${pickHour ? hour * 30 : minute * 6}deg`,
};
const valueText = `${hour}:${pad(minute)} ${labels.meridiem}`;
// 각도 → 값은 Math.atan2 로 실제 계산한다
function fromPointer(e: PtrEvt) {
const r = e.currentTarget.getBoundingClientRect();
let 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 (pickHour) setHour(Math.round(a / 30) || 12);
else setMinute((Math.round(a / 30) * 5) % 60);
}
function onDown(e: PtrEvt) {
setDemo(false);
e.currentTarget.setPointerCapture(e.pointerId);
dragging.current = true;
fromPointer(e);
e.preventDefault();
}
function onUp() {
if (!dragging.current) return;
dragging.current = false;
if (pickHour) setPickHour(false); // 시가 확정되면 판이 분 눈금으로 갈린다
}
function onKey(e: KeyEvt) {
const d = e.key === "ArrowRight" || e.key === "ArrowUp" ? 1 : e.key === "ArrowLeft" || e.key === "ArrowDown" ? -1 : 0;
const jump = e.key === "Home" || e.key === "End";
const turn = e.key === "Enter" || e.key === " ";
if (!d && !jump && !turn) return;
e.preventDefault();
setDemo(false);
if (turn) setPickHour((v) => !v);
else if (pickHour) setHour(e.key === "Home" ? 1 : e.key === "End" ? 12 : ((hour - 1 + d + 12) % 12) + 1);
else setMinute(e.key === "Home" ? 0 : e.key === "End" ? 55 : (minute + d * 5 + 60) % 60);
}
return (
<div className={`${styles.wrap} ${demo ? styles.isDemo : ""}`} data-stage={pickHour ? "h" : "m"} style={vars}>
<div
className={styles.dial}
role="slider"
tabIndex={0}
aria-label={labels.dial}
aria-valuemin={pickHour ? 1 : 0}
aria-valuemax={pickHour ? 12 : 55}
aria-valuenow={pickHour ? hour : minute}
aria-valuetext={valueText}
onPointerDown={onDown}
onPointerMove={(e: PtrEvt) => { if (dragging.current) fromPointer(e); }}
onPointerUp={onUp}
onKeyDown={onKey}
>
<span className={styles.ring} data-ring="hour" aria-hidden="true">
{HOURS.map((n) => (
<span key={n} className={styles.tick} style={{ "--i": n } as TickVars}>{n}</span>
))}
</span>
<span className={styles.ring} data-ring="min" aria-hidden="true">
{MINS.map((t, i) => (
<span key={t} className={styles.tick} style={{ "--i": i + 1 } as TickVars}>{t}</span>
))}
</span>
<span className={styles.hand} aria-hidden="true"><span className={styles.tip} /></span>
<span className={styles.read} aria-hidden="true">
<span className={styles.val}>
<span className={styles.num}>
<span className={styles.d} data-live="h">{pad(hour)}</span>
<span className={styles.d} data-d="h10">10</span>
<span className={styles.d} data-d="h11">11</span>
<span className={styles.d} data-d="h12">12</span>
</span>
<span className={styles.sep}>:</span>
<span className={styles.num}>
<span className={styles.d} data-live="m">{pad(minute)}</span>
<span className={styles.d} data-d="m45">45</span>
<span className={styles.d} data-d="m00">00</span>
</span>
</span>
<span className={styles.step}>
<span className={styles.sv} data-live="s">{pickHour ? labels.hour : labels.minute}</span>
<span className={styles.sv} data-s="m">{labels.minute}</span>
</span>
</span>
</div>
</div>
);
}01 · 시침을 돌려 고르는 둥근 다이얼 — 측정 누적면적 7.094% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
알람이나 기상 시각처럼 대략적인 시각을 손가락으로 빨리 잡는 모바일 설정 화면에 맞습니다.
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시와 분이 따로 도는 드럼 롤러
모바일 시간 선택 ui 의 드럼은 화면에서 어떻게 도나
세로로 굴리면 숫자 줄이 관성으로 미끄러지다가 가장 가까운 칸에 붙고, 가운데 창에 걸린 값만 확정으로 읽힙니다. 확정값을 스크롤 위치에서 읽어 내므로 스크롤 상자 자체에는 이동 효과를 걸지 않습니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
폼 한 줄 안에서 시각만 바꾸는 모바일 예약·알림 설정에 씁니다.
// 스크롤 상자 자체에는 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 한 곳에만 적히고, 확정 배지는 상자에 붙는 클래스 하나로 한 프레임에 나타납니다.

<main class="stage">
<div class="sg is-demo">
<div class="sg__grid" id="sg-grid" role="group" aria-label="예약 시간대">
<button class="sg__slot" type="button" aria-pressed="false" data-t="09:00" style="--i:0">09:00</button>
<button class="sg__slot" type="button" disabled data-t="09:30" style="--i:1">09:30<span class="sg__sr">마감</span></button>
<button class="sg__slot" type="button" aria-pressed="false" data-t="10:00" style="--i:2">10:00</button>
<button class="sg__slot" type="button" aria-pressed="false" data-t="10:30" style="--i:3">10:30</button>
<button class="sg__slot" type="button" disabled data-t="11:00" style="--i:4">11:00<span class="sg__sr">마감</span></button>
<button class="sg__slot" type="button" aria-pressed="false" data-t="11:30" data-pick style="--i:5">11:30</button>
<button class="sg__slot" type="button" aria-pressed="false" data-t="12:00" style="--i:6">12:00</button>
<button class="sg__slot" type="button" aria-pressed="false" data-t="12:30" style="--i:7">12:30</button>
<button class="sg__slot" type="button" disabled data-t="13:00" style="--i:8">13:00<span class="sg__sr">마감</span></button>
<button class="sg__slot" type="button" aria-pressed="false" data-t="13:30" style="--i:9">13:30</button>
<button class="sg__slot" type="button" disabled data-t="14:00" style="--i:10">14:00<span class="sg__sr">마감</span></button>
<button class="sg__slot" type="button" aria-pressed="false" data-t="14:30" style="--i:11">14:30</button>
</div>
<p class="sg__out" data-fmt="{t} 예약" aria-live="polite">
<span class="sg__badge">
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg>확정</span>
<span class="sg__box">
<span class="sg__txt" data-live>시간을 고르세요</span>
<span class="sg__txt" data-v="2">11:30 예약</span>
</span>
</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: $ease-pop;
$color: $subject-ink;
@include stage($stage-orange);
.sg {
box-sizing: border-box;
display: grid;
gap: $sp-3;
width: min(320px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
// 30분 단위 칸 12개 — 폭이 좁아지면 3열로
.sg__grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: $sp-2;
width: 100%;
}
.sg__slot {
position: relative;
box-sizing: border-box;
margin: 0;
padding: 0;
height: 36px;
border: 0;
border-radius: $r-sm;
background-color: $subject-cream;
color: $color;
font-family: inherit;
font-size: clamp(11px, 3.2vw, 13px);
line-height: 1;
font-weight: 700;
cursor: pointer;
}
.sg__slot:focus-visible { outline: 3px solid $color; outline-offset: 2px; }
// 이미 찬 자리 — 진짜 disabled 라 눌러도 아무 일이 없다. 빗금은 있는 그대로 흐리게
.sg__slot[disabled] {
background-color: rgba(255, 247, 230, .48);
background-image: repeating-linear-gradient(45deg, transparent 0 4px, rgba(23, 20, 26, .18) 4px 8px);
color: $subject-ink; // 빗금 면 위 흐린 잉크 2.55:1 → 9.11:1
cursor: not-allowed;
}
.sg__sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
// 눌린 칸은 aria-pressed 한 곳에서 갈린다
.sg:not(.is-demo) .sg__slot[aria-pressed="true"] {
background-color: $color;
color: $subject-cream;
}
.sg__out {
display: grid;
grid-template-columns: auto auto;
justify-content: center;
align-items: center;
gap: $sp-2;
margin: 0;
}
.sg__badge {
display: inline-flex;
align-items: center;
gap: $sp-1;
box-sizing: border-box;
height: 20px;
padding: 0 $sp-2;
border-radius: $r-pill;
background-color: $subject-cream;
color: $stage-orange;
font-size: 11px;
line-height: 1;
font-weight: 800;
white-space: nowrap;
opacity: 0;
}
.sg__badge svg { display: block; }
.sg:not(.is-demo).is-picked .sg__badge { opacity: 1; }
// 아직 아무 칸도 안 골랐으면 배지 자리를 비워 결과 문장이 가운데로 온다
.sg:not(.is-demo):not(.is-picked) .sg__badge { display: none; }
.sg__box { position: relative; display: block; width: 8.6em; height: 20px; }
.sg__txt {
position: absolute;
left: 0;
right: 0;
top: 0;
text-align: center;
font-size: clamp(11px, 3.2vw, 13px);
line-height: 20px;
font-weight: 700;
color: $subject-cream;
white-space: nowrap;
opacity: 0;
}
.sg__txt[data-live] { opacity: 1; }
// ── 자동 루프 ──
// 옆 칸들이 순서대로 살짝 떠올라 컷 사이 프레임을 채운다
.sg.is-demo .sg__slot:not([data-pick]) {
animation-name: sg-rise;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-delay: calc(var(--i) * .07s);
animation-fill-mode: backwards;
}
@keyframes sg-rise {
0% { transform: translateY(0); }
5% { transform: translateY(-5px); }
11% { transform: translateY(0); }
100% { transform: translateY(0); }
}
// 고른 칸은 한 프레임에 컷으로 갈리고, 되돌림은 78·82·86% 로 쪼갠다
.sg.is-demo .sg__slot[data-pick] {
animation-name: sg-pick, sg-pop;
animation-duration: $duration, $duration;
animation-timing-function: steps(1, end), $easing;
animation-iteration-count: infinite, infinite;
}
@keyframes sg-pick {
0% { background-color: $subject-cream; color: $color; }
40% { background-color: $color; color: $subject-cream; }
86% { background-color: $subject-cream; color: $color; }
100% { background-color: $subject-cream; color: $color; }
}
@keyframes sg-pop {
0% { transform: scale(1); }
38% { transform: scale(1); }
42% { transform: scale(.93); }
50% { transform: scale(1.04); }
58% { transform: scale(1); }
100% { transform: scale(1); }
}
.sg.is-demo .sg__badge,
.sg.is-demo .sg__txt {
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.sg.is-demo .sg__badge { animation-name: sg-badge; }
.sg.is-demo .sg__txt[data-live] { animation-name: sg-t1; }
.sg.is-demo .sg__txt[data-v="2"] { animation-name: sg-t2; }
@keyframes sg-badge { 0% { opacity: 0; } 40% { opacity: 1; } 78% { opacity: 0; } 100% { opacity: 0; } }
@keyframes sg-t1 { 0% { opacity: 1; } 40% { opacity: 0; } 82% { opacity: 1; } 100% { opacity: 1; } }
@keyframes sg-t2 { 0% { opacity: 0; } 40% { opacity: 1; } 82% { opacity: 0; } 100% { opacity: 0; } }
@media (max-width: 340px) {
.sg { gap: $sp-2; }
.sg__grid { grid-template-columns: repeat(3, 1fr); gap: $sp-1; }
.sg__slot { height: 28px; }
.sg__badge { height: 18px; padding: 0 $sp-1; }
.sg__box { height: 18px; }
.sg__txt { line-height: 18px; }
}
@media (prefers-reduced-motion: reduce) {
.sg.is-demo .sg__slot,
.sg.is-demo .sg__badge,
.sg.is-demo .sg__txt { animation: none !important; }
.sg.is-demo .sg__txt[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: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sg {
box-sizing: border-box;
display: grid;
gap: 12px;
width: min(320px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.sg__grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
width: 100%;
}
.sg__slot {
position: relative;
box-sizing: border-box;
margin: 0;
padding: 0;
height: 36px;
border: 0;
border-radius: 8px;
background-color: #fff7e6;
color: #17141a;
font-family: inherit;
font-size: clamp(11px, 3.2vw, 13px);
line-height: 1;
font-weight: 700;
cursor: pointer;
}
.sg__slot:focus-visible {
outline: 3px solid #17141a;
outline-offset: 2px;
}
.sg__slot[disabled] {
background-color: rgba(255, 247, 230, 0.48);
background-image: repeating-linear-gradient(45deg, transparent 0 4px, rgba(23, 20, 26, 0.18) 4px 8px);
color: #17141a;
cursor: not-allowed;
}
.sg__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.sg:not(.is-demo) .sg__slot[aria-pressed=true] {
background-color: #17141a;
color: #fff7e6;
}
.sg__out {
display: grid;
grid-template-columns: auto auto;
justify-content: center;
align-items: center;
gap: 8px;
margin: 0;
}
.sg__badge {
display: inline-flex;
align-items: center;
gap: 4px;
box-sizing: border-box;
height: 20px;
padding: 0 8px;
border-radius: 999px;
background-color: #fff7e6;
color: #ff4d1f;
font-size: 11px;
line-height: 1;
font-weight: 800;
white-space: nowrap;
opacity: 0;
}
.sg__badge svg {
display: block;
}
.sg:not(.is-demo).is-picked .sg__badge {
opacity: 1;
}
.sg:not(.is-demo):not(.is-picked) .sg__badge {
display: none;
}
.sg__box {
position: relative;
display: block;
width: 8.6em;
height: 20px;
}
.sg__txt {
position: absolute;
left: 0;
right: 0;
top: 0;
text-align: center;
font-size: clamp(11px, 3.2vw, 13px);
line-height: 20px;
font-weight: 700;
color: #fff7e6;
white-space: nowrap;
opacity: 0;
}
.sg__txt[data-live] {
opacity: 1;
}
.sg.is-demo .sg__slot:not([data-pick]) {
animation-name: sg-rise;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
animation-delay: calc(var(--i) * 0.07s);
animation-fill-mode: backwards;
}
@keyframes sg-rise {
0% {
transform: translateY(0);
}
5% {
transform: translateY(-5px);
}
11% {
transform: translateY(0);
}
100% {
transform: translateY(0);
}
}
.sg.is-demo .sg__slot[data-pick] {
animation-name: sg-pick, sg-pop;
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite, infinite;
}
@keyframes sg-pick {
0% {
background-color: #fff7e6;
color: #17141a;
}
40% {
background-color: #17141a;
color: #fff7e6;
}
86% {
background-color: #fff7e6;
color: #17141a;
}
100% {
background-color: #fff7e6;
color: #17141a;
}
}
@keyframes sg-pop {
0% {
transform: scale(1);
}
38% {
transform: scale(1);
}
42% {
transform: scale(0.93);
}
50% {
transform: scale(1.04);
}
58% {
transform: scale(1);
}
100% {
transform: scale(1);
}
}
.sg.is-demo .sg__badge,
.sg.is-demo .sg__txt {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.sg.is-demo .sg__badge {
animation-name: sg-badge;
}
.sg.is-demo .sg__txt[data-live] {
animation-name: sg-t1;
}
.sg.is-demo .sg__txt[data-v="2"] {
animation-name: sg-t2;
}
@keyframes sg-badge {
0% {
opacity: 0;
}
40% {
opacity: 1;
}
78% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes sg-t1 {
0% {
opacity: 1;
}
40% {
opacity: 0;
}
82% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes sg-t2 {
0% {
opacity: 0;
}
40% {
opacity: 1;
}
82% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.sg {
gap: 8px;
}
.sg__grid {
grid-template-columns: repeat(3, 1fr);
gap: 4px;
}
.sg__slot {
height: 28px;
}
.sg__badge {
height: 18px;
padding: 0 4px;
}
.sg__box {
height: 18px;
}
.sg__txt {
line-height: 18px;
}
}
@media (prefers-reduced-motion: reduce) {
.sg.is-demo .sg__slot,
.sg.is-demo .sg__badge,
.sg.is-demo .sg__txt {
animation: none !important;
}
.sg.is-demo .sg__txt[data-live] {
opacity: 1;
}
}// 찬 자리는 진짜 disabled 라 눌러도 아무 일이 없다. 고른 칸은 aria-pressed 한 곳에만 적힌다
(function () {
var sg = document.querySelector('.sg'), grid = document.getElementById('sg-grid');
var out = sg.querySelector('.sg__out'), live = sg.querySelector('[data-live]');
var slots = [].slice.call(grid.querySelectorAll('.sg__slot'));
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); }); });
grid.addEventListener('keydown', function (e) {
var cols = getComputedStyle(grid).gridTemplateColumns.split(' ').length;
var step = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: cols, ArrowUp: -cols }[e.key];
var jump = e.key === 'Home' || e.key === 'End';
if (!step && !jump) return;
var k = slots.indexOf(document.activeElement);
if (k < 0) return;
e.preventDefault();
var d = jump ? (e.key === 'Home' ? -1 : 1) : (step > 0 ? 1 : -1);
var j = jump ? (e.key === 'Home' ? 0 : slots.length - 1) : k + step;
while (j >= 0 && j < slots.length && slots[j].disabled) j += d; // 찬 자리는 건너뛴다
if (j >= 0 && j < slots.length) slots[j].focus({ preventScroll: true });
});
})();import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./BookingSlotGrid.module.scss";
export type BookingSlotGridProps = {
labels?: { group: string; full: string; empty: string; confirmed: string; booked: string };
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type SlotVars = CSSProperties & { "--i"?: number };
type GridKeyEvt = { key: string; preventDefault(): void; currentTarget: HTMLElement };
const SLOTS = [
{ t: "09:00", taken: false }, { t: "09:30", taken: true },
{ t: "10:00", taken: false }, { t: "10:30", taken: false },
{ t: "11:00", taken: true }, { t: "11:30", taken: false },
{ t: "12:00", taken: false }, { t: "12:30", taken: false },
{ t: "13:00", taken: true }, { t: "13:30", taken: false },
{ t: "14:00", taken: true }, { t: "14:30", taken: false },
];
const PICK = 5; // 자동 루프가 고르는 칸
export default function BookingSlotGrid({
labels = {
group: "Booking time slots",
full: "Full",
empty: "Pick a time",
confirmed: "Confirmed",
booked: "{t} booked",
},
duration = 2000,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#17141a",
}: BookingSlotGridProps) {
const [demo, setDemo] = useState(true);
const [picked, setPicked] = useState("");
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 찬 자리는 진짜 disabled 라 눌러도 아무 일이 없다. 고른 칸은 aria-pressed 한 곳에만 적힌다
function pick(t: string) {
setDemo(false);
setPicked(t);
}
function onKey(e: GridKeyEvt) {
const grid = e.currentTarget;
const btns = Array.from(grid.querySelectorAll("button"));
const cols = getComputedStyle(grid).gridTemplateColumns.split(" ").length;
const step = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1
: e.key === "ArrowDown" ? cols : e.key === "ArrowUp" ? -cols : 0;
const jump = e.key === "Home" || e.key === "End";
if (!step && !jump) return;
const k = btns.indexOf(document.activeElement as unknown as HTMLButtonElement);
if (k < 0) return;
e.preventDefault();
const d = jump ? (e.key === "Home" ? 1 : -1) : (step > 0 ? 1 : -1);
let j = jump ? (e.key === "Home" ? 0 : btns.length - 1) : k + step;
while (j >= 0 && j < btns.length && btns[j].disabled) j += d; // 찬 자리는 건너뛴다
const next = btns[j];
if (next) next.focus({ preventScroll: true });
}
return (
<div className={`${styles.wrap} ${demo ? styles.isDemo : ""} ${picked ? styles.isPicked : ""}`} style={vars}>
<div className={styles.grid} role="group" aria-label={labels.group} onKeyDown={onKey}>
{SLOTS.map((s, i) => (
<button
key={s.t}
className={styles.slot}
type="button"
disabled={s.taken}
aria-pressed={s.taken ? undefined : picked === s.t}
data-pick={i === PICK ? "" : undefined}
style={{ "--i": i } as SlotVars}
onClick={() => pick(s.t)}
>
{s.t}
{s.taken ? <span className={styles.sr}>{labels.full}</span> : null}
</button>
))}
</div>
<p className={styles.out} aria-live="polite">
<span className={styles.badge}>
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true">
<g fill="currentColor">
<path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" />
<path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88" />
</g>
</svg>
{labels.confirmed}
</span>
<span className={styles.box}>
<span className={styles.txt} data-live>{picked ? labels.booked.replace("{t}", picked) : labels.empty}</span>
<span className={styles.txt} data-v="2">{labels.booked.replace("{t}", SLOTS[PICK].t)}</span>
</span>
</p>
</div>
);
}03 · 예약 가능한 칸만 눌리는 슬롯 격자 — 측정 누적면적 17.663% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
병원이나 미용실처럼 남은 시간대를 한눈에 보여야 하는 예약 화면에 맞습니다.
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시까지 한 축을 나눠 쓰고, 최소 간격인 한 시간 안으로 다가오면 상대를 밀어냅니다. 가운데 띠를 잡으면 길이를 유지한 채 구간이 통째로 옮겨 가며, 축 밖으로는 나가지 않습니다.

<main class="stage">
<div class="rg is-demo">
<div class="rg__head">
<span class="rg__cut" data-k="1"><span class="rg__val">오전 9:30 – 오후 6:00</span><span class="rg__len">8시간 30분</span></span>
<span class="rg__cut" data-k="2" aria-hidden="true"><span class="rg__val">오후 5:30 – 오후 6:30</span><span class="rg__len">1시간</span></span>
<span class="rg__cut" data-k="3" aria-hidden="true"><span class="rg__val">오후 7:30 – 오후 8:30</span><span class="rg__len">1시간</span></span>
</div>
<div class="rg__body">
<div class="rg__axis" id="rg-axis">
<div class="rg__rail" aria-hidden="true"><div class="rg__fill"></div></div>
<button class="rg__band" id="rg-band" type="button" aria-label="구간 통째로 이동"></button>
<div class="rg__h rg__h--a">
<span class="rg__knob" id="rg-a" role="slider" tabindex="0"
aria-label="시작 시각" aria-valuemin="0" aria-valuemax="1440"
aria-valuenow="570" aria-valuetext="오전 9시 30분"></span>
</div>
<div class="rg__h rg__h--b">
<span class="rg__knob" id="rg-b" role="slider" tabindex="0"
aria-label="종료 시각" aria-valuemin="0" aria-valuemax="1440"
aria-valuenow="1080" aria-valuetext="오후 6시"></span>
</div>
</div>
<div class="rg__ticks" aria-hidden="true">
<span>0시</span><span>6시</span><span>12시</span><span>18시</span><span>24시</span>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.rg {
--a: .39583; // 시작 손잡이 위치 (0~1)
--b: .75; // 끝 손잡이 위치
--len: .35417; // 두 손잡이 사이 길이
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: $sp-3;
width: min(340px, 90vw);
color: $subject-ink;
}
// ── 머리: 값 텍스트 세 장을 겹쳐 두고 컷으로 갈린다 ──
.rg__head {
position: relative;
height: 22px;
}
.rg__cut {
position: absolute;
left: 0;
right: 0;
top: 0;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: $sp-2;
opacity: 0;
}
.rg__cut[data-k="1"] { opacity: 1; }
.rg__val {
font-size: clamp(13px, 4vw, 15px);
line-height: 22px;
font-weight: 700;
white-space: nowrap;
}
.rg__len {
font-size: clamp(11px, 3.2vw, 12px);
line-height: 22px;
font-weight: 700;
color: $color;
white-space: nowrap;
}
// 손잡이 레이어는 축 폭만큼 통째로 밀리므로 여기서 잘라 낸다 (안 자르면 body.scrollWidth 가 넘친다)
.rg__body {
padding-left: $sp-3;
padding-right: $sp-3;
overflow: hidden;
}
.rg__axis {
position: relative;
height: 40px;
}
.rg__rail {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 16px;
translate: 0 -50%;
border-radius: $r-pill;
overflow: hidden;
background-color: rgba($subject-ink, .1);
background-image: repeating-linear-gradient(90deg, rgba($subject-ink, .3) 0 2px, rgba($subject-ink, 0) 2px 25%);
}
// 채움은 scaleX — 부모가 flex 가 아니라 단일 트랙이라 폭이 어긋나지 않는다
.rg__fill {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 100%;
transform-origin: left center; // 규칙8: 범위 선택 scaleX 좌 기준
transform: translateX(calc(var(--a) * 100%)) scaleX(var(--len));
background-color: $color;
}
// 가운데 띠 — 잡으면 길이를 유지한 채 통째로 움직인다
.rg__band {
position: absolute;
top: 50%;
left: calc(var(--a) * 100%);
width: calc(var(--len) * 100%);
height: 26px;
translate: 0 -50%;
margin: 0;
padding: 0;
border: 0;
border-radius: $r-pill;
background-color: transparent;
cursor: grab;
touch-action: none;
}
.rg__band:focus-visible { outline: 3px solid $subject-ink; outline-offset: 0; }
.rg__h {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 100%;
pointer-events: none;
}
.rg__h--a { transform: translateX(calc(var(--a) * 100%)); }
.rg__h--b { transform: translateX(calc(var(--b) * 100%)); }
.rg__knob {
position: absolute;
left: 0;
top: 50%;
width: 22px;
height: 22px;
translate: -50% -50%;
border-radius: 50%;
background-color: $subject-ink;
box-shadow: $shadow-press;
cursor: ew-resize;
pointer-events: auto;
touch-action: none;
}
.rg__knob::after {
content: "";
position: absolute;
left: 50%;
top: 50%;
width: 6px;
height: 6px;
translate: -50% -50%;
border-radius: 50%;
background-color: $stage-paper;
}
.rg__knob:focus-visible { outline: 3px solid $color; outline-offset: 2px; }
.rg__ticks {
display: flex;
justify-content: space-between;
margin-top: $sp-1;
}
.rg__ticks span {
font-size: clamp(9px, 2.8vw, 11px);
line-height: 14px;
font-weight: 500;
color: rgba($subject-ink, .6);
}
// ── 자동 루프 ──
// 8~30% 시작 손잡이가 끝 손잡이를 밀어낸다 → 42~60% 구간이 통째로 오른쪽으로 → 74~88% 복귀
.rg.is-demo .rg__fill,
.rg.is-demo .rg__h--a,
.rg.is-demo .rg__h--b {
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.rg.is-demo .rg__fill { animation-name: rg-fill; }
.rg.is-demo .rg__h--a { animation-name: rg-a; }
.rg.is-demo .rg__h--b { animation-name: rg-b; }
// 값은 38·72 (15분 칸) → 밀려서 70·74 → 통째로 78·82 → 다시 70·74 → 38·72
// 되돌림(68~84%)은 여러 프레임으로 쪼개 최대 변화가 되지 않게 한다 — poster 는 "밀려난 순간"(25%)이 뽑힌다
// 중간 스톱을 촘촘히 둬 끄는 구간이 등속에 가깝게 흐르게 한다 — 한 프레임에 몰리면
// 되돌림이 poster 를 가져가고, 값 텍스트가 아직 안 갈린 프레임이 크게 어긋나 보인다
@keyframes rg-fill {
0%, 14% { transform: translateX(39.583%) scaleX(.35417); }
18% { transform: translateX(53.125%) scaleX(.21875); }
22% { transform: translateX(66.667%) scaleX(.08333); }
25%, 36% { transform: translateX(72.917%) scaleX(.04167); }
48%, 56% { transform: translateX(81.25%) scaleX(.04167); }
68% { transform: translateX(72.917%) scaleX(.04167); }
73% { transform: translateX(61.458%) scaleX(.14583); }
78% { transform: translateX(50%) scaleX(.25); }
84%, 100% { transform: translateX(39.583%) scaleX(.35417); }
}
@keyframes rg-a {
0%, 14% { transform: translateX(39.583%); }
18% { transform: translateX(53.125%); }
22% { transform: translateX(66.667%); }
25%, 36% { transform: translateX(72.917%); }
48%, 56% { transform: translateX(81.25%); }
68% { transform: translateX(72.917%); }
73% { transform: translateX(61.458%); }
78% { transform: translateX(50%); }
84%, 100% { transform: translateX(39.583%); }
}
@keyframes rg-b {
0%, 22% { transform: translateX(75%) scale(1); }
25% { transform: translateX(77.083%) scale(1.25); }
32%, 36% { transform: translateX(77.083%) scale(1); }
48%, 56% { transform: translateX(85.417%) scale(1); }
68% { transform: translateX(77.083%) scale(1); }
73% { transform: translateX(76.042%) scale(1); }
78%, 100% { transform: translateX(75%) scale(1); }
}
// 값 텍스트는 한 프레임에 컷 — 크로스페이드는 poster 에 유령을 남긴다
.rg.is-demo .rg__cut {
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.rg.is-demo .rg__cut[data-k="1"] { animation-name: rg-k1; }
.rg.is-demo .rg__cut[data-k="2"] { animation-name: rg-k2; }
.rg.is-demo .rg__cut[data-k="3"] { animation-name: rg-k3; }
@keyframes rg-k1 { 0% { opacity: 1; } 25% { opacity: 0; } 84% { opacity: 1; } 100% { opacity: 1; } }
@keyframes rg-k2 { 0% { opacity: 0; } 25% { opacity: 1; } 50% { opacity: 0; } 68% { opacity: 1; } 84% { opacity: 0; } 100% { opacity: 0; } }
@keyframes rg-k3 { 0% { opacity: 0; } 50% { opacity: 1; } 68% { opacity: 0; } 100% { opacity: 0; } }
@media (max-width: 340px) {
.rg { gap: $sp-2; }
.rg__head { height: 20px; }
.rg__val { line-height: 20px; }
.rg__len { line-height: 20px; }
.rg__axis { height: 32px; }
.rg__rail { height: 14px; }
.rg__knob { width: 20px; height: 20px; }
.rg__band { height: 22px; }
}
@media (prefers-reduced-motion: reduce) {
.rg.is-demo .rg__fill,
.rg.is-demo .rg__h--a,
.rg.is-demo .rg__h--b,
.rg.is-demo .rg__cut { animation: none !important; }
.rg.is-demo .rg__cut[data-k="1"] { opacity: 1; }
}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);
}
.rg {
--a: .39583;
--b: .75;
--len: .35417;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 12px;
width: min(340px, 90vw);
color: #17141a;
}
.rg__head {
position: relative;
height: 22px;
}
.rg__cut {
position: absolute;
left: 0;
right: 0;
top: 0;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
opacity: 0;
}
.rg__cut[data-k="1"] {
opacity: 1;
}
.rg__val {
font-size: clamp(13px, 4vw, 15px);
line-height: 22px;
font-weight: 700;
white-space: nowrap;
}
.rg__len {
font-size: clamp(11px, 3.2vw, 12px);
line-height: 22px;
font-weight: 700;
color: #2f6df6;
white-space: nowrap;
}
.rg__body {
padding-left: 12px;
padding-right: 12px;
overflow: hidden;
}
.rg__axis {
position: relative;
height: 40px;
}
.rg__rail {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 16px;
translate: 0 -50%;
border-radius: 999px;
overflow: hidden;
background-color: rgba(23, 20, 26, 0.1);
background-image: repeating-linear-gradient(90deg, rgba(23, 20, 26, 0.3) 0 2px, rgba(23, 20, 26, 0) 2px 25%);
}
.rg__fill {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 100%;
transform-origin: left center;
transform: translateX(calc(var(--a) * 100%)) scaleX(var(--len));
background-color: #2f6df6;
}
.rg__band {
position: absolute;
top: 50%;
left: calc(var(--a) * 100%);
width: calc(var(--len) * 100%);
height: 26px;
translate: 0 -50%;
margin: 0;
padding: 0;
border: 0;
border-radius: 999px;
background-color: transparent;
cursor: grab;
touch-action: none;
}
.rg__band:focus-visible {
outline: 3px solid #17141a;
outline-offset: 0;
}
.rg__h {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 100%;
pointer-events: none;
}
.rg__h--a {
transform: translateX(calc(var(--a) * 100%));
}
.rg__h--b {
transform: translateX(calc(var(--b) * 100%));
}
.rg__knob {
position: absolute;
left: 0;
top: 50%;
width: 22px;
height: 22px;
translate: -50% -50%;
border-radius: 50%;
background-color: #17141a;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
cursor: ew-resize;
pointer-events: auto;
touch-action: none;
}
.rg__knob::after {
content: "";
position: absolute;
left: 50%;
top: 50%;
width: 6px;
height: 6px;
translate: -50% -50%;
border-radius: 50%;
background-color: #ffffff;
}
.rg__knob:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 2px;
}
.rg__ticks {
display: flex;
justify-content: space-between;
margin-top: 4px;
}
.rg__ticks span {
font-size: clamp(9px, 2.8vw, 11px);
line-height: 14px;
font-weight: 500;
color: rgba(23, 20, 26, 0.6);
}
.rg.is-demo .rg__fill,
.rg.is-demo .rg__h--a,
.rg.is-demo .rg__h--b {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.rg.is-demo .rg__fill {
animation-name: rg-fill;
}
.rg.is-demo .rg__h--a {
animation-name: rg-a;
}
.rg.is-demo .rg__h--b {
animation-name: rg-b;
}
@keyframes rg-fill {
0%, 14% {
transform: translateX(39.583%) scaleX(0.35417);
}
18% {
transform: translateX(53.125%) scaleX(0.21875);
}
22% {
transform: translateX(66.667%) scaleX(0.08333);
}
25%, 36% {
transform: translateX(72.917%) scaleX(0.04167);
}
48%, 56% {
transform: translateX(81.25%) scaleX(0.04167);
}
68% {
transform: translateX(72.917%) scaleX(0.04167);
}
73% {
transform: translateX(61.458%) scaleX(0.14583);
}
78% {
transform: translateX(50%) scaleX(0.25);
}
84%, 100% {
transform: translateX(39.583%) scaleX(0.35417);
}
}
@keyframes rg-a {
0%, 14% {
transform: translateX(39.583%);
}
18% {
transform: translateX(53.125%);
}
22% {
transform: translateX(66.667%);
}
25%, 36% {
transform: translateX(72.917%);
}
48%, 56% {
transform: translateX(81.25%);
}
68% {
transform: translateX(72.917%);
}
73% {
transform: translateX(61.458%);
}
78% {
transform: translateX(50%);
}
84%, 100% {
transform: translateX(39.583%);
}
}
@keyframes rg-b {
0%, 22% {
transform: translateX(75%) scale(1);
}
25% {
transform: translateX(77.083%) scale(1.25);
}
32%, 36% {
transform: translateX(77.083%) scale(1);
}
48%, 56% {
transform: translateX(85.417%) scale(1);
}
68% {
transform: translateX(77.083%) scale(1);
}
73% {
transform: translateX(76.042%) scale(1);
}
78%, 100% {
transform: translateX(75%) scale(1);
}
}
.rg.is-demo .rg__cut {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.rg.is-demo .rg__cut[data-k="1"] {
animation-name: rg-k1;
}
.rg.is-demo .rg__cut[data-k="2"] {
animation-name: rg-k2;
}
.rg.is-demo .rg__cut[data-k="3"] {
animation-name: rg-k3;
}
@keyframes rg-k1 {
0% {
opacity: 1;
}
25% {
opacity: 0;
}
84% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes rg-k2 {
0% {
opacity: 0;
}
25% {
opacity: 1;
}
50% {
opacity: 0;
}
68% {
opacity: 1;
}
84% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes rg-k3 {
0% {
opacity: 0;
}
50% {
opacity: 1;
}
68% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.rg {
gap: 8px;
}
.rg__head {
height: 20px;
}
.rg__val {
line-height: 20px;
}
.rg__len {
line-height: 20px;
}
.rg__axis {
height: 32px;
}
.rg__rail {
height: 14px;
}
.rg__knob {
width: 20px;
height: 20px;
}
.rg__band {
height: 22px;
}
}
@media (prefers-reduced-motion: reduce) {
.rg.is-demo .rg__fill,
.rg.is-demo .rg__h--a,
.rg.is-demo .rg__h--b,
.rg.is-demo .rg__cut {
animation: none !important;
}
.rg.is-demo .rg__cut[data-k="1"] {
opacity: 1;
}
}(function () {
var root = document.querySelector('.rg'), axis = document.getElementById('rg-axis');
var K = [document.getElementById('rg-a'), document.getElementById('rg-b')];
var band = document.getElementById('rg-band');
var cut = root.querySelector('.rg__cut[data-k="1"]');
var val = cut.querySelector('.rg__val'), len = cut.querySelector('.rg__len');
var v = [38, 72], MIN = 4, drag = null, st = 0, sv = [0, 0]; // 15분 한 칸, 최소 간격 1시간
function fmt(t, k) {
var h = (t / 4 | 0) % 24, m = (t % 4) * 15, ap = h < 12 ? '오전' : '오후', hh = h % 12 || 12;
return k ? ap + ' ' + hh + '시' + (m ? ' ' + m + '분' : '')
: ap + ' ' + hh + ':' + (m < 10 ? '0' : '') + m;
}
function draw() {
root.style.setProperty('--a', v[0] / 96);
root.style.setProperty('--b', v[1] / 96);
root.style.setProperty('--len', (v[1] - v[0]) / 96);
for (var i = 0; i < 2; i++) {
K[i].setAttribute('aria-valuenow', v[i] * 15);
K[i].setAttribute('aria-valuetext', fmt(v[i], 1));
}
val.textContent = fmt(v[0]) + ' – ' + fmt(v[1]);
var d = (v[1] - v[0]) * 15, H = d / 60 | 0, M = d % 60;
len.textContent = (H ? H + '시간' : '') + (M ? (H ? ' ' : '') + M + '분' : '');
}
// 값이 뒤집히지 않는다 — 최소 간격 안으로 들어오면 상대 손잡이를 밀어낸다
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(); }
function at(e) { var r = axis.getBoundingClientRect(); return Math.round((e.clientX - r.left) / r.width * 96); }
function key(e, i) {
var s = e.key === 'ArrowRight' || e.key === 'ArrowUp' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowDown' ? -1 : 0;
if (!s && e.key !== 'Home' && e.key !== 'End') return;
root.classList.remove('is-demo'); e.preventDefault(); sv = [v[0], v[1]];
if (i === 2) shift(v[0] + (e.key === 'Home' ? -96 : e.key === 'End' ? 96 : s));
else set(i, e.key === 'Home' ? 0 : e.key === 'End' ? 96 : v[i] + s);
}
[K[0], K[1], band].forEach(function (el, i) {
el.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo'); drag = i; st = at(e); sv = [v[0], v[1]];
el.setPointerCapture(e.pointerId); el.focus({ preventScroll: true }); e.preventDefault();
});
el.addEventListener('keydown', function (e) { key(e, i); });
});
document.addEventListener('pointermove', function (e) {
if (drag === null) return;
var d = at(e) - st;
if (drag === 2) shift(sv[0] + d); else set(drag, sv[drag] + d);
});
document.addEventListener('pointerup', function () { drag = null; });
draw();
})();import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TimeRangeDualHandle.module.scss";
export type TimeRangeDualHandleProps = {
labels?: { start: string; end: string; band: string; ticks: string[] };
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string; "--easing"?: string; "--color"?: string;
"--a"?: string; "--b"?: string; "--len"?: string;
};
type PtrEvt = {
clientX: number; pointerId: number; preventDefault(): void;
currentTarget: { setPointerCapture(id: number): void };
};
type KeyEvt = { key: string; preventDefault(): void };
type Span = [number, number];
const MIN = 4; // 최소 간격 1시간 = 15분 네 칸
const S2: Span = [70, 74]; // 루프 2단계: 밀려난 구간
const S3: Span = [78, 82]; // 루프 3단계: 통째로 옮긴 구간
function clock(t: number): string {
const h = Math.floor(t / 4) % 24, m = (t % 4) * 15, hh = h % 12 || 12;
return hh + ":" + (m < 10 ? "0" : "") + m + " " + (h < 12 ? "AM" : "PM");
}
function spoken(t: number): string {
const h = Math.floor(t / 4) % 24, m = (t % 4) * 15, hh = h % 12 || 12;
return (m ? hh + ":" + (m < 10 ? "0" : "") + m : String(hh)) + " " + (h < 12 ? "AM" : "PM");
}
function length(v: Span): string {
const d = (v[1] - v[0]) * 15, H = Math.floor(d / 60), M = d % 60;
return (H ? H + " hr" : "") + (M ? (H ? " " : "") + M + " min" : "");
}
export default function TimeRangeDualHandle({
labels = {
start: "Start time",
end: "End time",
band: "Move the whole span",
ticks: ["0:00", "6:00", "12:00", "18:00", "24:00"],
},
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: TimeRangeDualHandleProps) {
const [demo, setDemo] = useState(true);
const [v, setV] = useState<Span>([38, 72]);
const axis = useRef<HTMLDivElement>(null);
const drag = useRef<{ i: number; st: number; sv: Span } | null>(null);
const vars: Vars = {
"--duration": duration + "ms", "--easing": easing, "--color": color,
"--a": String(v[0] / 96), "--b": String(v[1] / 96), "--len": String((v[1] - v[0]) / 96),
};
// 값이 뒤집히지 않는다 — 최소 간격 안으로 들어오면 상대 손잡이를 밀어낸다
function set(i: number, t: number) {
const n = Math.min(Math.max(t, 0), 96);
setV((p): Span => {
if (i) { const b = Math.max(n, MIN); return [Math.min(p[0], b - MIN), b]; }
const a = Math.min(n, 96 - MIN); return [a, Math.max(p[1], a + MIN)];
});
}
function shift(a0: number, L: number) {
const a = Math.min(Math.max(a0, 0), 96 - L);
setV([a, a + L]);
}
function at(e: PtrEvt): number {
const el = axis.current;
if (!el) return 0;
const r = el.getBoundingClientRect();
return Math.round(((e.clientX - r.left) / r.width) * 96);
}
function down(i: number) {
return (e: PtrEvt) => {
setDemo(false);
drag.current = { i, st: at(e), sv: v };
e.currentTarget.setPointerCapture(e.pointerId);
e.preventDefault();
};
}
function move(e: PtrEvt) {
const d = drag.current;
if (!d) return;
const delta = at(e) - d.st;
if (d.i === 2) shift(d.sv[0] + delta, d.sv[1] - d.sv[0]);
else set(d.i, d.sv[d.i] + delta);
}
function up() { drag.current = null; }
function key(i: number) {
return (e: KeyEvt) => {
const s = e.key === "ArrowRight" || e.key === "ArrowUp" ? 1
: e.key === "ArrowLeft" || e.key === "ArrowDown" ? -1 : 0;
if (!s && e.key !== "Home" && e.key !== "End") return;
setDemo(false);
e.preventDefault();
const jump = e.key === "Home" ? -96 : e.key === "End" ? 96 : s;
if (i === 2) shift(v[0] + jump, v[1] - v[0]);
else set(i, e.key === "Home" ? 0 : e.key === "End" ? 96 : v[i] + s);
};
}
const cuts: Span[] = [v, S2, S3];
return (
<div className={styles.rg + (demo ? " " + styles.isDemo : "")} style={vars}>
<div className={styles.head}>
{cuts.map((c, i) => (
<span key={i} className={styles.cut} data-k={i + 1} aria-hidden={i > 0}>
<span className={styles.val}>{clock(c[0]) + " – " + clock(c[1])}</span>
<span className={styles.len}>{length(c)}</span>
</span>
))}
</div>
<div className={styles.body}>
<div className={styles.axis} ref={axis}>
<div className={styles.rail} aria-hidden="true"><div className={styles.fill} /></div>
<button
className={styles.band}
type="button"
aria-label={labels.band}
onPointerDown={down(2)}
onPointerMove={move}
onPointerUp={up}
onKeyDown={key(2)}
/>
{[0, 1].map((i) => (
<div key={i} className={styles.h + " " + (i ? styles.hB : styles.hA)}>
<span
className={styles.knob}
role="slider"
tabIndex={0}
aria-label={i ? labels.end : labels.start}
aria-valuemin={0}
aria-valuemax={1440}
aria-valuenow={v[i] * 15}
aria-valuetext={spoken(v[i])}
onPointerDown={down(i)}
onPointerMove={move}
onPointerUp={up}
onKeyDown={key(i)}
/>
</div>
))}
</div>
<div className={styles.ticks} aria-hidden="true">
{labels.ticks.map((t) => <span key={t}>{t}</span>)}
</div>
</div>
</div>
);
}04 · 두 손잡이가 서로를 밀어내는 구간 막대 — 측정 누적면적 4.152% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
회의나 대여처럼 시작과 끝을 함께 정해야 하는 폼에 씁니다.
// 값이 뒤집히지 않는다 — 최소 간격 안으로 들어오면 상대 손잡이를 밀어낸다
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 가 양쪽을 한 칸씩 막습니다.

<main class="stage">
<div class="wh is-demo">
<div class="wh__scale" aria-hidden="true">
<span class="wh__pad"></span>
<span class="wh__marks"><span>0</span><span>6</span><span>12</span><span>18</span><span>24</span></span>
<span class="wh__pad"></span>
</div>
<div class="wh__row" data-d="mon" data-s="18" data-e="36" style="--s:.375;--e:.75">
<span class="wh__day"><span class="wh__full" id="wh-d1">월요일</span><span class="wh__ab" aria-hidden="true">월</span></span>
<div class="wh__axis" role="group" aria-labelledby="wh-d1">
<span class="wh__rail" aria-hidden="true"></span>
<span class="wh__pos" aria-hidden="true"><span class="wh__bar"></span></span>
<span class="wh__h wh__h--s"><span class="wh__grip" role="slider" tabindex="0" aria-label="시작" aria-valuemin="0" aria-valuemax="1440" aria-valuenow="540" aria-valuetext="09:00"></span></span>
<span class="wh__h wh__h--e"><span class="wh__grip" role="slider" tabindex="0" aria-label="종료" aria-valuemin="0" aria-valuemax="1440" aria-valuenow="1080" aria-valuetext="18:00"></span></span>
</div>
<span class="wh__read"><span data-k="1">09:00–18:00</span></span>
</div>
<div class="wh__row" data-d="tue" data-s="18" data-e="36" style="--s:.375;--e:.75">
<span class="wh__day"><span class="wh__full" id="wh-d2">화요일</span><span class="wh__ab" aria-hidden="true">화</span></span>
<div class="wh__axis" role="group" aria-labelledby="wh-d2">
<span class="wh__rail" aria-hidden="true"></span>
<span class="wh__pos" aria-hidden="true"><span class="wh__bar"></span></span>
<span class="wh__h wh__h--s"><span class="wh__grip" role="slider" tabindex="0" aria-label="시작" aria-valuemin="0" aria-valuemax="1440" aria-valuenow="540" aria-valuetext="09:00"></span></span>
<span class="wh__h wh__h--e"><span class="wh__grip" role="slider" tabindex="0" aria-label="종료" aria-valuemin="0" aria-valuemax="1440" aria-valuenow="1080" aria-valuetext="18:00"></span></span>
</div>
<span class="wh__read"><span data-k="1">09:00–18:00</span></span>
</div>
<div class="wh__row" data-d="wed" data-s="20" data-e="38" style="--s:.41667;--e:.79167">
<span class="wh__day"><span class="wh__full" id="wh-d3">수요일</span><span class="wh__ab" aria-hidden="true">수</span></span>
<div class="wh__axis" role="group" aria-labelledby="wh-d3">
<span class="wh__rail" aria-hidden="true"></span>
<span class="wh__pos" aria-hidden="true"><span class="wh__bar"></span></span>
<span class="wh__h wh__h--s"><span class="wh__grip" role="slider" tabindex="0" aria-label="시작" aria-valuemin="0" aria-valuemax="1440" aria-valuenow="600" aria-valuetext="10:00"></span></span>
<span class="wh__h wh__h--e"><span class="wh__grip" role="slider" tabindex="0" aria-label="종료" aria-valuemin="0" aria-valuemax="1440" aria-valuenow="1140" aria-valuetext="19:00"></span></span>
</div>
<span class="wh__read"><span data-k="1">10:00–19:00</span><span data-k="2" aria-hidden="true">10:00–22:00</span></span>
</div>
<div class="wh__row" data-d="thu" data-s="18" data-e="35" style="--s:.375;--e:.72917">
<span class="wh__day"><span class="wh__full" id="wh-d4">목요일</span><span class="wh__ab" aria-hidden="true">목</span></span>
<div class="wh__axis" role="group" aria-labelledby="wh-d4">
<span class="wh__rail" aria-hidden="true"></span>
<span class="wh__pos" aria-hidden="true"><span class="wh__bar"></span></span>
<span class="wh__h wh__h--s"><span class="wh__grip" role="slider" tabindex="0" aria-label="시작" aria-valuemin="0" aria-valuemax="1440" aria-valuenow="540" aria-valuetext="09:00"></span></span>
<span class="wh__h wh__h--e"><span class="wh__grip" role="slider" tabindex="0" aria-label="종료" aria-valuemin="0" aria-valuemax="1440" aria-valuenow="1050" aria-valuetext="17:30"></span></span>
</div>
<span class="wh__read"><span data-k="1">09:00–17:30</span></span>
</div>
<div class="wh__row" data-d="fri" data-s="18" data-e="32" style="--s:.375;--e:.66667">
<span class="wh__day"><span class="wh__full" id="wh-d5">금요일</span><span class="wh__ab" aria-hidden="true">금</span></span>
<div class="wh__axis" role="group" aria-labelledby="wh-d5">
<span class="wh__rail" aria-hidden="true"></span>
<span class="wh__pos" aria-hidden="true"><span class="wh__bar"></span></span>
<span class="wh__h wh__h--s"><span class="wh__grip" role="slider" tabindex="0" aria-label="시작" aria-valuemin="0" aria-valuemax="1440" aria-valuenow="540" aria-valuetext="09:00"></span></span>
<span class="wh__h wh__h--e"><span class="wh__grip" role="slider" tabindex="0" aria-label="종료" aria-valuemin="0" aria-valuemax="1440" aria-valuenow="960" aria-valuetext="16:00"></span></span>
</div>
<span class="wh__read"><span data-k="1">09:00–16:00</span></span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-mint;
@include stage($stage-ink);
.wh {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: $sp-2;
width: min(420px, 90vw);
overflow: hidden; // 손잡이 레이어가 축 폭만큼 밀려도 문서가 안 넘치게
color: $subject-cream;
}
.wh__scale,
.wh__row {
display: grid;
grid-template-columns: clamp(16px, 6vw, 40px) minmax(0, 1fr) clamp(62px, 22vw, 84px);
gap: $sp-2;
align-items: center;
}
.wh__marks {
display: flex;
justify-content: space-between;
}
.wh__marks span {
font-size: clamp(8px, 2.4vw, 10px);
line-height: 12px;
font-weight: 500;
color: rgba($subject-cream, .55);
}
.wh__day {
font-size: clamp(9px, 3vw, 12px);
line-height: 14px;
font-weight: 700;
white-space: nowrap;
}
.wh__ab { display: none; }
.wh__axis {
position: relative;
height: clamp(20px, 6.5vw, 26px);
}
.wh__rail {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: clamp(10px, 3.4vw, 14px);
translate: 0 -50%;
border-radius: $r-pill;
background-color: rgba($subject-cream, .14);
background-image: repeating-linear-gradient(90deg, rgba($subject-cream, .34) 0 1px, rgba($subject-cream, 0) 1px 25%);
}
// 막대는 자리를 translateX 로, 길이를 폭으로 — 늘여도 알약 모서리가 찌그러지지 않는다
.wh__pos {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 100%;
transform: translateX(calc(var(--s) * 100%));
}
.wh__bar {
position: absolute;
left: 0;
top: 50%;
width: calc((var(--e) - var(--s)) * 100%);
height: clamp(10px, 3.4vw, 14px);
translate: 0 -50%;
border-radius: $r-pill;
background-color: $color;
}
.wh__h {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 100%;
pointer-events: none;
}
.wh__h--s { transform: translateX(calc(var(--s) * 100%)); }
.wh__h--e { transform: translateX(calc(var(--e) * 100%)); }
.wh__grip {
position: absolute;
left: 0;
top: 50%;
width: clamp(12px, 4vw, 16px);
height: clamp(12px, 4vw, 16px);
translate: -50% -50%;
border-radius: 50%;
background-color: $subject-cream;
box-shadow: $shadow-press;
cursor: ew-resize;
pointer-events: auto;
touch-action: none;
}
.wh__grip:focus-visible { outline: 3px solid $color; outline-offset: 2px; }
.wh__read {
position: relative;
height: 14px;
}
.wh__read span {
position: absolute;
right: 0;
top: 0;
font-size: clamp(8px, 2.6vw, 11px);
line-height: 14px;
font-weight: 700;
white-space: nowrap;
color: rgba($subject-cream, .88);
}
.wh__read span[data-k="2"] { opacity: 0; }
// ── 자동 루프: 수요일 끝 손잡이가 늘어났다 줄고, 나머지 줄은 시차로 밝아진다 ──
.wh.is-demo .wh__row[data-d="wed"] .wh__bar,
.wh.is-demo .wh__row[data-d="wed"] .wh__h--e {
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.wh.is-demo .wh__row[data-d="wed"] .wh__bar { animation-name: wh-grow; }
.wh.is-demo .wh__row[data-d="wed"] .wh__h--e { animation-name: wh-end; }
@keyframes wh-grow {
0%, 10% { width: 37.5%; }
36%, 52% { width: 50%; }
78%, 100% { width: 37.5%; }
}
@keyframes wh-end {
0%, 10% { transform: translateX(79.167%) scale(1); }
36% { transform: translateX(91.667%) scale(1.18); }
42%, 52% { transform: translateX(91.667%) scale(1); }
78%, 100% { transform: translateX(79.167%) scale(1); }
}
// 끄는 동안 다시 계산된 숫자는 한 프레임에 컷으로 갈린다
.wh.is-demo .wh__row[data-d="wed"] .wh__read span {
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.wh.is-demo .wh__row[data-d="wed"] .wh__read span[data-k="1"] { animation-name: wh-t1; }
.wh.is-demo .wh__row[data-d="wed"] .wh__read span[data-k="2"] { animation-name: wh-t2; }
@keyframes wh-t1 { 0% { opacity: 1; } 36% { opacity: 0; } 78% { opacity: 1; } 100% { opacity: 1; } }
@keyframes wh-t2 { 0% { opacity: 0; } 36% { opacity: 1; } 78% { opacity: 0; } 100% { opacity: 0; } }
.wh.is-demo .wh__row:not([data-d="wed"]) {
animation-name: wh-glow;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.wh.is-demo .wh__row[data-d="tue"] { animation-delay: 120ms; }
.wh.is-demo .wh__row[data-d="thu"] { animation-delay: 240ms; }
.wh.is-demo .wh__row[data-d="fri"] { animation-delay: 360ms; }
@keyframes wh-glow {
0% { opacity: .55; }
22% { opacity: 1; }
48%, 100% { opacity: .55; }
}
@media (max-width: 340px) {
.wh { gap: $sp-1; }
.wh__scale,
.wh__row { gap: $sp-1; }
.wh__full {
display: inline-block;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.wh__ab { display: inline; }
.wh__read { height: 12px; }
.wh__read span { line-height: 12px; }
.wh__marks span { line-height: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.wh.is-demo .wh__row,
.wh.is-demo .wh__bar,
.wh.is-demo .wh__h--e,
.wh.is-demo .wh__read span { animation: none !important; }
.wh.is-demo .wh__read span[data-k="2"] { opacity: 0; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.wh {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 8px;
width: min(420px, 90vw);
overflow: hidden;
color: #fff7e6;
}
.wh__scale,
.wh__row {
display: grid;
grid-template-columns: clamp(16px, 6vw, 40px) minmax(0, 1fr) clamp(62px, 22vw, 84px);
gap: 8px;
align-items: center;
}
.wh__marks {
display: flex;
justify-content: space-between;
}
.wh__marks span {
font-size: clamp(8px, 2.4vw, 10px);
line-height: 12px;
font-weight: 500;
color: rgba(255, 247, 230, 0.55);
}
.wh__day {
font-size: clamp(9px, 3vw, 12px);
line-height: 14px;
font-weight: 700;
white-space: nowrap;
}
.wh__ab {
display: none;
}
.wh__axis {
position: relative;
height: clamp(20px, 6.5vw, 26px);
}
.wh__rail {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: clamp(10px, 3.4vw, 14px);
translate: 0 -50%;
border-radius: 999px;
background-color: rgba(255, 247, 230, 0.14);
background-image: repeating-linear-gradient(90deg, rgba(255, 247, 230, 0.34) 0 1px, rgba(255, 247, 230, 0) 1px 25%);
}
.wh__pos {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 100%;
transform: translateX(calc(var(--s) * 100%));
}
.wh__bar {
position: absolute;
left: 0;
top: 50%;
width: calc((var(--e) - var(--s)) * 100%);
height: clamp(10px, 3.4vw, 14px);
translate: 0 -50%;
border-radius: 999px;
background-color: #4cd4a6;
}
.wh__h {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 100%;
pointer-events: none;
}
.wh__h--s {
transform: translateX(calc(var(--s) * 100%));
}
.wh__h--e {
transform: translateX(calc(var(--e) * 100%));
}
.wh__grip {
position: absolute;
left: 0;
top: 50%;
width: clamp(12px, 4vw, 16px);
height: clamp(12px, 4vw, 16px);
translate: -50% -50%;
border-radius: 50%;
background-color: #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
cursor: ew-resize;
pointer-events: auto;
touch-action: none;
}
.wh__grip:focus-visible {
outline: 3px solid #4cd4a6;
outline-offset: 2px;
}
.wh__read {
position: relative;
height: 14px;
}
.wh__read span {
position: absolute;
right: 0;
top: 0;
font-size: clamp(8px, 2.6vw, 11px);
line-height: 14px;
font-weight: 700;
white-space: nowrap;
color: rgba(255, 247, 230, 0.88);
}
.wh__read span[data-k="2"] {
opacity: 0;
}
.wh.is-demo .wh__row[data-d=wed] .wh__bar,
.wh.is-demo .wh__row[data-d=wed] .wh__h--e {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.wh.is-demo .wh__row[data-d=wed] .wh__bar {
animation-name: wh-grow;
}
.wh.is-demo .wh__row[data-d=wed] .wh__h--e {
animation-name: wh-end;
}
@keyframes wh-grow {
0%, 10% {
width: 37.5%;
}
36%, 52% {
width: 50%;
}
78%, 100% {
width: 37.5%;
}
}
@keyframes wh-end {
0%, 10% {
transform: translateX(79.167%) scale(1);
}
36% {
transform: translateX(91.667%) scale(1.18);
}
42%, 52% {
transform: translateX(91.667%) scale(1);
}
78%, 100% {
transform: translateX(79.167%) scale(1);
}
}
.wh.is-demo .wh__row[data-d=wed] .wh__read span {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.wh.is-demo .wh__row[data-d=wed] .wh__read span[data-k="1"] {
animation-name: wh-t1;
}
.wh.is-demo .wh__row[data-d=wed] .wh__read span[data-k="2"] {
animation-name: wh-t2;
}
@keyframes wh-t1 {
0% {
opacity: 1;
}
36% {
opacity: 0;
}
78% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes wh-t2 {
0% {
opacity: 0;
}
36% {
opacity: 1;
}
78% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.wh.is-demo .wh__row:not([data-d=wed]) {
animation-name: wh-glow;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.wh.is-demo .wh__row[data-d=tue] {
animation-delay: 120ms;
}
.wh.is-demo .wh__row[data-d=thu] {
animation-delay: 240ms;
}
.wh.is-demo .wh__row[data-d=fri] {
animation-delay: 360ms;
}
@keyframes wh-glow {
0% {
opacity: 0.55;
}
22% {
opacity: 1;
}
48%, 100% {
opacity: 0.55;
}
}
@media (max-width: 340px) {
.wh {
gap: 4px;
}
.wh__scale,
.wh__row {
gap: 4px;
}
.wh__full {
display: inline-block;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.wh__ab {
display: inline;
}
.wh__read {
height: 12px;
}
.wh__read span {
line-height: 12px;
}
.wh__marks span {
line-height: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.wh.is-demo .wh__row,
.wh.is-demo .wh__bar,
.wh.is-demo .wh__h--e,
.wh.is-demo .wh__read span {
animation: none !important;
}
.wh.is-demo .wh__read span[data-k="2"] {
opacity: 0;
}
}(function () {
var root = document.querySelector('.wh');
var rows = [].slice.call(root.querySelectorAll('.wh__row'));
var drag = null;
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);
}
function at(r, e) {
var b = r.querySelector('.wh__axis').getBoundingClientRect();
return Math.round((e.clientX - b.left) / b.width * 48);
}
function grip(e) { return e.target.closest ? e.target.closest('.wh__grip') : null; }
function side(g) { return g.parentNode.classList.contains('wh__h--e') ? 1 : 0; }
root.addEventListener('pointerdown', function (e) {
var g = grip(e); if (!g) return;
root.classList.remove('is-demo');
drag = { r: g.closest('.wh__row'), i: side(g) };
g.setPointerCapture(e.pointerId); g.focus({ preventScroll: true }); e.preventDefault();
});
root.addEventListener('pointermove', function (e) { if (drag) set(drag.r, drag.i, at(drag.r, e)); });
root.addEventListener('pointerup', function () { drag = null; });
root.addEventListener('keydown', function (e) {
var g = grip(e); if (!g) return;
var d = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
if (!d && e.key !== 'Home' && e.key !== 'End') return;
root.classList.remove('is-demo'); e.preventDefault();
var r = g.closest('.wh__row'), i = side(g);
set(r, i, e.key === 'Home' ? 0 : e.key === 'End' ? 48 : (i ? +r.dataset.e : +r.dataset.s) + d);
});
rows.forEach(draw);
})();import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./WorkingHoursDayBars.module.scss";
export type WorkingHoursDayBarsProps = {
labels?: { full: string[]; abbr: string[]; start: string; end: string; marks: string[] };
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type RowVars = CSSProperties & { "--s"?: string; "--e"?: string };
type PtrEvt = {
clientX: number; pointerId: number; preventDefault(): void;
currentTarget: { setPointerCapture(id: number): void };
};
type KeyEvt = { key: string; preventDefault(): void };
type Row = { key: string; s: number; e: number };
const START: Row[] = [
{ key: "mon", s: 18, e: 36 },
{ key: "tue", s: 18, e: 36 },
{ key: "wed", s: 20, e: 38 },
{ key: "thu", s: 18, e: 35 },
{ key: "fri", s: 18, e: 32 },
];
function clamp(x: number, lo: number, hi: number): number { return Math.min(Math.max(x, lo), hi); }
function fmt(t: number): string { // 30분 한 칸
const h = Math.floor(t / 2);
return (h < 10 ? "0" : "") + h + ":" + (t % 2 ? "30" : "00");
}
export default function WorkingHoursDayBars({
labels = {
full: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
abbr: ["Mo", "Tu", "We", "Th", "Fr"],
start: "Start",
end: "End",
marks: ["0", "6", "12", "18", "24"],
},
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#4cd4a6",
}: WorkingHoursDayBarsProps) {
const [demo, setDemo] = useState(true);
const [rows, setRows] = useState<Row[]>(START);
const axes = useRef<(HTMLDivElement | null)[]>([]);
const drag = useRef<{ r: number; i: number } | null>(null);
const vars: Vars = { "--duration": duration + "ms", "--easing": easing, "--color": color };
function set(r: number, i: number, t: number) {
setRows((p) => p.map((row, n) => {
if (n !== r) return row;
return i ? { ...row, e: clamp(t, row.s + 1, 48) } : { ...row, s: clamp(t, 0, row.e - 1) };
}));
}
function at(r: number, e: PtrEvt): number {
const list = axes.current;
const el = list ? list[r] : null;
if (!el) return 0;
const b = el.getBoundingClientRect();
return Math.round(((e.clientX - b.left) / b.width) * 48);
}
function down(r: number, i: number) {
return (e: PtrEvt) => {
setDemo(false);
drag.current = { r, i };
e.currentTarget.setPointerCapture(e.pointerId); // 손가락이 축을 벗어나도 계속 따라온다
e.preventDefault();
};
}
function move(e: PtrEvt) {
const d = drag.current;
if (d) set(d.r, d.i, at(d.r, e));
}
function up() { drag.current = null; }
function key(r: number, i: number) {
return (e: KeyEvt) => {
const d = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
if (!d && e.key !== "Home" && e.key !== "End") return;
setDemo(false);
e.preventDefault();
const row = rows[r];
const t = e.key === "Home" ? 0 : e.key === "End" ? 48 : (i ? row.e : row.s) + d;
set(r, i, t);
};
}
return (
<div className={styles.wh + (demo ? " " + styles.isDemo : "")} style={vars}>
<div className={styles.scale} aria-hidden="true">
<span />
<span className={styles.marks}>{labels.marks.map((m) => <span key={m}>{m}</span>)}</span>
<span />
</div>
{rows.map((row, r) => {
const rv: RowVars = { "--s": String(row.s / 48), "--e": String(row.e / 48) };
return (
<div className={styles.row} data-d={row.key} key={row.key} style={rv}>
<span className={styles.day}>
<span className={styles.full} id={"wh-d" + r}>{labels.full[r]}</span>
<span className={styles.ab} aria-hidden="true">{labels.abbr[r]}</span>
</span>
<div className={styles.axis} role="group" aria-labelledby={"wh-d" + r}
ref={(el: HTMLDivElement | null) => { if (axes.current) axes.current[r] = el; }}>
<span className={styles.rail} aria-hidden="true" />
<span className={styles.pos} aria-hidden="true"><span className={styles.bar} /></span>
{[0, 1].map((i) => (
<span key={i} className={styles.h + " " + (i ? styles.hE : styles.hS)}>
<span
className={styles.grip}
role="slider"
tabIndex={0}
aria-label={i ? labels.end : labels.start}
aria-valuemin={0}
aria-valuemax={1440}
aria-valuenow={(i ? row.e : row.s) * 30}
aria-valuetext={fmt(i ? row.e : row.s)}
onPointerDown={down(r, i)}
onPointerMove={move}
onPointerUp={up}
onKeyDown={key(r, i)}
/>
</span>
))}
</div>
<span className={styles.read}>
<span data-k="1">{fmt(row.s) + "–" + fmt(row.e)}</span>
{row.key === "wed" ? <span data-k="2" aria-hidden="true">{fmt(row.s) + "–" + fmt(44)}</span> : null}
</span>
</div>
);
})}
</div>
);
}05 · 요일마다 한 줄씩 늘리는 근무 시간 바 — 측정 누적면적 13.674% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
가게 영업시간이나 상담 가능 시간을 요일별로 등록하는 관리자 화면이 이 모양입니다.
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칩을 눌러 문장이 되는 반복 규칙
어떤 움직임인가
요일 칩을 켜고 끌 때마다 아래 문장이 매주 월·수·금처럼 다시 쓰입니다. 매월로 바꾸면 같은 칩이 몇째 주 무슨 요일이라는 뜻으로 읽히는데, 그 주차는 기준일에서 해당 요일이 처음 오는 날짜를 세어 구합니다.

<main class="stage">
<div class="rc is-demo">
<div class="rc__seg" id="rc-seg" data-m="w" role="radiogroup" aria-label="반복 주기">
<span class="rc__ind" aria-hidden="true"></span>
<button class="rc__opt" type="button" role="radio" aria-checked="true" data-m="w">매주</button>
<button class="rc__opt" type="button" role="radio" aria-checked="false" data-m="m">매월</button>
</div>
<div class="rc__days" role="group" aria-label="반복 요일">
<button class="rc__chip" type="button" role="checkbox" aria-checked="false" aria-label="일요일" data-w="0">일</button>
<button class="rc__chip" type="button" role="checkbox" aria-checked="true" aria-label="월요일" data-w="1">월</button>
<button class="rc__chip" type="button" role="checkbox" aria-checked="false" aria-label="화요일" data-w="2">화</button>
<button class="rc__chip" type="button" role="checkbox" aria-checked="true" aria-label="수요일" data-w="3">수</button>
<button class="rc__chip" type="button" role="checkbox" aria-checked="false" aria-label="목요일" data-w="4">목</button>
<button class="rc__chip" type="button" role="checkbox" aria-checked="true" aria-label="금요일" data-w="5">금</button>
<button class="rc__chip" type="button" role="checkbox" aria-checked="false" aria-label="토요일" data-w="6">토</button>
</div>
<p class="rc__sum" aria-live="polite">
<span class="rc__cut" data-k="1">매주 월·수·금 오전 9시</span>
<span class="rc__cut" data-k="2" aria-hidden="true">매주 월·수·목·금 오전 9시</span>
<span class="rc__cut" data-k="3" aria-hidden="true">매주 월·화·수·목·금 오전 9시</span>
<span class="rc__cut" data-k="4" aria-hidden="true">매월 둘째 주 월요일 오전 9시</span>
</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-ink;
@include stage($stage-yellow);
.rc {
--seg-w: 72px; // 세그먼트 한 칸 폭 = 인디케이터가 옮겨가는 거리 (BD19: 칸은 flex: 0 0 auto)
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
width: min(300px, 90vw);
color: $color;
}
// ── 매주 / 매월 세그먼트 두 칸 ──
.rc__seg {
position: relative;
display: flex;
gap: 0;
padding: $sp-1;
border-radius: $r-pill;
background-color: rgba($subject-ink, .16);
}
.rc__ind {
position: absolute;
left: $sp-1;
top: $sp-1;
width: var(--seg-w);
height: 28px;
border-radius: $r-pill;
background-color: $color;
transform: translateX(0);
transition: transform $dur-base $easing;
}
.rc__seg[data-m="m"] .rc__ind { transform: translateX(var(--seg-w)); }
.rc__opt {
position: relative;
flex: 0 0 auto;
width: var(--seg-w);
height: 28px;
margin: 0;
padding: 0;
border: 0;
border-radius: $r-pill;
background-color: transparent;
color: $color;
font-size: clamp(12px, 3.6vw, 13px);
line-height: 28px;
font-weight: 700;
cursor: pointer;
}
.rc__opt[aria-checked="true"] { color: $stage-yellow; }
.rc__opt:focus-visible { outline: 3px solid $color; outline-offset: 2px; }
// ── 요일 칩 일곱 개 ──
.rc__days {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: $sp-1;
width: 100%;
}
.rc__chip {
margin: 0;
padding: 0;
height: 30px;
border: 2px solid $color;
border-radius: $r-pill;
background-color: transparent;
color: $color;
font-size: clamp(11px, 3.4vw, 13px);
line-height: 26px;
font-weight: 700;
cursor: pointer;
}
.rc__chip[aria-checked="true"] { background-color: $color; color: $stage-yellow; }
.rc__chip:focus-visible { outline: 3px solid $color; outline-offset: 2px; }
// ── 라이브 요약 문장 ──
.rc__sum {
position: relative;
margin: 0;
width: 100%;
height: 38px;
}
.rc__cut {
position: absolute;
left: 0;
right: 0;
top: 0;
text-align: center;
font-size: clamp(12px, 3.8vw, 14px);
line-height: 19px;
font-weight: 700;
opacity: 0;
}
.rc__cut[data-k="1"] { opacity: 1; }
// ── 자동 루프: 칩 두 개가 차례로 켜지고 문장이 컷으로 갈리며 세그먼트가 한 번 옮겨간다 ──
.rc.is-demo .rc__ind {
animation-name: rc-ind;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
// 인디케이터가 매월로 옮겨 앉는 66% 가 이 데모의 주인공 장면이다 — 요약 문장도 그 프레임에 함께 갈린다
@keyframes rc-ind {
0%, 62% { transform: translateX(0); }
70%, 76% { transform: translateX(var(--seg-w)); }
86%, 100% { transform: translateX(0); }
}
.rc.is-demo .rc__opt {
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.rc.is-demo .rc__opt[data-m="w"] { animation-name: rc-ow; }
.rc.is-demo .rc__opt[data-m="m"] { animation-name: rc-om; }
// 라벨 색은 인디케이터가 그 칸을 덮는 순간에 맞춰 갈린다 — 어긋나면 먹 글자가 먹 알약 위에 얹혀 사라진다
@keyframes rc-ow { 0% { color: $stage-yellow; } 64% { color: $color; } 78% { color: $stage-yellow; } 100% { color: $stage-yellow; } }
@keyframes rc-om { 0% { color: $color; } 64% { color: $stage-yellow; } 78% { color: $color; } 100% { color: $color; } }
// 켜짐은 한 프레임에 컷 — 그 옆에 눌림 반동(scale)을 붙여 프레임을 채운다
.rc.is-demo .rc__chip[data-w="4"],
.rc.is-demo .rc__chip[data-w="2"] {
animation-duration: $duration, $duration;
animation-timing-function: steps(1, end), $ease-pop;
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
.rc.is-demo .rc__chip[data-w="4"] { animation-name: rc-on4, rc-pop4; }
.rc.is-demo .rc__chip[data-w="2"] { animation-name: rc-on2, rc-pop2; }
// 끄기는 한 프레임에 몰지 않고 74% · 82% 로 나눈다 (되돌림이 최대 변화가 되면 poster 가 빈 장면이 된다)
@keyframes rc-on4 {
0% { background-color: transparent; color: $color; }
20% { background-color: $color; color: $stage-yellow; }
74% { background-color: transparent; color: $color; }
100% { background-color: transparent; color: $color; }
}
@keyframes rc-on2 {
0% { background-color: transparent; color: $color; }
40% { background-color: $color; color: $stage-yellow; }
82% { background-color: transparent; color: $color; }
100% { background-color: transparent; color: $color; }
}
@keyframes rc-pop4 {
0%, 19% { transform: scale(1); }
22% { transform: scale(.86); }
29% { transform: scale(1.12); }
36%, 100% { transform: scale(1); }
}
@keyframes rc-pop2 {
0%, 39% { transform: scale(1); }
42% { transform: scale(.86); }
49% { transform: scale(1.12); }
56%, 100% { transform: scale(1); }
}
.rc.is-demo .rc__cut {
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.rc.is-demo .rc__cut[data-k="1"] { animation-name: rc-k1; }
.rc.is-demo .rc__cut[data-k="2"] { animation-name: rc-k2; }
.rc.is-demo .rc__cut[data-k="3"] { animation-name: rc-k3; }
.rc.is-demo .rc__cut[data-k="4"] { animation-name: rc-k4; }
@keyframes rc-k1 { 0% { opacity: 1; } 20% { opacity: 0; } 86% { opacity: 1; } 100% { opacity: 1; } }
@keyframes rc-k2 { 0% { opacity: 0; } 20% { opacity: 1; } 40% { opacity: 0; } 100% { opacity: 0; } }
@keyframes rc-k3 { 0% { opacity: 0; } 40% { opacity: 1; } 66% { opacity: 0; } 100% { opacity: 0; } }
@keyframes rc-k4 { 0% { opacity: 0; } 66% { opacity: 1; } 86% { opacity: 0; } 100% { opacity: 0; } }
.rc.is-demo .rc__sum {
animation-name: rc-drop;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes rc-drop {
0%, 19% { transform: translateY(0); }
22% { transform: translateY(6px); }
29% { transform: translateY(0); }
39% { transform: translateY(0); }
42% { transform: translateY(6px); }
49% { transform: translateY(0); }
65% { transform: translateY(0); }
68% { transform: translateY(6px); }
75%, 100% { transform: translateY(0); }
}
@media (max-width: 340px) {
.rc { --seg-w: 60px; gap: $sp-2; }
.rc__chip { height: 28px; line-height: 24px; }
.rc__sum { height: 36px; }
.rc__cut { line-height: 18px; }
}
@media (prefers-reduced-motion: reduce) {
.rc.is-demo .rc__ind,
.rc.is-demo .rc__opt,
.rc.is-demo .rc__chip,
.rc.is-demo .rc__cut,
.rc.is-demo .rc__sum { animation: none !important; }
.rc__ind { transition: none !important; }
.rc.is-demo .rc__cut[data-k="1"] { 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);
}
.rc {
--seg-w: 72px;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: min(300px, 90vw);
color: #17141a;
}
.rc__seg {
position: relative;
display: flex;
gap: 0;
padding: 4px;
border-radius: 999px;
background-color: rgba(23, 20, 26, 0.16);
}
.rc__ind {
position: absolute;
left: 4px;
top: 4px;
width: var(--seg-w);
height: 28px;
border-radius: 999px;
background-color: #17141a;
transform: translateX(0);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.rc__seg[data-m=m] .rc__ind {
transform: translateX(var(--seg-w));
}
.rc__opt {
position: relative;
flex: 0 0 auto;
width: var(--seg-w);
height: 28px;
margin: 0;
padding: 0;
border: 0;
border-radius: 999px;
background-color: transparent;
color: #17141a;
font-size: clamp(12px, 3.6vw, 13px);
line-height: 28px;
font-weight: 700;
cursor: pointer;
}
.rc__opt[aria-checked=true] {
color: #ffd23f;
}
.rc__opt:focus-visible {
outline: 3px solid #17141a;
outline-offset: 2px;
}
.rc__days {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
width: 100%;
}
.rc__chip {
margin: 0;
padding: 0;
height: 30px;
border: 2px solid #17141a;
border-radius: 999px;
background-color: transparent;
color: #17141a;
font-size: clamp(11px, 3.4vw, 13px);
line-height: 26px;
font-weight: 700;
cursor: pointer;
}
.rc__chip[aria-checked=true] {
background-color: #17141a;
color: #ffd23f;
}
.rc__chip:focus-visible {
outline: 3px solid #17141a;
outline-offset: 2px;
}
.rc__sum {
position: relative;
margin: 0;
width: 100%;
height: 38px;
}
.rc__cut {
position: absolute;
left: 0;
right: 0;
top: 0;
text-align: center;
font-size: clamp(12px, 3.8vw, 14px);
line-height: 19px;
font-weight: 700;
opacity: 0;
}
.rc__cut[data-k="1"] {
opacity: 1;
}
.rc.is-demo .rc__ind {
animation-name: rc-ind;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes rc-ind {
0%, 62% {
transform: translateX(0);
}
70%, 76% {
transform: translateX(var(--seg-w));
}
86%, 100% {
transform: translateX(0);
}
}
.rc.is-demo .rc__opt {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.rc.is-demo .rc__opt[data-m=w] {
animation-name: rc-ow;
}
.rc.is-demo .rc__opt[data-m=m] {
animation-name: rc-om;
}
@keyframes rc-ow {
0% {
color: #ffd23f;
}
64% {
color: #17141a;
}
78% {
color: #ffd23f;
}
100% {
color: #ffd23f;
}
}
@keyframes rc-om {
0% {
color: #17141a;
}
64% {
color: #ffd23f;
}
78% {
color: #17141a;
}
100% {
color: #17141a;
}
}
.rc.is-demo .rc__chip[data-w="4"],
.rc.is-demo .rc__chip[data-w="2"] {
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
.rc.is-demo .rc__chip[data-w="4"] {
animation-name: rc-on4, rc-pop4;
}
.rc.is-demo .rc__chip[data-w="2"] {
animation-name: rc-on2, rc-pop2;
}
@keyframes rc-on4 {
0% {
background-color: transparent;
color: #17141a;
}
20% {
background-color: #17141a;
color: #ffd23f;
}
74% {
background-color: transparent;
color: #17141a;
}
100% {
background-color: transparent;
color: #17141a;
}
}
@keyframes rc-on2 {
0% {
background-color: transparent;
color: #17141a;
}
40% {
background-color: #17141a;
color: #ffd23f;
}
82% {
background-color: transparent;
color: #17141a;
}
100% {
background-color: transparent;
color: #17141a;
}
}
@keyframes rc-pop4 {
0%, 19% {
transform: scale(1);
}
22% {
transform: scale(0.86);
}
29% {
transform: scale(1.12);
}
36%, 100% {
transform: scale(1);
}
}
@keyframes rc-pop2 {
0%, 39% {
transform: scale(1);
}
42% {
transform: scale(0.86);
}
49% {
transform: scale(1.12);
}
56%, 100% {
transform: scale(1);
}
}
.rc.is-demo .rc__cut {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.rc.is-demo .rc__cut[data-k="1"] {
animation-name: rc-k1;
}
.rc.is-demo .rc__cut[data-k="2"] {
animation-name: rc-k2;
}
.rc.is-demo .rc__cut[data-k="3"] {
animation-name: rc-k3;
}
.rc.is-demo .rc__cut[data-k="4"] {
animation-name: rc-k4;
}
@keyframes rc-k1 {
0% {
opacity: 1;
}
20% {
opacity: 0;
}
86% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes rc-k2 {
0% {
opacity: 0;
}
20% {
opacity: 1;
}
40% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes rc-k3 {
0% {
opacity: 0;
}
40% {
opacity: 1;
}
66% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes rc-k4 {
0% {
opacity: 0;
}
66% {
opacity: 1;
}
86% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.rc.is-demo .rc__sum {
animation-name: rc-drop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes rc-drop {
0%, 19% {
transform: translateY(0);
}
22% {
transform: translateY(6px);
}
29% {
transform: translateY(0);
}
39% {
transform: translateY(0);
}
42% {
transform: translateY(6px);
}
49% {
transform: translateY(0);
}
65% {
transform: translateY(0);
}
68% {
transform: translateY(6px);
}
75%, 100% {
transform: translateY(0);
}
}
@media (max-width: 340px) {
.rc {
--seg-w: 60px;
gap: 8px;
}
.rc__chip {
height: 28px;
line-height: 24px;
}
.rc__sum {
height: 36px;
}
.rc__cut {
line-height: 18px;
}
}
@media (prefers-reduced-motion: reduce) {
.rc.is-demo .rc__ind,
.rc.is-demo .rc__opt,
.rc.is-demo .rc__chip,
.rc.is-demo .rc__cut,
.rc.is-demo .rc__sum {
animation: none !important;
}
.rc__ind {
transition: none !important;
}
.rc.is-demo .rc__cut[data-k="1"] {
opacity: 1;
}
}(function () {
var root = document.querySelector('.rc'), seg = document.getElementById('rc-seg');
var sum = root.querySelector('.rc__cut[data-k="1"]');
var opts = [].slice.call(root.querySelectorAll('.rc__opt'));
var chips = [].slice.call(root.querySelectorAll('.rc__chip'));
var SH = ['일', '월', '화', '수', '목', '금', '토'];
var OR = ['첫째', '둘째', '셋째', '넷째', '다섯째'];
function on(c) { return c.getAttribute('aria-checked') === 'true'; }
// 매월 모드의 몇째 주는 실제로 센다 — 기준일에서 그 요일이 처음 오는 날짜로 주차를 구한다
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시';
}
function wake() { root.classList.remove('is-demo'); }
chips.forEach(function (c) {
c.addEventListener('click', function () { wake(); c.setAttribute('aria-checked', String(!on(c))); render(); });
});
opts.forEach(function (o) {
o.addEventListener('click', function () {
wake();
opts.forEach(function (x) { x.setAttribute('aria-checked', String(x === o)); });
seg.dataset.m = o.dataset.m; render();
});
});
seg.addEventListener('keydown', function (e) {
var d = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
if (!d) return;
e.preventDefault(); wake();
var i = (opts.indexOf(document.activeElement) + d + 2) % 2;
opts[i].focus({ preventScroll: true }); opts[i].click();
});
root.querySelector('.rc__days').addEventListener('keydown', function (e) {
var d = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
if (!d) return;
e.preventDefault();
var i = (chips.indexOf(document.activeElement) + d + 7) % 7;
chips[i].focus({ preventScroll: true });
});
render();
})();import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./RecurrenceWeekdayChips.module.scss";
export type RecurrenceWeekdayChipsProps = {
labels?: {
cycle: string; weekly: string; monthly: string;
days: string; abbr: string[]; full: string[]; ordinals: string[];
at: string; none: string;
};
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault(): void };
const DEMO_CUTS = [
[1, 3, 5],
[1, 3, 4, 5],
[1, 2, 3, 4, 5],
];
export default function RecurrenceWeekdayChips({
labels = {
cycle: "Repeat cycle",
weekly: "Weekly",
monthly: "Monthly",
days: "Repeat days",
abbr: ["S", "M", "T", "W", "T", "F", "S"],
full: ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"],
ordinals: ["1st", "2nd", "3rd", "4th", "5th"],
at: "at 9:00 AM",
none: "no days selected",
},
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#17141a",
}: RecurrenceWeekdayChipsProps) {
const [demo, setDemo] = useState(true);
const [monthly, setMonthly] = useState(false);
const [picked, setPicked] = useState<number[]>([1, 3, 5]);
const vars: Vars = { "--duration": duration + "ms", "--easing": easing, "--color": color };
// 매월 모드의 몇째 주는 실제로 센다 — 기준일에서 그 요일이 처음 오는 날짜로 주차를 구한다
function ordinal(w: number): string {
const d = new Date(2026, 8, 12);
d.setDate(d.getDate() + ((w - d.getDay() + 7) % 7));
return labels.ordinals[Math.ceil(d.getDate() / 7) - 1];
}
function sentence(mode: boolean, p: number[]): string {
const head = mode ? labels.monthly : labels.weekly;
if (!p.length) return head + " — " + labels.none;
if (mode) return head + " on the " + ordinal(p[0]) + " " + labels.full[p[0]] + " " + labels.at;
if (p.length === 1) return head + " on " + labels.full[p[0]] + " " + labels.at;
return head + " on " + p.map((w) => labels.full[w].slice(0, 3)).join(", ") + " " + labels.at;
}
function wake() { setDemo(false); }
function toggle(w: number) {
wake();
setPicked((p) => (p.indexOf(w) < 0 ? p.concat([w]).sort((x, y) => x - y) : p.filter((x) => x !== w)));
}
function segKey(e: KeyEvt) {
const d = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
if (!d) return;
e.preventDefault();
wake();
setMonthly((m) => !m);
}
const cuts = [
sentence(false, DEMO_CUTS[0]),
sentence(false, DEMO_CUTS[1]),
sentence(false, DEMO_CUTS[2]),
sentence(true, [1]),
];
return (
<div className={styles.rc + (demo ? " " + styles.isDemo : "")} style={vars}>
<div className={styles.seg} data-m={monthly ? "m" : "w"} role="radiogroup"
aria-label={labels.cycle} onKeyDown={segKey}>
<span className={styles.ind} aria-hidden="true" />
<button className={styles.opt} type="button" role="radio" data-m="w"
aria-checked={!monthly} onClick={() => { wake(); setMonthly(false); }}>
{labels.weekly}
</button>
<button className={styles.opt} type="button" role="radio" data-m="m"
aria-checked={monthly} onClick={() => { wake(); setMonthly(true); }}>
{labels.monthly}
</button>
</div>
<div className={styles.days} role="group" aria-label={labels.days}>
{labels.abbr.map((a, w) => (
<button key={w} className={styles.chip} type="button" role="checkbox" data-w={w}
aria-checked={picked.indexOf(w) >= 0} aria-label={labels.full[w]}
onClick={() => toggle(w)}>
{a}
</button>
))}
</div>
<p className={styles.sum} aria-live="polite">
<span className={styles.cut} data-k="1">{demo ? cuts[0] : sentence(monthly, picked)}</span>
<span className={styles.cut} data-k="2" aria-hidden="true">{cuts[1]}</span>
<span className={styles.cut} data-k="3" aria-hidden="true">{cuts[2]}</span>
<span className={styles.cut} data-k="4" aria-hidden="true">{cuts[3]}</span>
</p>
</div>
);
}06 · 칩을 눌러 문장이 되는 반복 규칙 — 측정 누적면적 6.613% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
반복 일정이나 정기 알림처럼 규칙을 말로 확인시켜야 하는 설정 창에 씁니다.
// 매월 모드의 몇째 주는 실제로 센다 — 기준일에서 그 요일이 처음 오는 날짜로 주차를 구한다
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 안 템플릿에 있고 분 계산만 자바스크립트가 맡아, 같은 결과가 화면과 읽히는 영역으로 함께 나갑니다.

<main class="stage">
<div class="rp is-demo">
<p class="rp__base"><span class="rp__baselab">기준 시각</span><time class="rp__basenum" datetime="14:40">14:40</time></p>
<div class="rp__chips" role="group" aria-label="상대 시간 프리셋">
<button class="rp__chip" type="button" data-i="0" data-add="10" aria-pressed="true">10분 뒤</button>
<button class="rp__chip" type="button" data-i="1" data-add="60" aria-pressed="false">1시간 뒤</button>
<button class="rp__chip" type="button" data-i="2" data-at="1080" aria-pressed="false">오늘 저녁</button>
<button class="rp__chip" type="button" data-i="3" data-at="1920" aria-pressed="false">내일 아침</button>
</div>
<div class="rp__out">
<span class="rp__cap">
<span class="rp__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M216 136a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M128 40a96 96 0 1 0 96 96a96.11 96.11 0 0 0-96-96m0 176a80 80 0 1 1 80-80a80.09 80.09 0 0 1-80 80M61.66 37.66l-32 32a8 8 0 0 1-11.32-11.32l32-32a8 8 0 0 1 11.32 11.32m176 32a8 8 0 0 1-11.32 0l-32-32a8 8 0 0 1 11.32-11.32l32 32a8 8 0 0 1 0 11.32M184 128a8 8 0 0 1 0 16h-56a8 8 0 0 1-8-8V80a8 8 0 0 1 16 0v48Z"/></g></svg></span>
<span class="rp__caplab">알림 시각</span>
</span>
<span class="rp__slot" id="rp-slot">
<span class="rp__val rp__val--cur" id="rp-cur">오늘 14:50</span>
<span class="rp__val rp__val--nxt" id="rp-nxt"></span>
<span class="rp__f" data-k="a">오늘 14:50</span>
<span class="rp__f" data-k="b">내일 08:00</span>
</span>
</div>
<p class="rp__live" id="rp-live" aria-live="polite"></p>
<span class="rp__tpl" data-day="0" hidden>오늘 {t}</span>
<span class="rp__tpl" data-day="1" hidden>내일 {t}</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-ink; // 주황 무대 위 전경은 잉크 — 크림은 3.11:1
@include stage($stage-orange);
.rp {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: $sp-2;
width: min(300px, 90vw);
color: $color;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.rp__base {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: $sp-2;
margin: 0;
}
.rp__baselab {
font-size: 11px;
line-height: 16px;
font-weight: 600;
opacity: .9; // 주황 면 위 잉크 4.96:1
}
.rp__basenum {
font-size: 14px;
line-height: 18px;
font-weight: 700;
letter-spacing: .02em;
}
// ── 칩 넷 ──────────────────────────────────────────────
.rp__chips {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: $sp-2;
}
.rp__chip {
box-sizing: border-box;
margin: 0;
padding: $sp-1 $sp-2;
border: 2px solid $color;
border-radius: $r-pill;
background-color: transparent;
color: $color;
font-family: inherit;
font-size: 13px;
line-height: 18px;
font-weight: 700;
white-space: nowrap;
cursor: pointer;
}
.rp__chip:focus-visible { outline: 3px solid $color; outline-offset: 3px; }
// 고른 칩은 aria-pressed 한 곳에만 적힌다 — 색은 그 값에서 나온다
.rp:not(.is-demo) .rp__chip[aria-pressed="true"] {
background-color: $color;
color: $subject-cream;
}
// ── 결과 줄 (옛 값은 위로, 새 값은 아래에서) ───────────
.rp__out {
display: flex;
flex-direction: column;
gap: $sp-1;
padding: $sp-2;
border-radius: $r-card;
background-color: $subject-ink; // 결과 패널 — 크림 글자를 받으려면 면이 충분히 어두워야 한다
color: $subject-cream;
}
.rp__cap {
display: flex;
align-items: center;
gap: $sp-1;
font-size: 11px;
line-height: 14px;
font-weight: 600;
opacity: .82;
}
.rp__ic { display: inline-flex; flex: 0 0 auto; }
.rp__ic svg { display: block; }
.rp__slot {
position: relative;
display: block;
height: 40px;
overflow: hidden;
}
.rp__val,
.rp__f {
position: absolute;
left: 0;
right: 0;
top: 0;
display: flex;
align-items: center;
height: 40px;
font-size: 26px;
line-height: 30px;
font-weight: 800;
white-space: nowrap;
}
.rp__val--nxt { transform: translateY(100%); }
.rp__slot.is-roll .rp__val { transition: transform $duration $easing; }
.rp__slot.is-roll .rp__val--cur { transform: translateY(-100%); }
.rp__slot.is-roll .rp__val--nxt { transform: translateY(0); }
.rp__live {
position: absolute;
margin: 0;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 루프 전용 값 두 장 — 사람이 조작하면 위의 실제 값이 다시 보인다
.rp__f { display: none; }
.rp.is-demo .rp__f { display: flex; }
.rp.is-demo .rp__val { display: none; }
// ── 자동 루프: 칩은 한 프레임에 컷, 결과 시각은 굴러서 갈린다 ──
.rp.is-demo .rp__chip[data-i="0"],
.rp.is-demo .rp__chip[data-i="3"] {
animation-duration: $dur-loop, $dur-loop;
animation-iteration-count: infinite, infinite;
animation-timing-function: steps(1, end), $easing;
animation-fill-mode: backwards, backwards;
}
.rp.is-demo .rp__chip[data-i="0"] { animation-name: rp-skin-a, rp-pop-a; }
.rp.is-demo .rp__chip[data-i="3"] { animation-name: rp-skin-b, rp-pop-b; }
@keyframes rp-skin-a {
0% { background-color: $color; color: $subject-cream; }
32% { background-color: transparent; color: $color; }
74% { background-color: $color; color: $subject-cream; }
100% { background-color: $color; color: $subject-cream; }
}
@keyframes rp-skin-b {
0% { background-color: transparent; color: $color; }
32% { background-color: $color; color: $subject-ink; }
74% { background-color: transparent; color: $color; }
100% { background-color: transparent; color: $color; }
}
// 컷만으로는 움직인 프레임이 안 차니 눌린 칩이 한 번 반동한다
@keyframes rp-pop-a {
0% { transform: scale(1); }
72% { transform: scale(1); }
76% { transform: scale(.93); }
82% { transform: scale(1.05); }
88% { transform: scale(1); }
100% { transform: scale(1); }
}
@keyframes rp-pop-b {
0% { transform: scale(1); }
30% { transform: scale(1); }
34% { transform: scale(.93); }
40% { transform: scale(1.05); }
46% { transform: scale(1); }
100% { transform: scale(1); }
}
// 값 교체는 한 프레임에 컷(steps) — 반쯤 걸친 두 값이 poster 로 뽑히지 않는다.
// 컷 직후의 작은 translateY 상승이 보조 움직임을 만든다
.rp.is-demo .rp__f {
animation-duration: $dur-loop, $dur-loop;
animation-iteration-count: infinite, infinite;
animation-timing-function: steps(1, end), $easing;
animation-fill-mode: backwards, backwards;
}
.rp.is-demo .rp__f[data-k="a"] { animation-name: rp-cut-a, rp-rise-a; }
.rp.is-demo .rp__f[data-k="b"] { animation-name: rp-cut-b, rp-rise-b; }
@keyframes rp-cut-a {
0% { opacity: 1; }
32% { opacity: 0; }
74% { opacity: 1; }
100% { opacity: 1; }
}
@keyframes rp-cut-b {
0% { opacity: 0; }
32% { opacity: 1; }
74% { opacity: 0; }
100% { opacity: 0; }
}
// 안 보이는 동안(opacity 0) 아래로 내려가 있다가, 컷과 함께 제자리로 올라온다
@keyframes rp-rise-a {
0% { transform: translateY(0); }
69.9% { transform: translateY(0); animation-timing-function: steps(1, end); }
70% { transform: translateY(5px); }
74% { transform: translateY(5px); }
84% { transform: translateY(0); }
100% { transform: translateY(0); }
}
@keyframes rp-rise-b {
0% { transform: translateY(5px); }
32% { transform: translateY(5px); }
42% { transform: translateY(0); }
70% { transform: translateY(0); }
100% { transform: translateY(0); }
}
@media (max-width: 340px) {
.rp { gap: $sp-1; }
.rp__chip { font-size: 12px; line-height: 16px; }
.rp__slot,
.rp__val,
.rp__f { height: 34px; }
.rp__val,
.rp__f { font-size: 22px; line-height: 26px; }
}
@media (prefers-reduced-motion: reduce) {
.rp.is-demo .rp__chip,
.rp.is-demo .rp__f { animation: none !important; }
.rp__slot.is-roll .rp__val { transition: none !important; }
.rp.is-demo .rp__f[data-k="b"] { display: none; }
.rp.is-demo .rp__chip[aria-pressed="true"] { background-color: $color; color: $subject-cream; }
}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);
}
.rp {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 8px;
width: min(300px, 90vw);
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.rp__base {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
margin: 0;
}
.rp__baselab {
font-size: 11px;
line-height: 16px;
font-weight: 600;
opacity: 0.9;
}
.rp__basenum {
font-size: 14px;
line-height: 18px;
font-weight: 700;
letter-spacing: 0.02em;
}
.rp__chips {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.rp__chip {
box-sizing: border-box;
margin: 0;
padding: 4px 8px;
border: 2px solid #17141a;
border-radius: 999px;
background-color: transparent;
color: #17141a;
font-family: inherit;
font-size: 13px;
line-height: 18px;
font-weight: 700;
white-space: nowrap;
cursor: pointer;
}
.rp__chip:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.rp:not(.is-demo) .rp__chip[aria-pressed=true] {
background-color: #17141a;
color: #fff7e6;
}
.rp__out {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px;
border-radius: 16px;
background-color: #17141a;
color: #fff7e6;
}
.rp__cap {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
line-height: 14px;
font-weight: 600;
opacity: 0.82;
}
.rp__ic {
display: inline-flex;
flex: 0 0 auto;
}
.rp__ic svg {
display: block;
}
.rp__slot {
position: relative;
display: block;
height: 40px;
overflow: hidden;
}
.rp__val,
.rp__f {
position: absolute;
left: 0;
right: 0;
top: 0;
display: flex;
align-items: center;
height: 40px;
font-size: 26px;
line-height: 30px;
font-weight: 800;
white-space: nowrap;
}
.rp__val--nxt {
transform: translateY(100%);
}
.rp__slot.is-roll .rp__val {
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.rp__slot.is-roll .rp__val--cur {
transform: translateY(-100%);
}
.rp__slot.is-roll .rp__val--nxt {
transform: translateY(0);
}
.rp__live {
position: absolute;
margin: 0;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.rp__f {
display: none;
}
.rp.is-demo .rp__f {
display: flex;
}
.rp.is-demo .rp__val {
display: none;
}
.rp.is-demo .rp__chip[data-i="0"],
.rp.is-demo .rp__chip[data-i="3"] {
animation-duration: 2s, 2s;
animation-iteration-count: infinite, infinite;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 0.8, 0.2, 1);
animation-fill-mode: backwards, backwards;
}
.rp.is-demo .rp__chip[data-i="0"] {
animation-name: rp-skin-a, rp-pop-a;
}
.rp.is-demo .rp__chip[data-i="3"] {
animation-name: rp-skin-b, rp-pop-b;
}
@keyframes rp-skin-a {
0% {
background-color: #17141a;
color: #fff7e6;
}
32% {
background-color: transparent;
color: #17141a;
}
74% {
background-color: #17141a;
color: #fff7e6;
}
100% {
background-color: #17141a;
color: #fff7e6;
}
}
@keyframes rp-skin-b {
0% {
background-color: transparent;
color: #17141a;
}
32% {
background-color: #17141a;
color: #17141a;
}
74% {
background-color: transparent;
color: #17141a;
}
100% {
background-color: transparent;
color: #17141a;
}
}
@keyframes rp-pop-a {
0% {
transform: scale(1);
}
72% {
transform: scale(1);
}
76% {
transform: scale(0.93);
}
82% {
transform: scale(1.05);
}
88% {
transform: scale(1);
}
100% {
transform: scale(1);
}
}
@keyframes rp-pop-b {
0% {
transform: scale(1);
}
30% {
transform: scale(1);
}
34% {
transform: scale(0.93);
}
40% {
transform: scale(1.05);
}
46% {
transform: scale(1);
}
100% {
transform: scale(1);
}
}
.rp.is-demo .rp__f {
animation-duration: 2s, 2s;
animation-iteration-count: infinite, infinite;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 0.8, 0.2, 1);
animation-fill-mode: backwards, backwards;
}
.rp.is-demo .rp__f[data-k=a] {
animation-name: rp-cut-a, rp-rise-a;
}
.rp.is-demo .rp__f[data-k=b] {
animation-name: rp-cut-b, rp-rise-b;
}
@keyframes rp-cut-a {
0% {
opacity: 1;
}
32% {
opacity: 0;
}
74% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes rp-cut-b {
0% {
opacity: 0;
}
32% {
opacity: 1;
}
74% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes rp-rise-a {
0% {
transform: translateY(0);
}
69.9% {
transform: translateY(0);
animation-timing-function: steps(1, end);
}
70% {
transform: translateY(5px);
}
74% {
transform: translateY(5px);
}
84% {
transform: translateY(0);
}
100% {
transform: translateY(0);
}
}
@keyframes rp-rise-b {
0% {
transform: translateY(5px);
}
32% {
transform: translateY(5px);
}
42% {
transform: translateY(0);
}
70% {
transform: translateY(0);
}
100% {
transform: translateY(0);
}
}
@media (max-width: 340px) {
.rp {
gap: 4px;
}
.rp__chip {
font-size: 12px;
line-height: 16px;
}
.rp__slot,
.rp__val,
.rp__f {
height: 34px;
}
.rp__val,
.rp__f {
font-size: 22px;
line-height: 26px;
}
}
@media (prefers-reduced-motion: reduce) {
.rp.is-demo .rp__chip,
.rp.is-demo .rp__f {
animation: none !important;
}
.rp__slot.is-roll .rp__val {
transition: none !important;
}
.rp.is-demo .rp__f[data-k=b] {
display: none;
}
.rp.is-demo .rp__chip[aria-pressed=true] {
background-color: #17141a;
color: #fff7e6;
}
}// 실제 동작: 칩마다 기준 시각에 더할 분(data-add)이나 절대 분(data-at)이 적혀 있고,
// 결과 시각은 그때그때 계산한다. 기준은 데모라 지금 시각이 아니라 코드 안 고정값이다.
var root = document.querySelector('.rp');
var cur = document.getElementById('rp-cur');
var nxt = document.getElementById('rp-nxt');
var slot = document.getElementById('rp-slot');
var live = document.getElementById('rp-live');
var chips = root.querySelectorAll('.rp__chip');
var BASE = 14 * 60 + 40;
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');
}
nxt.addEventListener('transitionend', function () {
cur.textContent = nxt.textContent; // 굴러 올라온 값이 확정 값이 된다
slot.classList.remove('is-roll');
});
for (var i = 0; i < chips.length; i++) {
chips[i].addEventListener('click', function (e) { pick(e.currentTarget); });
chips[i].addEventListener('keydown', function (e) {
var d = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
if (!d) return;
e.preventDefault();
var j = (Array.prototype.indexOf.call(chips, e.currentTarget) + d + chips.length) % chips.length;
chips[j].focus({ preventScroll: true });
pick(chips[j]);
});
}import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./RelativePresetChips.module.scss";
type KeyEvt = { key: string; preventDefault(): void };
type Box<T> = { current: T };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export type Preset = { label: string; add?: number; at?: number };
export type RelativePresetChipsProps = {
/** 기준 시각 — 자정부터의 분. 데모라 지금 시각이 아니라 고정값이다 */
base?: number;
presets?: Preset[];
/** [오늘, 내일] 꼴 — {t} 자리에 계산한 시각이 들어간다 */
dayTemplates?: [string, string];
baseLabel?: string;
outLabel?: string;
groupLabel?: string;
duration?: number;
easing?: string;
color?: string;
};
const ALARM = (
<g fill="currentColor">
<path d="M216 136a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2" />
<path d="M128 40a96 96 0 1 0 96 96a96.11 96.11 0 0 0-96-96m0 176a80 80 0 1 1 80-80a80.09 80.09 0 0 1-80 80M61.66 37.66l-32 32a8 8 0 0 1-11.32-11.32l32-32a8 8 0 0 1 11.32 11.32m176 32a8 8 0 0 1-11.32 0l-32-32a8 8 0 0 1 11.32-11.32l32 32a8 8 0 0 1 0 11.32M184 128a8 8 0 0 1 0 16h-56a8 8 0 0 1-8-8V80a8 8 0 0 1 16 0v48Z" />
</g>
);
function pad(n: number) {
return (n < 10 ? "0" : "") + n;
}
function clockOf(m: number, tpl: [string, string]) {
const day = m >= 1440 ? 1 : 0;
const t = m % 1440;
return tpl[day].replace("{t}", pad(Math.floor(t / 60)) + ":" + pad(t % 60));
}
export default function RelativePresetChips({
base = 14 * 60 + 40,
presets = [
{ label: "In 10 min", add: 10 },
{ label: "In 1 hour", add: 60 },
{ label: "This evening", at: 18 * 60 },
{ label: "Tomorrow morning", at: 32 * 60 },
],
dayTemplates = ["Today {t}", "Tomorrow {t}"],
baseLabel = "Base time",
outLabel = "Reminder at",
groupLabel = "Relative time presets",
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: RelativePresetChipsProps) {
const minutesOf = (p: Preset) => (p.add === undefined ? (p.at ?? 0) : base + p.add);
const [demo, setDemo] = useState(true);
const [sel, setSel] = useState(0);
const [cur, setCur] = useState(() => clockOf(minutesOf(presets[0]), dayTemplates));
const [next, setNext] = useState("");
const [rolling, setRolling] = useState(false);
const [live, setLive] = useState("");
const chips = useRef<(HTMLButtonElement | null)[]>([]) as Box<(HTMLButtonElement | null)[]>;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 칩마다 더할 분(add)이나 절대 분(at)이 적혀 있고, 결과 시각은 그때그때 계산한다
function pick(i: number) {
setDemo(false);
setSel(i);
const t = clockOf(minutesOf(presets[i]), dayTemplates);
setLive(t);
if (t === cur) return;
setNext(t);
setRolling(false);
requestAnimationFrame(() => setRolling(true)); // 같은 값이 아니면 언제나 한 번 더 굴러야 한다
}
function onKey(e: KeyEvt, i: number) {
const d = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
if (!d) return;
e.preventDefault();
const j = (i + d + presets.length) % presets.length;
const el = chips.current[j];
if (el) el.focus({ preventScroll: true });
pick(j);
}
return (
<div className={`${styles.rp} ${demo ? styles.isDemo : ""}`} style={vars}>
<p className={styles.base}>
<span className={styles.baseLab}>{baseLabel}</span>
<time className={styles.baseNum} dateTime={pad(Math.floor(base / 60)) + ":" + pad(base % 60)}>
{pad(Math.floor(base / 60))}:{pad(base % 60)}
</time>
</p>
<div className={styles.chips} role="group" aria-label={groupLabel}>
{presets.map((p, i) => (
<button
key={p.label}
className={styles.chip}
type="button"
data-i={i}
aria-pressed={sel === i}
ref={(el: HTMLButtonElement | null) => {
chips.current[i] = el;
}}
onClick={() => pick(i)}
onKeyDown={(e: KeyEvt) => onKey(e, i)}
>
{p.label}
</button>
))}
</div>
<div className={styles.out}>
<span className={styles.cap}>
<span className={styles.ic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="14" height="14">{ALARM}</svg>
</span>
<span>{outLabel}</span>
</span>
<span className={`${styles.slot} ${rolling ? styles.isRoll : ""}`}>
<span className={`${styles.val} ${styles.valCur}`}>{cur}</span>
<span
className={`${styles.val} ${styles.valNext}`}
onTransitionEnd={() => {
setCur(next); // 굴러 올라온 값이 확정 값이 된다
setRolling(false);
}}
>
{next}
</span>
<span className={styles.f} data-k="a">{clockOf(minutesOf(presets[0]), dayTemplates)}</span>
<span className={styles.f} data-k="b">{clockOf(minutesOf(presets[3]), dayTemplates)}</span>
</span>
</div>
<p className={styles.live} aria-live="polite">{live}</p>
</div>
);
}07 · 누르면 결과 시각까지 보여주는 프리셋 — 측정 누적면적 8.162% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
다시 알림이나 나중에 보내기처럼 정확한 시각보다 거리를 고르는 자리에 맞습니다.
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분처럼 단위가 붙은 말입니다.

<main class="stage">
<div class="cd is-demo">
<div class="cd__ring" id="cd-ring">
<span class="cd__hole">
<span class="cd__num" id="cd-num">05:00</span>
<span class="cd__f" data-k="1">05:00</span>
<span class="cd__f" data-k="2">03:45</span>
<span class="cd__f" data-k="3">02:30</span>
</span>
</div>
<div class="cd__side">
<span class="cd__cap">타이머 길이</span>
<div class="cd__field">
<button class="cd__step" type="button" data-t="min" data-d="-1" aria-label="분 내리기"><svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="m208 96l-80 80l-80-80Z" opacity=".2"/><path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z"/></g></svg></button>
<span class="cd__val" role="spinbutton" tabindex="0" id="cd-min" data-t="min" aria-label="분" aria-valuemin="0" aria-valuemax="59" aria-valuenow="5" aria-valuetext="5분"><b class="cd__n" id="cd-min-n">05</b><i class="cd__u">분</i></span>
<button class="cd__step" type="button" data-t="min" data-d="1" aria-label="분 올리기"><svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M208 160H48l80-80Z" opacity=".2"/><path d="m213.66 154.34l-80-80a8 8 0 0 0-11.32 0l-80 80A8 8 0 0 0 48 168h160a8 8 0 0 0 5.66-13.66M67.31 152L128 91.31L188.69 152Z"/></g></svg></button>
</div>
<div class="cd__field">
<button class="cd__step" type="button" data-t="sec" data-d="-1" aria-label="초 내리기"><svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="m208 96l-80 80l-80-80Z" opacity=".2"/><path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z"/></g></svg></button>
<span class="cd__val" role="spinbutton" tabindex="0" id="cd-sec" data-t="sec" aria-label="초" aria-valuemin="0" aria-valuemax="55" aria-valuenow="0" aria-valuetext="0초"><b class="cd__n" id="cd-sec-n">00</b><i class="cd__u">초</i></span>
<button class="cd__step" type="button" data-t="sec" data-d="1" aria-label="초 올리기"><svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M208 160H48l80-80Z" opacity=".2"/><path d="m213.66 154.34l-80-80a8 8 0 0 0-11.32 0l-80 80A8 8 0 0 0 48 168h160a8 8 0 0 0 5.66-13.66M67.31 152L128 91.31L188.69 152Z"/></g></svg></button>
</div>
<button class="cd__go" type="button" id="cd-go">시작</button>
</div>
<span class="cd__tpl" data-k="min" hidden>{n}분</span>
<span class="cd__tpl" data-k="sec" hidden>{n}초</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
// 등록하지 않은 커스텀 속성은 보간되지 않는다 — 각도를 애니메이션하려면 이 선언이 먼저다
@property --ring-angle {
syntax: "<angle>";
inherits: false;
initial-value: 360deg;
}
.cd {
box-sizing: border-box;
display: flex;
align-items: center;
gap: $sp-5;
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
// ── 둘레 링: conic-gradient 한 장 + 가운데 구멍 ──────────
.cd__ring {
position: relative;
flex: 0 0 auto;
display: grid;
place-items: center;
width: 124px;
height: 124px;
border-radius: 50%;
background: conic-gradient($color 0deg var(--ring-angle), rgba($subject-ink, .14) var(--ring-angle) 360deg);
}
.cd__hole {
display: grid;
place-items: center;
width: 94px;
height: 94px;
border-radius: 50%;
background-color: $stage-paper;
}
.cd__num,
.cd__f {
grid-area: 1 / 1;
font-size: 26px;
line-height: 30px;
font-weight: 800;
letter-spacing: .01em;
font-variant-numeric: tabular-nums;
}
// 루프 전용 숫자 석 장 — 사람이 조작하면 위의 실제 값이 다시 보인다
.cd__f { display: none; }
.cd.is-demo .cd__f { display: block; }
.cd.is-demo .cd__num { display: none; }
// ── 분·초 칸 ────────────────────────────────────────────
.cd__side {
display: flex;
flex-direction: column;
gap: $sp-2;
}
.cd__cap {
font-size: 11px;
line-height: 14px;
font-weight: 600;
color: rgba($subject-ink, .62);
}
.cd__field {
display: flex;
align-items: center;
gap: $sp-1;
}
.cd__step {
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
margin: 0;
padding: 0;
width: 28px;
height: 28px;
border: 0;
border-radius: $r-sm;
background-color: rgba($subject-ink, .08);
color: $subject-ink;
cursor: pointer;
}
.cd__step:focus-visible,
.cd__val:focus-visible,
.cd__go:focus-visible { outline: 3px solid $color; outline-offset: 2px; }
.cd__step svg { display: block; }
.cd__val {
box-sizing: border-box;
display: inline-flex;
align-items: baseline;
justify-content: center;
gap: $sp-1;
padding: $sp-1 $sp-2;
min-width: 66px;
border-radius: $r-sm;
background-color: rgba($color, .1);
cursor: ns-resize;
}
.cd__n {
font-size: 20px;
line-height: 24px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.cd__u {
font-size: 11px;
line-height: 14px;
font-weight: 700;
font-style: normal;
color: rgba($subject-ink, .72);
}
.cd__go {
box-sizing: border-box;
margin: 0;
padding: $sp-1 $sp-4;
border: 0;
border-radius: $r-pill;
background-color: $color;
color: $stage-paper;
font-family: inherit;
font-size: 13px;
line-height: 20px;
font-weight: 700;
cursor: pointer;
}
.cd__tpl { display: none; }
// ── 자동 루프: 링은 이어서 줄고, 숫자는 한 프레임에 컷 ──
// 다 닳아서 비는 프레임이 없도록 72deg 에서 멈추고, 되감기는 여러 프레임으로 나눠 돌린다
.cd.is-demo .cd__ring {
animation-name: cd-drain;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes cd-drain {
0% { --ring-angle: 360deg; }
62% { --ring-angle: 72deg; }
86% { --ring-angle: 360deg; }
100% { --ring-angle: 360deg; }
}
.cd.is-demo .cd__f {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.cd.is-demo .cd__f[data-k="1"] { animation-name: cd-n1; }
.cd.is-demo .cd__f[data-k="2"] { animation-name: cd-n2; }
.cd.is-demo .cd__f[data-k="3"] { animation-name: cd-n3; }
@keyframes cd-n1 {
0% { opacity: 1; }
21% { opacity: 0; }
62% { opacity: 1; }
100% { opacity: 1; }
}
@keyframes cd-n2 {
0% { opacity: 0; }
21% { opacity: 1; }
41% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes cd-n3 {
0% { opacity: 0; }
41% { opacity: 1; }
62% { opacity: 0; }
100% { opacity: 0; }
}
// 시작 버튼이 한 번 눌렸다 펴지며 루프의 출발을 알린다
.cd.is-demo .cd__go {
animation-name: cd-press;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes cd-press {
0% { transform: scale(.92); }
8% { transform: scale(1.04); }
16% { transform: scale(1); }
100% { transform: scale(1); }
}
@media (max-width: 340px) {
.cd { gap: $sp-3; }
.cd__ring { width: 100px; height: 100px; }
.cd__hole { width: 76px; height: 76px; }
.cd__num,
.cd__f { font-size: 20px; line-height: 24px; }
.cd__val { min-width: 56px; padding: 0 $sp-1; gap: 0; }
.cd__n { font-size: 17px; line-height: 22px; }
.cd__step { width: 24px; height: 24px; }
.cd__go { padding: $sp-1 $sp-3; font-size: 12px; line-height: 18px; }
}
@media (prefers-reduced-motion: reduce) {
.cd.is-demo .cd__ring,
.cd.is-demo .cd__f,
.cd.is-demo .cd__go { animation: none !important; }
.cd.is-demo .cd__f[data-k="2"],
.cd.is-demo .cd__f[data-k="3"] { display: none; }
}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);
}
@property --ring-angle {
syntax: "<angle>";
inherits: false;
initial-value: 360deg;
}
.cd {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 20px;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.cd__ring {
position: relative;
flex: 0 0 auto;
display: grid;
place-items: center;
width: 124px;
height: 124px;
border-radius: 50%;
background: conic-gradient(#2f6df6 0deg var(--ring-angle), rgba(23, 20, 26, 0.14) var(--ring-angle) 360deg);
}
.cd__hole {
display: grid;
place-items: center;
width: 94px;
height: 94px;
border-radius: 50%;
background-color: #ffffff;
}
.cd__num,
.cd__f {
grid-area: 1/1;
font-size: 26px;
line-height: 30px;
font-weight: 800;
letter-spacing: 0.01em;
font-variant-numeric: tabular-nums;
}
.cd__f {
display: none;
}
.cd.is-demo .cd__f {
display: block;
}
.cd.is-demo .cd__num {
display: none;
}
.cd__side {
display: flex;
flex-direction: column;
gap: 8px;
}
.cd__cap {
font-size: 11px;
line-height: 14px;
font-weight: 600;
color: rgba(23, 20, 26, 0.62);
}
.cd__field {
display: flex;
align-items: center;
gap: 4px;
}
.cd__step {
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
margin: 0;
padding: 0;
width: 28px;
height: 28px;
border: 0;
border-radius: 8px;
background-color: rgba(23, 20, 26, 0.08);
color: #17141a;
cursor: pointer;
}
.cd__step:focus-visible,
.cd__val:focus-visible,
.cd__go:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 2px;
}
.cd__step svg {
display: block;
}
.cd__val {
box-sizing: border-box;
display: inline-flex;
align-items: baseline;
justify-content: center;
gap: 4px;
padding: 4px 8px;
min-width: 66px;
border-radius: 8px;
background-color: rgba(47, 109, 246, 0.1);
cursor: ns-resize;
}
.cd__n {
font-size: 20px;
line-height: 24px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.cd__u {
font-size: 11px;
line-height: 14px;
font-weight: 700;
font-style: normal;
color: rgba(23, 20, 26, 0.72);
}
.cd__go {
box-sizing: border-box;
margin: 0;
padding: 4px 16px;
border: 0;
border-radius: 999px;
background-color: #2f6df6;
color: #ffffff;
font-family: inherit;
font-size: 13px;
line-height: 20px;
font-weight: 700;
cursor: pointer;
}
.cd__tpl {
display: none;
}
.cd.is-demo .cd__ring {
animation-name: cd-drain;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes cd-drain {
0% {
--ring-angle: 360deg;
}
62% {
--ring-angle: 72deg;
}
86% {
--ring-angle: 360deg;
}
100% {
--ring-angle: 360deg;
}
}
.cd.is-demo .cd__f {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.cd.is-demo .cd__f[data-k="1"] {
animation-name: cd-n1;
}
.cd.is-demo .cd__f[data-k="2"] {
animation-name: cd-n2;
}
.cd.is-demo .cd__f[data-k="3"] {
animation-name: cd-n3;
}
@keyframes cd-n1 {
0% {
opacity: 1;
}
21% {
opacity: 0;
}
62% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes cd-n2 {
0% {
opacity: 0;
}
21% {
opacity: 1;
}
41% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes cd-n3 {
0% {
opacity: 0;
}
41% {
opacity: 1;
}
62% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.cd.is-demo .cd__go {
animation-name: cd-press;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes cd-press {
0% {
transform: scale(0.92);
}
8% {
transform: scale(1.04);
}
16% {
transform: scale(1);
}
100% {
transform: scale(1);
}
}
@media (max-width: 340px) {
.cd {
gap: 12px;
}
.cd__ring {
width: 100px;
height: 100px;
}
.cd__hole {
width: 76px;
height: 76px;
}
.cd__num,
.cd__f {
font-size: 20px;
line-height: 24px;
}
.cd__val {
min-width: 56px;
padding: 0 4px;
gap: 0;
}
.cd__n {
font-size: 17px;
line-height: 22px;
}
.cd__step {
width: 24px;
height: 24px;
}
.cd__go {
padding: 4px 12px;
font-size: 12px;
line-height: 18px;
}
}
@media (prefers-reduced-motion: reduce) {
.cd.is-demo .cd__ring,
.cd.is-demo .cd__f,
.cd.is-demo .cd__go {
animation: none !important;
}
.cd.is-demo .cd__f[data-k="2"],
.cd.is-demo .cd__f[data-k="3"] {
display: none;
}
}// 실제 동작: 분·초 칸은 진짜 spinbutton 이라 ↑↓ 로 값이 오르내리고(분 1, 초 5), 시작을 누르면
// 남은 비율이 --ring-angle 로 나간다. 이 커스텀 속성은 @property 로 등록돼 있어야 각도가 보간된다
var root = document.querySelector('.cd');
var ringEl = document.getElementById('cd-ring');
var num = document.getElementById('cd-num');
var V = { min: 5, sec: 0 }, STEP = { min: 1, sec: 5 }, MAX = { min: 59, sec: 55 };
var timer = null;
function pad(n) { return (n < 10 ? '0' : '') + n; }
function tpl(k, n) { return root.querySelector('.cd__tpl[data-k="' + k + '"]').textContent.replace('{n}', String(n)); }
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);
}
function bump(k, d) {
root.classList.remove('is-demo'); stop();
V[k] = Math.max(0, Math.min(MAX[k], V[k] + d * STEP[k]));
paint();
}
function start() {
root.classList.remove('is-demo'); stop();
var total = V.min * 60 + V.sec, left = total;
if (!total) return;
timer = setInterval(function () {
left -= 1; show(left); ring(left / total);
if (left <= 0) stop();
}, 1000);
}
root.addEventListener('click', function (e) {
var b = e.target.closest('.cd__step');
if (b) { bump(b.getAttribute('data-t'), Number(b.getAttribute('data-d'))); return; }
if (e.target.closest('.cd__go')) start();
});
root.addEventListener('keydown', function (e) {
var s = e.target.closest('[role="spinbutton"]');
if (!s) return;
var k = s.getAttribute('data-t');
var d = e.key === 'ArrowUp' ? 1 : e.key === 'ArrowDown' ? -1 : 0;
if (d) { e.preventDefault(); bump(k, d); return; }
if (e.key === 'Home' || e.key === 'End') {
e.preventDefault(); root.classList.remove('is-demo'); stop();
V[k] = e.key === 'Home' ? 0 : MAX[k]; paint();
}
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CountdownRingSetter.module.scss";
type KeyEvt = { key: string; preventDefault(): void };
type Box<T> = { current: T };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type RingVars = CSSProperties & { "--ring-angle"?: string };
type Unit = "min" | "sec";
export type CountdownRingSetterProps = {
minutes?: number;
seconds?: number;
labels?: {
cap: string;
start: string;
min: string;
sec: string;
minUp: string;
minDown: string;
secUp: string;
secDown: string;
minUnit: string;
secUnit: string;
};
/** {n} 자리에 값이 들어가는 aria-valuetext 틀 */
valueText?: { min: string; sec: string };
/** 격자 미리보기(.isDemo) 루프에서만 보이는 숫자 석 장 */
demoFrames?: [string, string, string];
duration?: number;
easing?: string;
color?: string;
};
const STEP: { min: number; sec: number } = { min: 1, sec: 5 };
const MAX: { min: number; sec: number } = { min: 59, sec: 55 };
const CARET_DOWN = (
<g fill="currentColor">
<path d="m208 96l-80 80l-80-80Z" opacity=".2" />
<path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z" />
</g>
);
const CARET_UP = (
<g fill="currentColor">
<path d="M208 160H48l80-80Z" opacity=".2" />
<path d="m213.66 154.34l-80-80a8 8 0 0 0-11.32 0l-80 80A8 8 0 0 0 48 168h160a8 8 0 0 0 5.66-13.66M67.31 152L128 91.31L188.69 152Z" />
</g>
);
function pad(n: number) {
return (n < 10 ? "0" : "") + n;
}
export default function CountdownRingSetter({
minutes = 5,
seconds = 0,
labels = {
cap: "Timer length",
start: "Start",
min: "minutes",
sec: "seconds",
minUp: "minutes up",
minDown: "minutes down",
secUp: "seconds up",
secDown: "seconds down",
minUnit: "m",
secUnit: "s",
},
valueText = { min: "{n} minutes", sec: "{n} seconds" },
demoFrames = ["05:00", "03:45", "02:30"],
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: CountdownRingSetterProps) {
const [demo, setDemo] = useState(true);
const [v, setV] = useState({ min: minutes, sec: seconds });
const [left, setLeft] = useState<number | null>(null);
const timer = useRef<number | null>(null) as Box<number | null>;
const total = v.min * 60 + v.sec;
const shown = left === null ? total : left;
// 남은 비율이 그대로 각도가 된다 — @property 로 등록된 값이라야 이 각도가 보간된다
const angle = left === null || total === 0 ? 360 : (left / total) * 360;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const ringVars: RingVars = { "--ring-angle": `${angle.toFixed(1)}deg` };
function stop() {
if (timer.current !== null) {
window.clearInterval(timer.current);
timer.current = null;
}
}
function set(k: Unit, n: number) {
setDemo(false);
stop();
setLeft(null);
const c = Math.max(0, Math.min(MAX[k], n));
setV((p) => (k === "min" ? { min: c, sec: p.sec } : { min: p.min, sec: c }));
}
function bump(k: Unit, d: number) {
set(k, v[k] + d * STEP[k]);
}
function start() {
setDemo(false);
stop();
if (!total) return;
setLeft(total);
timer.current = window.setInterval(() => {
setLeft((p) => {
const n = (p === null ? total : p) - 1;
if (n <= 0) stop();
return n < 0 ? 0 : n;
});
}, 1000);
}
function onKey(e: KeyEvt, k: Unit) {
const d = e.key === "ArrowUp" ? 1 : e.key === "ArrowDown" ? -1 : 0;
if (d) {
e.preventDefault();
bump(k, d);
return;
}
if (e.key === "Home" || e.key === "End") {
e.preventDefault();
set(k, e.key === "Home" ? 0 : MAX[k]);
}
}
function field(k: Unit, lab: string, unit: string, up: string, down: string) {
return (
<div className={styles.field}>
<button className={styles.step} type="button" aria-label={down} onClick={() => bump(k, -1)}>
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true">{CARET_DOWN}</svg>
</button>
<span
className={styles.val}
role="spinbutton"
tabIndex={0}
aria-label={lab}
aria-valuemin={0}
aria-valuemax={MAX[k]}
aria-valuenow={v[k]}
aria-valuetext={valueText[k].replace("{n}", String(v[k]))}
onKeyDown={(e: KeyEvt) => onKey(e, k)}
>
<b className={styles.n}>{pad(v[k])}</b>
<i className={styles.u}>{unit}</i>
</span>
<button className={styles.step} type="button" aria-label={up} onClick={() => bump(k, 1)}>
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true">{CARET_UP}</svg>
</button>
</div>
);
}
return (
<div className={`${styles.cd} ${demo ? styles.isDemo : ""}`} style={vars}>
<div className={styles.ring} style={ringVars}>
<span className={styles.hole}>
<span className={styles.num}>{pad(Math.floor(shown / 60))}:{pad(shown % 60)}</span>
{demoFrames.map((t, i) => (
<span className={styles.f} data-k={i + 1} key={t}>{t}</span>
))}
</span>
</div>
<div className={styles.side}>
<span className={styles.cap}>{labels.cap}</span>
{field("min", labels.min, labels.minUnit, labels.minUp, labels.minDown)}
{field("sec", labels.sec, labels.secUnit, labels.secUp, labels.secDown)}
<button className={styles.go} type="button" onClick={start}>{labels.start}</button>
</div>
</div>
);
}08 · 분과 초를 올리면 링이 도는 카운트다운 — 측정 누적면적 4.353% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
타이머나 휴식처럼 지금부터 얼마 동안인지를 정하는 화면에 씁니다.
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분씩 옮기면 세 줄이 같은 함수 한 번으로 함께 다시 그려집니다. 시차는 기준에서 몇 분 떨어졌는지 적은 배열이라 한 값에서 세 줄이 파생되고, 자정을 넘긴 줄에만 다음날 표시가 따로 붙습니다.

<main class="stage">
<div class="tz is-demo">
<ul class="tz__rows">
<li class="tz__row tz__row--base" data-r="0" data-next="0">
<span class="tz__city">
<span class="tz__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M128 24a104 104 0 1 0 104 104A104.12 104.12 0 0 0 128 24m88 104a87.6 87.6 0 0 1-3.33 24h-38.51a157.4 157.4 0 0 0 0-48h38.51a87.6 87.6 0 0 1 3.33 24m-114 40h52a115.1 115.1 0 0 1-26 45a115.3 115.3 0 0 1-26-45m-3.9-16a140.8 140.8 0 0 1 0-48h59.88a140.8 140.8 0 0 1 0 48ZM40 128a87.6 87.6 0 0 1 3.33-24h38.51a157.4 157.4 0 0 0 0 48H43.33A87.6 87.6 0 0 1 40 128m114-40h-52a115.1 115.1 0 0 1 26-45a115.3 115.3 0 0 1 26 45m52.33 0h-35.62a135.3 135.3 0 0 0-22.3-45.6A88.29 88.29 0 0 1 206.37 88Zm-98.74-45.6A135.3 135.3 0 0 0 85.29 88H49.63a88.29 88.29 0 0 1 57.96-45.6M49.63 168h35.66a135.3 135.3 0 0 0 22.3 45.6A88.29 88.29 0 0 1 49.63 168m98.78 45.6a135.3 135.3 0 0 0 22.3-45.6h35.66a88.29 88.29 0 0 1-57.96 45.6"/></g></svg></span>
<span class="tz__name">뉴욕</span>
</span>
<span class="tz__slot">
<span class="tz__t">07:00</span>
<span class="tz__f" data-k="a">07:00</span>
<span class="tz__f" data-k="b">09:00</span>
<span class="tz__f" data-k="c">12:00</span>
</span>
<span class="tz__ctl">
<button class="tz__nudge" type="button" data-d="-1" aria-label="30분 빼기"><svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M160 48v160l-80-80Z" opacity=".2"/><path d="M163.06 40.61a8 8 0 0 0-8.72 1.73l-80 80a8 8 0 0 0 0 11.32l80 80A8 8 0 0 0 168 208V48a8 8 0 0 0-4.94-7.39M152 188.69L91.31 128L152 67.31Z"/></g></svg></button>
<button class="tz__nudge" type="button" data-d="1" aria-label="30분 더하기"><svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="m176 128l-80 80V48Z" opacity=".2"/><path d="m181.66 122.34l-80-80A8 8 0 0 0 88 48v160a8 8 0 0 0 13.66 5.66l80-80a8 8 0 0 0 0-11.32M104 188.69V67.31L164.69 128Z"/></g></svg></button>
</span>
<span class="tz__rail" role="slider" tabindex="0" id="tz-rail" aria-label="기준 도시 시각" aria-valuemin="0" aria-valuemax="1410" aria-valuenow="420" aria-valuetext="뉴욕 07:00">
<span class="tz__fill"></span>
</span>
</li>
<li class="tz__row" data-r="1" data-next="0">
<span class="tz__city"><span class="tz__name">런던</span></span>
<span class="tz__slot">
<span class="tz__t">12:00</span>
<span class="tz__f" data-k="a">12:00</span>
<span class="tz__f" data-k="b">14:00</span>
<span class="tz__f" data-k="c">17:00</span>
</span>
<span class="tz__badge">다음날</span>
</li>
<li class="tz__row" data-r="2" data-next="0">
<span class="tz__city"><span class="tz__name">서울</span></span>
<span class="tz__slot">
<span class="tz__t">20:00</span>
<span class="tz__f" data-k="a">20:00</span>
<span class="tz__f" data-k="b">22:00</span>
<span class="tz__f" data-k="c">01:00</span>
</span>
<span class="tz__badge">다음날</span>
</li>
</ul>
<p class="tz__live" id="tz-live" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-mint;
@include stage($stage-ink);
.tz {
box-sizing: border-box;
width: min(300px, 90vw);
color: $subject-cream;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.tz__rows {
display: flex;
flex-direction: column;
gap: $sp-1;
margin: 0;
padding: 0;
list-style: none;
}
.tz__row {
display: grid;
grid-template-columns: 116px 1fr auto;
align-items: center;
gap: $sp-2;
padding: $sp-1 $sp-2;
border-radius: $r-sm;
background-color: rgba($subject-cream, .07);
}
.tz__row--base {
row-gap: $sp-1;
background-color: rgba($color, .14);
}
// 도시 이름은 로케일마다 길이가 다르다 — 칸을 넘기지 말고 칸 안에서 잘린다
.tz__city {
display: flex;
align-items: center;
gap: $sp-1;
min-width: 0;
overflow: hidden;
}
.tz__ic { display: inline-flex; flex: 0 0 auto; color: $color; }
.tz__ic svg { display: block; }
.tz__name {
min-width: 0;
overflow: hidden;
font-size: 12px;
line-height: 16px;
font-weight: 600;
white-space: nowrap;
text-overflow: ellipsis;
}
.tz__slot { display: grid; }
.tz__t,
.tz__f {
grid-area: 1 / 1;
justify-self: start;
font-size: 18px;
line-height: 24px;
font-weight: 800;
font-variant-numeric: tabular-nums;
letter-spacing: .01em;
}
.tz__row--base .tz__t,
.tz__row--base .tz__f {
font-size: 22px;
line-height: 26px;
color: $color;
}
// 루프 전용 값 석 장 — 사람이 조작하면 위의 실제 값이 다시 보인다
.tz__f { display: none; }
.tz.is-demo .tz__f { display: block; }
.tz.is-demo .tz__t { display: none; }
.tz__ctl {
display: flex;
align-items: center;
gap: $sp-1;
}
.tz__nudge {
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
margin: 0;
padding: 0;
width: 24px;
height: 24px;
border: 0;
border-radius: $r-xs;
background-color: rgba($subject-cream, .14);
color: $subject-cream;
cursor: pointer;
}
.tz__nudge svg { display: block; }
.tz__nudge:focus-visible,
.tz__rail:focus-visible { outline: 3px solid $color; outline-offset: 2px; }
.tz__rail {
grid-column: 1 / -1;
position: relative;
display: block;
height: 6px;
overflow: hidden;
border-radius: $r-pill;
background-color: rgba($subject-cream, .18);
cursor: ew-resize;
}
.tz__fill {
display: block;
width: 100%;
height: 100%;
border-radius: $r-pill;
background-color: $color;
transform: scaleX(var(--p, .2979));
transform-origin: left center; // 규칙8: 타임존 채움 좌측 기준
}
// 날짜가 넘어간 줄에만 붙는 표시 — 정적 상태에서는 data-next 가 켠다
.tz__badge {
padding: 0 $sp-1;
border-radius: $r-xs;
background-color: $subject-blue;
color: $subject-cream;
font-size: 10px;
line-height: 16px;
font-weight: 700;
white-space: nowrap;
opacity: 0;
}
.tz:not(.is-demo) .tz__row[data-next="1"] .tz__badge { opacity: 1; }
.tz__live {
position: absolute;
margin: 0;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// ── 자동 루프 ──────────────────────────────────────────
// 세 줄은 같은 keyframes 를 쓴다 — 한 프레임에 함께 갈린다(steps). 사이를 채우는 건
// 기준 줄의 막대(scaleX)와 배지의 translateY 팝이다
.tz.is-demo .tz__f {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.tz.is-demo .tz__f[data-k="a"] { animation-name: tz-a; }
.tz.is-demo .tz__f[data-k="b"] { animation-name: tz-b; }
.tz.is-demo .tz__f[data-k="c"] { animation-name: tz-c; }
@keyframes tz-a {
0% { opacity: 1; }
30% { opacity: 0; }
86% { opacity: 1; }
100% { opacity: 1; }
}
@keyframes tz-b {
0% { opacity: 0; }
30% { opacity: 1; }
58% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes tz-c {
0% { opacity: 0; }
58% { opacity: 1; }
86% { opacity: 0; }
100% { opacity: 0; }
}
.tz.is-demo .tz__fill {
animation-name: tz-slide;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes tz-slide {
0% { transform: scaleX(.2979); }
20% { transform: scaleX(.2979); }
30% { transform: scaleX(.383); }
48% { transform: scaleX(.383); }
58% { transform: scaleX(.5106); }
76% { transform: scaleX(.5106); }
86% { transform: scaleX(.2979); }
100% { transform: scaleX(.2979); }
}
.tz.is-demo .tz__row[data-r="2"] .tz__badge {
animation-name: tz-badge, tz-badge-pop;
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: steps(1, end), $easing;
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
// 배지는 값과 같은 프레임에 나가고 들어온다 — 01:00 인데 다음날 표시만 없는 프레임을 만들지 않는다
@keyframes tz-badge {
0% { opacity: 0; }
58% { opacity: 1; }
86% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes tz-badge-pop {
0% { transform: translateY(7px); }
58% { transform: translateY(7px); }
67% { transform: translateY(0); }
80% { transform: translateY(0); }
86% { transform: translateY(7px); }
100% { transform: translateY(7px); }
}
@media (max-width: 340px) {
.tz__row { grid-template-columns: 104px 1fr auto; gap: $sp-1; }
.tz__name { font-size: 11px; line-height: 14px; }
.tz__t,
.tz__f { font-size: 16px; line-height: 20px; }
.tz__row--base .tz__t,
.tz__row--base .tz__f { font-size: 19px; line-height: 22px; }
.tz__nudge { width: 22px; height: 22px; }
}
@media (prefers-reduced-motion: reduce) {
.tz.is-demo .tz__f,
.tz.is-demo .tz__fill,
.tz.is-demo .tz__row[data-r="2"] .tz__badge { animation: none !important; }
.tz.is-demo .tz__f[data-k="b"],
.tz.is-demo .tz__f[data-k="c"] { display: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tz {
box-sizing: border-box;
width: min(300px, 90vw);
color: #fff7e6;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.tz__rows {
display: flex;
flex-direction: column;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
}
.tz__row {
display: grid;
grid-template-columns: 116px 1fr auto;
align-items: center;
gap: 8px;
padding: 4px 8px;
border-radius: 8px;
background-color: rgba(255, 247, 230, 0.07);
}
.tz__row--base {
row-gap: 4px;
background-color: rgba(76, 212, 166, 0.14);
}
.tz__city {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
overflow: hidden;
}
.tz__ic {
display: inline-flex;
flex: 0 0 auto;
color: #4cd4a6;
}
.tz__ic svg {
display: block;
}
.tz__name {
min-width: 0;
overflow: hidden;
font-size: 12px;
line-height: 16px;
font-weight: 600;
white-space: nowrap;
text-overflow: ellipsis;
}
.tz__slot {
display: grid;
}
.tz__t,
.tz__f {
grid-area: 1/1;
justify-self: start;
font-size: 18px;
line-height: 24px;
font-weight: 800;
font-variant-numeric: tabular-nums;
letter-spacing: 0.01em;
}
.tz__row--base .tz__t,
.tz__row--base .tz__f {
font-size: 22px;
line-height: 26px;
color: #4cd4a6;
}
.tz__f {
display: none;
}
.tz.is-demo .tz__f {
display: block;
}
.tz.is-demo .tz__t {
display: none;
}
.tz__ctl {
display: flex;
align-items: center;
gap: 4px;
}
.tz__nudge {
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
margin: 0;
padding: 0;
width: 24px;
height: 24px;
border: 0;
border-radius: 4px;
background-color: rgba(255, 247, 230, 0.14);
color: #fff7e6;
cursor: pointer;
}
.tz__nudge svg {
display: block;
}
.tz__nudge:focus-visible,
.tz__rail:focus-visible {
outline: 3px solid #4cd4a6;
outline-offset: 2px;
}
.tz__rail {
grid-column: 1/-1;
position: relative;
display: block;
height: 6px;
overflow: hidden;
border-radius: 999px;
background-color: rgba(255, 247, 230, 0.18);
cursor: ew-resize;
}
.tz__fill {
display: block;
width: 100%;
height: 100%;
border-radius: 999px;
background-color: #4cd4a6;
transform: scaleX(var(--p, 0.2979));
transform-origin: left center;
}
.tz__badge {
padding: 0 4px;
border-radius: 4px;
background-color: #2f6df6;
color: #fff7e6;
font-size: 10px;
line-height: 16px;
font-weight: 700;
white-space: nowrap;
opacity: 0;
}
.tz:not(.is-demo) .tz__row[data-next="1"] .tz__badge {
opacity: 1;
}
.tz__live {
position: absolute;
margin: 0;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.tz.is-demo .tz__f {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.tz.is-demo .tz__f[data-k=a] {
animation-name: tz-a;
}
.tz.is-demo .tz__f[data-k=b] {
animation-name: tz-b;
}
.tz.is-demo .tz__f[data-k=c] {
animation-name: tz-c;
}
@keyframes tz-a {
0% {
opacity: 1;
}
30% {
opacity: 0;
}
86% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes tz-b {
0% {
opacity: 0;
}
30% {
opacity: 1;
}
58% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes tz-c {
0% {
opacity: 0;
}
58% {
opacity: 1;
}
86% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.tz.is-demo .tz__fill {
animation-name: tz-slide;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes tz-slide {
0% {
transform: scaleX(0.2979);
}
20% {
transform: scaleX(0.2979);
}
30% {
transform: scaleX(0.383);
}
48% {
transform: scaleX(0.383);
}
58% {
transform: scaleX(0.5106);
}
76% {
transform: scaleX(0.5106);
}
86% {
transform: scaleX(0.2979);
}
100% {
transform: scaleX(0.2979);
}
}
.tz.is-demo .tz__row[data-r="2"] .tz__badge {
animation-name: tz-badge, tz-badge-pop;
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
@keyframes tz-badge {
0% {
opacity: 0;
}
58% {
opacity: 1;
}
86% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes tz-badge-pop {
0% {
transform: translateY(7px);
}
58% {
transform: translateY(7px);
}
67% {
transform: translateY(0);
}
80% {
transform: translateY(0);
}
86% {
transform: translateY(7px);
}
100% {
transform: translateY(7px);
}
}
@media (max-width: 340px) {
.tz__row {
grid-template-columns: 104px 1fr auto;
gap: 4px;
}
.tz__name {
font-size: 11px;
line-height: 14px;
}
.tz__t,
.tz__f {
font-size: 16px;
line-height: 20px;
}
.tz__row--base .tz__t,
.tz__row--base .tz__f {
font-size: 19px;
line-height: 22px;
}
.tz__nudge {
width: 22px;
height: 22px;
}
}
@media (prefers-reduced-motion: reduce) {
.tz.is-demo .tz__f,
.tz.is-demo .tz__fill,
.tz.is-demo .tz__row[data-r="2"] .tz__badge {
animation: none !important;
}
.tz.is-demo .tz__f[data-k=b],
.tz.is-demo .tz__f[data-k=c] {
display: none;
}
}// 실제 동작: 시차는 Intl 실시간 값이 아니라 기준 도시에서 몇 분 떨어졌는지를 적은 고정 배열이다.
// 기준 시각이 바뀌면 세 줄을 한 번에 다시 그린다 — 한 값에서 세 줄이 나오므로 어긋날 수가 없다
var root = document.querySelector('.tz');
var rail = document.getElementById('tz-rail');
var live = document.getElementById('tz-live');
var rows = root.querySelectorAll('.tz__row');
var OFF = [0, 300, 780];
var MAX = 1410, STEP = 30;
var base = 7 * 60;
function pad(n) { return (n < 10 ? '0' : '') + n; }
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(' · ');
}
function move(d) { base = Math.max(0, Math.min(MAX, base + d * STEP)); apply(); }
root.addEventListener('click', function (e) {
var b = e.target.closest('.tz__nudge');
if (b) { move(Number(b.getAttribute('data-d'))); rail.focus({ preventScroll: true }); }
});
rail.addEventListener('keydown', function (e) {
var d = e.key === 'ArrowRight' || e.key === 'ArrowUp' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowDown' ? -1 : 0;
if (d) { e.preventDefault(); move(d); return; }
if (e.key === 'Home' || e.key === 'End') { e.preventDefault(); base = e.key === 'Home' ? 0 : MAX; apply(); }
});
rail.addEventListener('pointerdown', function (e) {
var r = rail.getBoundingClientRect();
base = Math.round(Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)) * MAX / STEP) * STEP;
apply();
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ThreeCityConvert.module.scss";
type KeyEvt = { key: string; preventDefault(): void };
type PtrEvt = { clientX: number; currentTarget: { getBoundingClientRect(): { left: number; width: number } } };
type Box<T> = { current: T };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type RailVars = CSSProperties & { "--p"?: string };
/** 기준 도시에서 몇 분 떨어졌는지 — Intl 실시간 값이 아니라 고정 오프셋이다 */
export type City = { name: string; offset: number };
export type ThreeCityConvertProps = {
cities?: [City, City, City];
base?: number;
nextDayLabel?: string;
sliderLabel?: string;
minusLabel?: string;
plusLabel?: string;
duration?: number;
easing?: string;
color?: string;
};
const MAX = 1410;
const STEP = 30;
const DEMO = [420, 540, 720]; // 격자 미리보기(.isDemo) 루프가 훑는 기준 시각 석 장
const GLOBE = (
<g fill="currentColor">
<path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" />
<path d="M128 24a104 104 0 1 0 104 104A104.12 104.12 0 0 0 128 24m88 104a87.6 87.6 0 0 1-3.33 24h-38.51a157.4 157.4 0 0 0 0-48h38.51a87.6 87.6 0 0 1 3.33 24m-114 40h52a115.1 115.1 0 0 1-26 45a115.3 115.3 0 0 1-26-45m-3.9-16a140.8 140.8 0 0 1 0-48h59.88a140.8 140.8 0 0 1 0 48ZM40 128a87.6 87.6 0 0 1 3.33-24h38.51a157.4 157.4 0 0 0 0 48H43.33A87.6 87.6 0 0 1 40 128m114-40h-52a115.1 115.1 0 0 1 26-45a115.3 115.3 0 0 1 26 45m52.33 0h-35.62a135.3 135.3 0 0 0-22.3-45.6A88.29 88.29 0 0 1 206.37 88Zm-98.74-45.6A135.3 135.3 0 0 0 85.29 88H49.63a88.29 88.29 0 0 1 57.96-45.6M49.63 168h35.66a135.3 135.3 0 0 0 22.3 45.6A88.29 88.29 0 0 1 49.63 168m98.78 45.6a135.3 135.3 0 0 0 22.3-45.6h35.66a88.29 88.29 0 0 1-57.96 45.6" />
</g>
);
const CARET_LEFT = (
<g fill="currentColor">
<path d="M160 48v160l-80-80Z" opacity=".2" />
<path d="M163.06 40.61a8 8 0 0 0-8.72 1.73l-80 80a8 8 0 0 0 0 11.32l80 80A8 8 0 0 0 168 208V48a8 8 0 0 0-4.94-7.39M152 188.69L91.31 128L152 67.31Z" />
</g>
);
const CARET_RIGHT = (
<g fill="currentColor">
<path d="m176 128l-80 80V48Z" opacity=".2" />
<path d="m181.66 122.34l-80-80A8 8 0 0 0 88 48v160a8 8 0 0 0 13.66 5.66l80-80a8 8 0 0 0 0-11.32M104 188.69V67.31L164.69 128Z" />
</g>
);
function pad(n: number) {
return (n < 10 ? "0" : "") + n;
}
function hhmm(m: number) {
const t = m % 1440;
return pad(Math.floor(t / 60)) + ":" + pad(t % 60);
}
export default function ThreeCityConvert({
cities = [
{ name: "New York", offset: 0 },
{ name: "London", offset: 300 },
{ name: "Seoul", offset: 780 },
],
base = 7 * 60,
nextDayLabel = "next day",
sliderLabel = "Reference city time",
minusLabel = "back 30 minutes",
plusLabel = "forward 30 minutes",
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#4cd4a6",
}: ThreeCityConvertProps) {
const [demo, setDemo] = useState(true);
const [at, setAt] = useState(base);
const rail = useRef<HTMLSpanElement | null>(null) as Box<HTMLSpanElement | null>;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const railVars: RailVars = { "--p": (at / MAX).toFixed(4) };
// 세 줄이 한 값에서 나오므로 어긋날 수가 없다 — 같은 렌더에 함께 갈린다
const lines = cities.map((c) => ({ name: c.name, t: hhmm(at + c.offset), next: at + c.offset >= 1440 }));
const say = lines.map((l) => `${l.name} ${l.t}${l.next ? " " + nextDayLabel : ""}`);
function move(d: number) {
setDemo(false);
setAt((p) => Math.max(0, Math.min(MAX, p + d * STEP)));
}
function onKey(e: KeyEvt) {
const d = e.key === "ArrowRight" || e.key === "ArrowUp" ? 1 : e.key === "ArrowLeft" || e.key === "ArrowDown" ? -1 : 0;
if (d) {
e.preventDefault();
move(d);
return;
}
if (e.key === "Home" || e.key === "End") {
e.preventDefault();
setDemo(false);
setAt(e.key === "Home" ? 0 : MAX);
}
}
function onDown(e: PtrEvt) {
const r = e.currentTarget.getBoundingClientRect();
const p = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
setDemo(false);
setAt(Math.round((p * MAX) / STEP) * STEP);
}
function nudge(d: number, label: string) {
return (
<button
className={styles.nudge}
type="button"
aria-label={label}
onClick={() => {
move(d);
if (rail.current) rail.current.focus({ preventScroll: true });
}}
>
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true">{d < 0 ? CARET_LEFT : CARET_RIGHT}</svg>
</button>
);
}
return (
<div className={`${styles.tz} ${demo ? styles.isDemo : ""}`} style={vars}>
<ul className={styles.rows}>
{lines.map((l, i) => (
<li
key={l.name}
className={`${styles.row} ${i === 0 ? styles.rowBase : ""}`}
data-r={i}
data-next={l.next ? "1" : "0"}
>
<span className={styles.city}>
{i === 0 ? (
<span className={styles.ic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="14" height="14">{GLOBE}</svg>
</span>
) : null}
<span className={styles.name}>{l.name}</span>
</span>
<span className={styles.slot}>
<span className={styles.t}>{l.t}</span>
{DEMO.map((d, k) => (
<span className={styles.f} data-k={["a", "b", "c"][k]} key={d}>
{hhmm(d + cities[i].offset)}
</span>
))}
</span>
{i === 0 ? (
<span className={styles.ctl}>
{nudge(-1, minusLabel)}
{nudge(1, plusLabel)}
</span>
) : (
<span className={styles.badge}>{nextDayLabel}</span>
)}
{i === 0 ? (
<span
className={styles.rail}
style={railVars}
role="slider"
tabIndex={0}
aria-label={sliderLabel}
aria-valuemin={0}
aria-valuemax={MAX}
aria-valuenow={at}
aria-valuetext={say[0]}
ref={(el: HTMLSpanElement | null) => {
rail.current = el;
}}
onKeyDown={onKey}
onPointerDown={onDown}
>
<span className={styles.fill} />
</span>
) : null}
</li>
))}
</ul>
<p className={styles.live} aria-live="polite">{say.join(" · ")}</p>
</div>
);
}09 · 한 시각을 세 도시가 동시에 읽는 표 — 측정 누적면적 1.799% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
해외 팀과 회의를 잡을 때처럼 시차를 같이 봐야 하는 일정 화면에 씁니다.
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%로, 칸 열둘이 차례로 떠오르는 동안 화면 대부분이 한 번씩 바뀝니다. 본인인증 화면에서 시각을 다시 입력받는 흐름은 본인인증 화면 UI가 같은 폼으로 풀어 둔 케이스입니다. 아홉 벌 소스가 든 압축 파일의 비밀번호는 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
타임피커 ui 를 브라우저 기본 <input type="time"> 대신 직접 만드는 이유가 뭔가요?
한 시각만 받으면 기본 입력이 낫습니다. 손댈 것도 없고 모바일에서는 운영체제 선택기가 뜹니다. 직접 만드는 이유는 기본 입력이 한 점밖에 담지 못해서입니다. 04·05번처럼 시작과 끝을 한 축에서 같이 잡거나, 03번처럼 이미 찬 시간대를 눌리지 않게 막거나, 06번처럼 반복 규칙을 문장으로 확인시키는 일은 기본 입력에는 자리가 없습니다. 시각을 하루씩 잡아야 한다면 달력 선택 UI가 점에서 날로 넓히는 판입니다.
시간대는 서버와 어떻게 맞추나요?
09번 데모는 기준 도시에서 몇 분 떨어졌는지를 적은 고정 배열을 씁니다. 실제 서비스라면 저장은 UTC 한 가지로, 표시는 도시나 지역 이름으로 하고 변환은 표시할 때 한 번만 합니다. 09번 구조가 그 연습에 가깝습니다. 기준값 하나에서 모든 줄이 파생되므로 서버와 맞출 지점도 하나뿐이고, 줄끼리 어긋날 틈이 없습니다.
30분 스냅 단위는 어떻게 정하나요?
예약 한 건이 실제로 차지하는 시간에서 나옵니다. 03번은 30분 칸을 열둘 깔았고, 05번은 하루를 48칸으로 나눠 30분마다 붙으며, 04번은 96칸이라 15분마다 붙되 최소 간격을 네 칸, 곧 한 시간으로 잡았습니다. 단위를 잘게 쪼갤수록 손가락으로 정확히 짚기 어려워지니, 끌어서 고르는 부품은 30분, 눌러서 고르는 칸은 15분까지를 바닥으로 두면 무난합니다.









