09한 시각을 세 도시가 동시에 읽는 표
기준 도시의 시각을 옮기면 나머지 두 줄이 같은 프레임에 함께 갈리고, 날짜가 넘어간 줄에는 다음날 표시가 따로 붙습니다.
쓰임해외 팀 회의 시간을 잡을 때처럼 시차를 같이 봐야 하는 일정 화면
steps(1, end)aria-livetranslateY

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


