タイムピッカー ui 9選 — 一点から区間まで
タイムピッカー ui とは、何時何分という一点だけでなく、いつからいつまでという長さまで画面上で決めるための部品のことです。ダイヤル・ドラム・枠の格子から、区間バー・繰り返しルール・時差表まで、実際に動く9つを置きました。
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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 針を回して選ぶ丸いダイヤル
- 02 時と分が別々に回るドラムローラー
- 03 空いている枠だけ押せるスロットグリッド
- 04 二つのつまみが押し合う区間バー
- 05 曜日ごとに一本ずつ伸ばす勤務時間バー
- 06 チップを押すと文になる繰り返しルール
- 07 押すと結果の時刻まで見せるプリセット
- 08 分と秒を上げると輪が回るカウントダウン
- 09 一つの時刻を三つの都市が同時に読む表
並び順は人気順ではなく、答えたい問いが「いつ」から「どれだけの長さ」へ移る順番です。最初の三つは一点の時刻を決めます(01〜03)。次の二つは始まりと終わりを同時に持つ区間です(04・05)。六番目と七番目は一度きりで終わらない時刻を扱います。つまり繰り返しと、今から何分先かという相対時間です(06・07)。最後の二つでは時刻そのものより長さと時差が主役になります(08・09)。日付そのものを月のカレンダーから選ぶ側は カレンダーの月表示グリッド に、選択部品全体の型は ピッカー UI のパターン にまとめてあります。九つに共通する原則は一つで、確定した値は必ず一か所にだけ書き、見た目も読み上げもその一か所を読みます。react タイムピッカー へ移し替えるときもこの考え方は変わらず、値の持ち主がコンポーネントの状態に移るだけです。選ぶ対象が場所なら地図 ui デザインが前席で、色ならカラーピッカー UIがその席を代わります。
01針を回して選ぶ丸いダイヤル
何が見えるか
丸い盤の上で針をドラッグすると、指の角度が Math.atan2 で時刻に変わります。指を離した瞬間に時が決まり、同じ盤がそのまま分の目盛りへ一度だけ切り替わるので、二段階が同じ場所で終わります。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
見た目は時計でも中身は role="slider" なので、値は aria-valuenow に、読ませたい言い方は aria-valuetext に分けて書きます。
function fromPointer(e) {
var r = dial.getBoundingClientRect();
var a = Math.atan2(e.clientX - r.left - r.width / 2, r.top + r.height / 2 - e.clientY) * 180 / Math.PI;
a = (a + 360) % 360;
if (s.hour) { s.h = Math.round(a / 30) || 12; } else { s.m = (Math.round(a / 30) * 5) % 60; }
paint();
}
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(); }
});
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
スクロールが止まってから 90 ミリ秒後に読むので、滑っている途中の値が確定として扱われることはありません。
var H = cols[0].querySelector('.dr__cell').getBoundingClientRect().height;
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])));
}
c.addEventListener('scroll', function () {
if (!human) return;
clearTimeout(tid); tid = setTimeout(read, 90);
});
03空いている枠だけ押せるスロットグリッド
どんな動きか
30分刻みの枠が格子に並び、埋まった枠は見た目を薄くするのではなく本物の disabled なので、押しても何も起きません。選んだ枠は aria-pressed だけが覚え、マスの色はその属性が決め、確定バッジは外側の箱に付く .is-picked を読んで一コマで現れます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
矢印キーの移動は満席の枠を同じ向きへ飛び越すので、押せない枠でフォーカスが止まりません。
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);
}
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 });
});
04二つのつまみが押し合う区間バー
何が見えるか
一日を 96 マス、つまり 15 分刻みに割った一本の軸を、開始と終了のつまみが分け合います。最小の間隔(MIN = 4、1時間)より近づくと相手を押しのけるので、値が入れ替わることがありません。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
間の帯をつかむと長さ L を保ったまま区間全体が動きます。
var v = [38, 72], MIN = 4, drag = null, st = 0, sv = [0, 0];
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(); }
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);
});
05曜日ごとに一本ずつ伸ばす勤務時間バー
画面で起きること
曜日ごとの5行にそれぞれ一本の横棒が置かれ、両端をドラッグすると 30 分刻み(一日 48 マス)で吸着します。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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
棒の上の読みも二つの aria-valuetext も、同じ draw の中で一緒に書き直されます。
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) {
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">毎月 第2月曜日 午前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 = ['第1', '第2', '第3', '第4', '第5'];
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
チップは role="checkbox"、上のセグメントは role="radiogroup" なので、状態はクラスではなく aria-checked にだけ書かれます。
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];
}
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();
});
});
07押すと結果の時刻まで見せるプリセット
何が見えるか
チップには基準時刻に足す分(data-add)か、0 時から数えた分(data-at)のどちらかが書いてあり、結果の時刻はその場で計算されます。1440 分に達したら翌日用の文型に切り替わるので、言い回しは HTML に、計算は JS に分かれたままです。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
同じ値を選び直したときは転がさず、違う値のときだけ下から一度上がって入れ替わります。
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;
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分と秒を上げると輪が回るカウントダウン
画面で起きること
分と秒の欄は本物の role="spinbutton" で、上下キーが分を1ずつ、秒を5ずつ動かします。開始を押すと残り時間の比率が --ring-angle に出て、周りの conic-gradient の輪がその分だけ減ります。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
角度が途中の値を通れるのは、そのカスタムプロパティが @property で登録されているからです(MDN の @property)。
@property --ring-angle {
syntax: "<angle>";
inherits: false;
initial-value: 360deg;
}
.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);
}
@keyframes cd-drain {
0% { --ring-angle: 360deg; }
62% { --ring-angle: 72deg; }
86% { --ring-angle: 360deg; }
100% { --ring-angle: 360deg; }
}
09一つの時刻を三つの都市が同時に読む表
どんな動きか
時差は実行時に問い合わせるのではなく、基準都市から何分離れているかを書いた配列(OFF = [0, 300, 780])で持ちます。基準の分が変わると三行が一度のループで書き直されるので、一つの値から三つの表示が出て、ずれようがありません。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
1440 分に達した行にだけ翌日の印が付き、その文言も読み上げ領域の一文に混ざります。
var OFF = [0, 300, 780];
var MAX = 1410, STEP = 30;
var base = 7 * 60;
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 のドラムです。.dr__col は scroll-snap-type: y mandatory が効いた本物のスクロールコンテナで、自動再生は中の .dr__track を translateY で動かします。ところが mandatory のスナップは、トラックが transform で動いた分だけスクロール位置を合わせ直そうとするため、動かした見た目がその場で打ち消されました。答えは動きを弱めることではなく、プレビューの間だけスナップを外すことです。.dr.is-demo .dr__col { scroll-snap-type: none; } の一行を足し、人が触った瞬間に .is-demo が外れて mandatory が戻るようにしました。同じ列でもう一つ効いているのが .dr__track の padding-block: var(--h) で、上下に一マス分の余白がないと最初の 01 と最後の 12 が中央の窓まで届かず、両端の値が選べない列になります。窓の外は mask-image のグラデーションで薄くしてあるので、見えている数字と確定した数字を取り違えにくくなっています。
二つ目は 08 の輪です。--ring-angle を keyframes で 360deg から 72deg へ動かしても、登録していないカスタムプロパティは中身がただの文字列なので補間されず、角度が途中の値を取らずに飛びます。@property で syntax: "<angle>" を宣言して初めて滑らかにつながります。そして inherits: false なので値は親から降りてきません。だから JS 側も ringEl.style.setProperty('--ring-angle', (360 * f).toFixed(1) + 'deg') と、輪の要素そのものに角度を書いています。親に書いても輪には届きません。減り方にも一手あって、輪は 0deg まで落とさず 62% の地点の 72deg で止め、そこから 86% まで使ってゆっくり満たし直します。一度でも空の輪のコマができると、その一枚が poster に選ばれて「何も設定されていないタイマー」の絵になってしまうからです。
三つ目は切り替えの作り方です。値の差し替えを steps(1, end) の一コマのカットだけで済ませると、隣り合うコマの差がほとんど出ず、動いたコマ数が足りません。24 コマの実測で動いたコマは 07 が 11、09 が 12 で、九つの中でこの二つだけが半分前後にとどまりました。累積変化面積でも 09 は 1.799% と最も小さく、最大の 03(17.651%)の十分の一ほどです。そこで 09 は、カットと同じ瞬間に基準行の棒を scaleX で 0.2979 から 0.383 へ伸ばし、翌日バッジを translateY で 7px 押し上げています。07 も同じ理由で、押されたチップが scale(.93) から scale(1.05) へ跳ね、値は見えていない間に 5px 下がってカットと一緒に戻ります。カットの隣に連続した動きを一つ置くだけで、静止画に見える瞬間が消えました。01 は逆に動いたコマが 23 と九つで最も多く、針が毎コマ回るので補助はいりませんでした。同じ理屈は 03 にもあり、確定バッジと下の文はどちらも 40% の一点でカットします。片方だけ先に出ると、バッジは付いているのに文は「時間を選んでください」のままというコマが一枚残るからです。本人確認の画面で時刻を入力し直す流れは本人確認 画面UIが同じフォームで解いた場合です。九つのフォルダをこの並びのまま開くパスワードは 6meczddf で、ここで挙げた keyframes はどれもフォルダの scss にそのまま入っています。
アクセシビリティ
prefers-reduced-motion: reduce では、九つとも .is-demo の自動再生を animation: none !important で止め、そのうえで静止した一枚だけを残します。02 と 03 は [data-live] の行を opacity: 1 に戻し、07・08・09 は差し替え用の複製を display: none で消して先頭の値だけを見せます。05 は水曜の二枚目の読みを opacity: 0 にし、01 は時の目盛りの輪を出したままにします。止めても情報が減らないのは、動いていたのが値そのものではなく値の切り替え方だからです。操作は JS 側にあるので、reduce でもドラッグもキーも同じように効きます。役割の使い分けは MDN の slider ロール が基準です。予約や通知の入り口としての置き場所は スケジュールで動く UI にまとめています。
| マウス | キーボード | 状態が書かれる場所 | |
|---|---|---|---|
| 01 | 盤の針をドラッグ | ←→↑↓ で1段、Home・End、Enter・Space で時と分を切り替え | role="slider" の aria-valuenow と aria-valuetext |
| 02 | 列を縦にスクロール | ↑↓ で1マス、Home・End で端へ | 列ごとの role="slider" の aria-valuetext |
| 03 | 空いている枠をクリック | ←→↑↓ で移動(満席は飛ばす)、Home・End | 選んだ <button> の aria-pressed、文は aria-live="polite" |
| 04 | つまみと帯をドラッグ | ←→↑↓ で15分、Home・End | 二つの role="slider" の aria-valuenow(分)と aria-valuetext |
| 05 | 行の端をドラッグ | ←→ で30分、Home・End | 行ごとの role="slider" 二つ、行は role="group" と aria-labelledby |
| 06 | チップとセグメントをクリック | ←→ でチップ間・モード間を移動 | チップの aria-checked、文は aria-live="polite" |
| 07 | チップをクリック | ←→ で隣のチップへ移り、その場で適用 | チップの aria-pressed と読み上げ領域の結果時刻 |
| 08 | ▲▼ ボタンをクリック | ↑↓ で分1・秒5、Home・End | role="spinbutton" の aria-valuenow と aria-valuetext |
| 09 | ◀▶ とレールをクリック | ←→↑↓ で30分、Home・End | 基準行の role="slider" の aria-valuetext、三行分は aria-live="polite" |
FAQ
html タイムピッカー の <input type="time"> で足りないのはどこですか
足りないのは一点より広い形です。標準の入力は「何時何分」を一つ返すだけなので、04 のような始まりと終わりの組も、06 のような繰り返しルールも、09 のような三都市同時の読みも表現できません。03 のように満席の枠を押せなくして残りだけを一目で見せるといった空き状況の扱いも、入力欄一つには持たせる場所がありません。時刻を丸一日単位で選ぶならカレンダー 選択 UIが点から日へ広げる版です。
サーバーとの時差はどう合わせますか
持ち回るのは表示用の文字列ではなく、基準からの分だけにします。09 は基準都市からの差を OFF = [0, 300, 780] という分の配列で持ち、基準の分が変わったら三行をまとめて書き直します。値が一つで、書き出しが一度にまとまっていれば、行同士がずれることはありません。実務でも同じ形で、UTC の分を唯一の値として保存し、都市名や翌日の印は描く直前に組み立てます。
スナップ幅はどう決めますか
受け取る側が実際に扱える最小単位に合わせます。この9つでも 04 は 15 分(一日 96 マス)、05 は 30 分(一日 48 マス)、03 は 30 分の枠を十二個とそれぞれ別で、02 は一マスの高さ --h で scrollTop を割って何番目かを求めます。予約枠が 30 分単位なら 5 分刻みで選ばせても指が疲れるだけですし、逆に細かい設定を 30 分刻みにすると入れたい値が入りません。









