04Timezone Cards Split From a Single Time Value
A single typed time value has each region offset added or subtracted to recompute every card time, and card background brightness also shifts with day or night.
Best forVideo-call scheduling forms and cross-timezone team pages
addOffsetMinutesbrightnessOfisDaytime

<main class="stage">
<div class="tz is-demo" id="tz">
<label class="tz__field">
<span class="tz__cap">Reference time</span>
<input class="tz__input" id="tzBase" type="time" step="60" value="09:30" aria-describedby="tzReadout">
</label>
<div class="tz__cards" id="tzCards" aria-hidden="true"></div>
<p class="tz__readout" id="tzReadout" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(오프셋 합산, 낮·밤 판정과 밝기)은 index.html 의 addOffsetMinutes()/brightnessOf() 에 있다. 여기는 무대·레이아웃만.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-paper);
.tz {
display: flex;
flex-direction: column;
align-items: stretch;
gap: $sp-2;
width: min(260px, 88vw);
background: $subject-blue-deep;
border-radius: $r-card;
padding: $sp-4;
box-shadow: $shadow-raised;
}
.tz__field {
display: flex;
flex-direction: column;
gap: $sp-1;
}
.tz__cap {
color: $color;
font-size: 12px;
font-weight: 500;
}
.tz__input {
border: 0;
border-radius: $r-control;
padding: $sp-1 $sp-2;
background: $subject-cream;
color: $subject-ink;
font-weight: 600;
font-size: 14px;
}
.tz__input:focus-visible {
outline: 2px solid $color;
outline-offset: 2px;
}
.tz__cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: $sp-2;
}
.tz__card {
--b: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-1;
border-radius: $r-sm;
padding: $sp-2 $sp-1;
background: hsl(224 40% calc(16% + var(--b) * 22%));
transition: box-shadow $duration $easing;
}
.tz__offset {
color: $color;
font-size: 10px;
font-weight: 600;
}
.tz__time {
color: $color;
font-size: 13px;
font-weight: 700;
}
.tz__readout {
margin: 0;
color: $color;
font-size: 12px;
font-weight: 500;
line-height: 1.3;
text-align: center;
}
@media (max-width: 340px) {
.tz { padding: $sp-3; gap: $sp-1; width: min(220px, 84vw); }
.tz__cap { font-size: 10px; }
.tz__input { font-size: 12px; }
.tz__cards { grid-template-columns: repeat(2, 1fr); gap: $sp-1; }
.tz__card { padding: $sp-1; gap: $sp-1; }
.tz__offset { font-size: 9px; }
.tz__time { font-size: 12px; }
.tz__readout { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.tz__card { transition: 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);
}
.tz {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 8px;
width: min(260px, 88vw);
background: #0a4fe8;
border-radius: 16px;
padding: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.tz__field {
display: flex;
flex-direction: column;
gap: 4px;
}
.tz__cap {
color: #fff7e6;
font-size: 12px;
font-weight: 500;
}
.tz__input {
border: 0;
border-radius: 12px;
padding: 4px 8px;
background: #fff7e6;
color: #17141a;
font-weight: 600;
font-size: 14px;
}
.tz__input:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.tz__cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.tz__card {
--b: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
border-radius: 8px;
padding: 8px 4px;
background: hsl(224, 40%, calc(16% + var(--b) * 22%));
transition: box-shadow 2s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tz__offset {
color: #fff7e6;
font-size: 10px;
font-weight: 600;
}
.tz__time {
color: #fff7e6;
font-size: 13px;
font-weight: 700;
}
.tz__readout {
margin: 0;
color: #fff7e6;
font-size: 12px;
font-weight: 500;
line-height: 1.3;
text-align: center;
}
@media (max-width: 340px) {
.tz {
padding: 12px;
gap: 4px;
width: min(220px, 84vw);
}
.tz__cap {
font-size: 10px;
}
.tz__input {
font-size: 12px;
}
.tz__cards {
grid-template-columns: repeat(2, 1fr);
gap: 4px;
}
.tz__card {
padding: 4px;
gap: 4px;
}
.tz__offset {
font-size: 9px;
}
.tz__time {
font-size: 12px;
}
.tz__readout {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.tz__card {
transition: none;
}
}(function () {
var root = document.getElementById('tz');
var input = document.getElementById('tzBase');
var cardsBox = document.getElementById('tzCards');
var readout = document.getElementById('tzReadout');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var isDemo = true;
function human() { if (isDemo) { root.classList.remove('is-demo'); isDemo = false; } }
// 카드마다 UTC 오프셋(시)You are 쓴다 — 도시·국가 이름 없음
var OFFSETS = [-8, 0, 9, 5.5];
var cards = OFFSETS.map(function (off) {
var el = document.createElement('div');
el.className = 'tz__card';
el.innerHTML = '<span class="tz__offset"></span><span class="tz__time"></span>';
cardsBox.appendChild(el);
return { off: off, el: el, offsetEl: el.querySelector('.tz__offset'), timeEl: el.querySelector('.tz__time') };
});
function pad2(n) { return (n < 10 ? '0' : '') + n; }
function formatOffset(off) {
var sign = off >= 0 ? '+' : '-';
var abs = Math.abs(off);
var h = Math.floor(abs);
var m = Math.round((abs - h) * 60);
return 'UTC' + sign + h + (m ? ':' + pad2(m) : '');
}
// 계산 한 겹 — Reference time(분) 에 오프셋(분)을 더해 각 카드 시각을 구하고, 시간대를 낮·밤으로 판정해 밝기를 정한다
function addOffsetMinutes(baseMinutes, offsetHours) {
var total = (baseMinutes + offsetHours * 60) % 1440;
if (total < 0) total += 1440;
return total;
}
function isDaytime(minutes) { return minutes >= 360 && minutes < 1080; }
function brightnessOf(minutes) {
var distFromNoon = Math.abs(minutes - 720) / 720;
return clamp01(1 - distFromNoon * 0.7);
}
function clamp01(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
var SENT_PREFIX = 'At reference ', SENT_MID = ' time, the first card shows ', SENT_SUFFIX = '.';
var targets = cards.map(function () { return 0; });
var rendered = cards.map(function () { return 0; });
var LERP_RATE = 0.15; // 변형 04: 낮추면 카드 밝기가 값 변화를 더 천천히 뒤따라간다
function readBaseMinutes() {
var parts = input.value.split(':');
var h = parseInt(parts[0], 10) || 0;
var m = parseInt(parts[1], 10) || 0;
return h * 60 + m;
}
function applyValue(baseMinutes) {
cards.forEach(function (card, i) {
var minutes = addOffsetMinutes(baseMinutes, card.off);
var h = Math.floor(minutes / 60);
var m = Math.floor(minutes % 60);
card.offsetEl.textContent = formatOffset(card.off);
card.timeEl.textContent = pad2(h) + ':' + pad2(m);
card.el.classList.toggle('is-night', !isDaytime(minutes));
targets[i] = brightnessOf(minutes);
if (reduce) { rendered[i] = targets[i]; }
});
var bh = Math.floor(baseMinutes / 60), bm = Math.floor(baseMinutes % 60);
var first = cards[0];
var firstMinutes = addOffsetMinutes(baseMinutes, first.off);
var fh = Math.floor(firstMinutes / 60), fm = Math.floor(firstMinutes % 60);
readout.textContent = SENT_PREFIX + pad2(bh) + ':' + pad2(bm) + SENT_MID + pad2(fh) + ':' + pad2(fm) + SENT_SUFFIX;
if (reduce) paint();
}
function paint() {
cards.forEach(function (card, i) {
card.el.style.setProperty('--b', rendered[i].toFixed(3));
});
}
input.addEventListener('input', function () { human(); applyValue(readBaseMinutes()); });
input.addEventListener('change', function () { human(); applyValue(readBaseMinutes()); });
var DEMO_TIMES = ['09:30', '14:00', '23:15', '03:45', '18:20'];
var DRIVE_PERIOD_MS = 2000;
var driveStart = 0, lastIdx = -1;
function driveTick(now) {
if (!driveStart) driveStart = now;
if (isDemo) {
var frac = ((now - driveStart) % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
var idx = Math.floor(frac * DEMO_TIMES.length);
if (idx !== lastIdx) { lastIdx = idx; input.value = DEMO_TIMES[idx]; applyValue(readBaseMinutes()); }
}
requestAnimationFrame(driveTick);
}
function frame() {
rendered = rendered.map(function (v, i) { return v + (targets[i] - v) * LERP_RATE; });
paint();
requestAnimationFrame(frame);
}
applyValue(readBaseMinutes());
if (!reduce) { requestAnimationFrame(driveTick); requestAnimationFrame(frame); }
})();import { useEffect, useRef } from "react";
import styles from "./TimezoneCardStack.module.scss";
const OFFSETS = [-8, 0, 9, 5.5];
type CardRow = { off: number; el: HTMLDivElement; offsetEl: HTMLElement; timeEl: HTMLElement };
export default function TimezoneCardStack() {
const rootRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const cardsBoxRef = useRef<HTMLDivElement | null>(null);
const readoutRef = useRef<HTMLParagraphElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const input = inputRef.current;
const cardsBox = cardsBoxRef.current;
const readout = readoutRef.current;
if (!root || !input || !cardsBox || !readout) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let isDemo = true;
const cards: CardRow[] = OFFSETS.map((off) => {
const el = document.createElement("div");
el.className = styles.card;
const offsetEl = document.createElement("span");
offsetEl.className = styles.offset;
const timeEl = document.createElement("span");
timeEl.className = styles.time;
el.appendChild(offsetEl);
el.appendChild(timeEl);
cardsBox.appendChild(el);
return { off, el, offsetEl, timeEl };
});
function human() {
if (isDemo) {
root!.classList.remove("is-demo");
isDemo = false;
}
}
function pad2(n: number) {
return (n < 10 ? "0" : "") + n;
}
function formatOffset(off: number) {
const sign = off >= 0 ? "+" : "-";
const abs = Math.abs(off);
const h = Math.floor(abs);
const m = Math.round((abs - h) * 60);
return "UTC" + sign + h + (m ? ":" + pad2(m) : "");
}
function addOffsetMinutes(baseMinutes: number, offsetHours: number) {
let total = (baseMinutes + offsetHours * 60) % 1440;
if (total < 0) total += 1440;
return total;
}
function isDaytime(minutes: number) {
return minutes >= 360 && minutes < 1080;
}
function clamp01(v: number) {
return v < 0 ? 0 : v > 1 ? 1 : v;
}
function brightnessOf(minutes: number) {
const distFromNoon = Math.abs(minutes - 720) / 720;
return clamp01(1 - distFromNoon * 0.7);
}
const SENT_PREFIX = "기준 ";
const SENT_MID = " 시각일 때 첫 카드는 ";
const SENT_SUFFIX = "입니다";
let targets = OFFSETS.map(() => 0);
let rendered = OFFSETS.map(() => 0);
const LERP_RATE = 0.15;
function readBaseMinutes() {
const parts = input!.value.split(":");
const h = parseInt(parts[0], 10) || 0;
const m = parseInt(parts[1], 10) || 0;
return h * 60 + m;
}
function applyValue(baseMinutes: number) {
cards.forEach((card, i) => {
const minutes = addOffsetMinutes(baseMinutes, card.off);
const h = Math.floor(minutes / 60);
const m = Math.floor(minutes % 60);
card.offsetEl.textContent = formatOffset(card.off);
card.timeEl.textContent = pad2(h) + ":" + pad2(m);
card.el.classList.toggle("is-night", !isDaytime(minutes));
targets[i] = brightnessOf(minutes);
if (reduce) rendered[i] = targets[i];
});
const bh = Math.floor(baseMinutes / 60);
const bm = Math.floor(baseMinutes % 60);
const first = cards[0];
const firstMinutes = addOffsetMinutes(baseMinutes, first.off);
const fh = Math.floor(firstMinutes / 60);
const fm = Math.floor(firstMinutes % 60);
readout!.textContent =
SENT_PREFIX + pad2(bh) + ":" + pad2(bm) + SENT_MID + pad2(fh) + ":" + pad2(fm) + SENT_SUFFIX;
if (reduce) paint();
}
function paint() {
cards.forEach((card, i) => {
card.el.style.setProperty("--b", rendered[i].toFixed(3));
});
}
function onInput() {
human();
applyValue(readBaseMinutes());
}
input.addEventListener("input", onInput);
input.addEventListener("change", onInput);
const DEMO_TIMES = ["09:30", "14:00", "23:15", "03:45", "18:20"];
const DRIVE_PERIOD_MS = 2000;
let driveStart = 0;
let lastIdx = -1;
let rafDrive = 0;
function driveTick(now: number) {
if (!driveStart) driveStart = now;
if (isDemo) {
const frac = ((now - driveStart) % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
const idx = Math.floor(frac * DEMO_TIMES.length);
if (idx !== lastIdx) {
lastIdx = idx;
input!.value = DEMO_TIMES[idx];
applyValue(readBaseMinutes());
}
}
rafDrive = requestAnimationFrame(driveTick);
}
let rafFrame = 0;
function frame() {
rendered = rendered.map((v, i) => v + (targets[i] - v) * LERP_RATE);
paint();
rafFrame = requestAnimationFrame(frame);
}
applyValue(readBaseMinutes());
if (!reduce) {
rafDrive = requestAnimationFrame(driveTick);
rafFrame = requestAnimationFrame(frame);
}
return () => {
input.removeEventListener("input", onInput);
input.removeEventListener("change", onInput);
if (rafDrive) cancelAnimationFrame(rafDrive);
if (rafFrame) cancelAnimationFrame(rafFrame);
};
}, []);
return (
<div ref={rootRef} className={`${styles.tz} is-demo`}>
<label className={styles.field}>
<span className={styles.cap}>기준 시각</span>
<input ref={inputRef} className={styles.input} type="time" step={60} defaultValue="09:30" aria-describedby="tzReadoutReact" />
</label>
<div ref={cardsBoxRef} className={styles.cards} aria-hidden="true" />
<p ref={readoutRef} className={styles.readout} id="tzReadoutReact" role="status" aria-live="polite" />
</div>
);
}04 Timezone Cards Split From a Single Time Value — Measured area 9.295% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · CSS Calculator Design: 9 Inputs, Unit Converter UI
The post this demo lives in
CSS Calculator Design: 9 Inputs, Unit Converter UIAmong nine css calculator design inputs, one draws a QR grid from a text value, one fills a ring gauge from a birthdate See all demos in this post
If you like this effect - similar demos
Inputs08 Live confirm-match diff — the position value where a confirmation input first diverges from the original value is found in real timePassword Strength Meter UI: 9 Calculation Patterns
Inputs05 Live validity value toggle — each time a digit value fills in, the whole number's validity value is recalculated and the display value changesCredit Card Input UI: 9 Patterns
Inputs04 macOS crayon — a grid color picker that sets its border color from a brightness value9 Color Picker Design Patterns (Copy-Paste)