09팀 푸터 — 팀원별 시간대를 계산해 지금 몇 시인지 보여주는 줄
팀원마다 저장된 IANA 시간대 문자열을 Intl.DateTimeFormat 의 timeZone 옵션에 넣어 같은 순간의 시각을 각자의 지역 시각으로 계산해 보여주고, setInterval 로 가상 시계를 95분씩 앞당기며 다시 계산해 갱신합니다. 심야 시간대에 속한 팀원에게는 계산된 시각을 기준으로 조용한 표시를 얹습니다.
쓰임리모트 팀 소개 푸터, 글로벌 팀 연락 정보
Intl.DateTimeFormatsetIntervaltimeZone

<main class="stage">
<section class="foot" id="foot">
<p class="foot__hint" id="footHint">고정 기준시각에 가상의 경과 시간을 더해 팀원마다 다른 시간대의 현지시각을 계산합니다</p>
<div class="foot__row" id="footRow" role="list" aria-label="팀원 현지시각"></div>
<p class="foot__meta"><span class="foot__chip" id="footChip">기준 2026-01-01 09:00 UTC</span></p>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 300ms — 조용 상태 전환
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-ink);
.foot {
width: 100%;
max-width: 420px;
display: flex;
flex-direction: column;
gap: $sp-3;
align-items: center;
text-align: center;
}
.foot__hint {
margin: 0;
padding: 0 $sp-2;
font-weight: 400;
font-size: 11px;
line-height: 1.4;
color: rgba($subject-cream, .72);
}
.foot__row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: $sp-2;
width: 100%;
}
.foot__cell {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-1;
padding: $sp-2;
border-radius: $r-control;
background: rgba($subject-cream, .08);
transition: background $duration $easing;
}
.foot__cell.is-quiet {
background: rgba($subject-cream, .03);
}
.foot__tag {
font-weight: 500;
font-size: 9px;
color: rgba($subject-cream, .72);
}
.foot__time {
font-weight: 700;
font-size: 14px;
color: $subject-cream;
font-variant-numeric: tabular-nums;
}
.foot__dot {
display: block;
width: 6px;
height: 6px;
border-radius: $r-pill;
background: $color;
opacity: 0;
transition: opacity $duration $easing;
}
.foot__cell.is-quiet .foot__dot {
opacity: 1;
background: rgba($subject-cream, .45);
}
.foot__meta { margin: 0; }
.foot__chip {
display: inline-flex;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $color;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
}
@media (max-width: 340px) {
.foot { max-width: 300px; gap: $sp-2; }
.foot__hint { font-size: 9px; }
.foot__row { gap: $sp-1; }
.foot__time { font-size: 12px; }
.foot__chip { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.foot__cell,
.foot__dot {
transition: 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);
}
.foot {
width: 100%;
max-width: 420px;
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
text-align: center;
}
.foot__hint {
margin: 0;
padding: 0 8px;
font-weight: 400;
font-size: 11px;
line-height: 1.4;
color: rgba(255, 247, 230, 0.72);
}
.foot__row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
width: 100%;
}
.foot__cell {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 8px;
border-radius: 12px;
background: rgba(255, 247, 230, 0.08);
transition: background 300ms ease-out;
}
.foot__cell.is-quiet {
background: rgba(255, 247, 230, 0.03);
}
.foot__tag {
font-weight: 500;
font-size: 9px;
color: rgba(255, 247, 230, 0.72);
}
.foot__time {
font-weight: 700;
font-size: 14px;
color: #fff7e6;
font-variant-numeric: tabular-nums;
}
.foot__dot {
display: block;
width: 6px;
height: 6px;
border-radius: 999px;
background: #0a4fe8;
opacity: 0;
transition: opacity 300ms ease-out;
}
.foot__cell.is-quiet .foot__dot {
opacity: 1;
background: rgba(255, 247, 230, 0.45);
}
.foot__meta {
margin: 0;
}
.foot__chip {
display: inline-flex;
padding: 4px 12px;
border-radius: 999px;
background: #0a4fe8;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
}
@media (max-width: 340px) {
.foot {
max-width: 300px;
gap: 8px;
}
.foot__hint {
font-size: 9px;
}
.foot__row {
gap: 4px;
}
.foot__time {
font-size: 12px;
}
.foot__chip {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.foot__cell,
.foot__dot {
transition: none;
}
}// 팀원 현지시각 푸터(09) — 고정 기준시각(EPOCH)에 가상의 경과시간을 더한 시각 하나를
// Intl.DateTimeFormat 으로 팀원마다 다른 timeZone 에 맞춰 다시 읽는다(계산 한 겹).
// 그 시각의 시(hour) 가 야간대(22~6시)에 들면 조용 표시를 켠다. 실제 Date.now() 는
// 캡처 재현성을 위해 쓰지 않는다.
(function () {
var row = document.getElementById('footRow');
var chip = document.getElementById('footChip');
if (!row || !chip) return;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var EPOCH = new Date('2026-01-01T09:00:00Z').getTime(); // 고정 기준시각
var STEP_MIN = 95; // 가상 경과 — 매 tick 마다 이만큼 시계를 돌린다
var MEMBERS = [
{ tag: 'M1', zone: 'Asia/Seoul' },
{ tag: 'M2', zone: 'Europe/London' },
{ tag: 'M3', zone: 'America/New_York' },
{ tag: 'M4', zone: 'Australia/Sydney' }
];
MEMBERS.forEach(function (m) {
var cell = document.createElement('div');
cell.className = 'foot__cell';
cell.setAttribute('role', 'listitem');
var tag = document.createElement('span');
tag.className = 'foot__tag';
tag.textContent = m.tag;
var time = document.createElement('time');
time.className = 'foot__time';
var dot = document.createElement('span');
dot.className = 'foot__dot';
dot.setAttribute('aria-hidden', 'true');
cell.appendChild(tag);
cell.appendChild(time);
cell.appendChild(dot);
row.appendChild(cell);
m.cell = cell;
m.timeEl = time;
});
function hourIn(zone, date) {
var s = new Intl.DateTimeFormat('en-GB', { timeZone: zone, hour: 'numeric', hourCycle: 'h23' }).format(date);
return parseInt(s, 10);
}
var elapsedMin = 0;
function render() {
var now = new Date(EPOCH + elapsedMin * 60000);
MEMBERS.forEach(function (m) {
var label = new Intl.DateTimeFormat('en-GB', { timeZone: m.zone, hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).format(now);
m.timeEl.textContent = label;
m.timeEl.setAttribute('datetime', now.toISOString());
var hour = hourIn(m.zone, now);
var quiet = hour < 7 || hour >= 22;
m.cell.classList.toggle('is-quiet', quiet);
});
chip.textContent = '경과 ' + elapsedMin + '분';
}
render();
if (!reduce) {
setInterval(function () {
elapsedMin += STEP_MIN;
render();
}, 550);
}
})();// TeamFooter — vanilla 판(run/338/demo/09-team-footer/index.html) 의 React 포트.
// 고정 기준시각에 가상의 경과 시간을 더해 팀원마다 다른 시간대의 현지시각을
// 계산한다(계산 한 겹).
import { useEffect, useRef } from "react";
import styles from "./TeamFooter.module.scss";
export type TeamFooterProps = {
duration?: string;
easing?: string;
color?: string;
};
const HINT = "고정 기준시각에 가상의 경과 시간을 더해 팀원마다 다른 시간대의 현지시각을 계산합니다";
const ROW_LABEL = "팀원 현지시각";
const EPOCH = new Date("2026-01-01T09:00:00Z").getTime();
const STEP_MIN = 95;
type Member = { tag: string; zone: string };
const MEMBERS: Member[] = [
{ tag: "M1", zone: "Asia/Seoul" },
{ tag: "M2", zone: "Europe/London" },
{ tag: "M3", zone: "America/New_York" },
{ tag: "M4", zone: "Australia/Sydney" }
];
function hourIn(zone: string, date: Date): number {
const fmt = new Intl.DateTimeFormat("en-GB", { timeZone: zone, hour: "numeric", hourCycle: "h23" });
return parseInt(fmt.format(date), 10) || 0;
}
export default function TeamFooter({
duration = "300ms",
easing = "ease-out",
color = "#0a4fe8"
}: TeamFooterProps) {
const rowRef = useRef<HTMLDivElement | null>(null);
const chipRef = useRef<HTMLSpanElement | null>(null);
useEffect(() => {
const row = rowRef.current;
const chip = chipRef.current;
if (!row || !chip) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const cells = Array.prototype.slice.call(row.querySelectorAll("." + styles.cell)) as HTMLElement[];
const timeEls = Array.prototype.slice.call(row.querySelectorAll("." + styles.time)) as HTMLElement[];
let elapsedMin = 0;
function render(): void {
const now = new Date(EPOCH + elapsedMin * 60000);
MEMBERS.forEach((m, i) => {
const fmt = new Intl.DateTimeFormat("en-GB", {
timeZone: m.zone,
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23"
});
const label = fmt.format(now);
const timeEl = timeEls[i];
const cell = cells[i];
if (!timeEl || !cell) return;
timeEl.textContent = label;
timeEl.setAttribute("datetime", now.toISOString());
const hour = hourIn(m.zone, now);
const quiet = hour < 7 || hour >= 22;
cell.classList.toggle(styles.isQuiet, quiet);
});
chip!.textContent = "경과 " + elapsedMin + "분";
}
render();
let timer = 0;
if (!reduce) {
timer = window.setInterval(() => {
elapsedMin += STEP_MIN;
render();
}, 550);
}
return () => {
if (timer) window.clearInterval(timer);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<section className={styles.foot}>
<p className={styles.hint}>{HINT}</p>
<div className={styles.row} ref={rowRef} role="list" aria-label={ROW_LABEL}>
{MEMBERS.map((m) => (
<div className={styles.cell} role="listitem" key={m.tag}>
<span className={styles.tag}>{m.tag}</span>
<time className={styles.time}>09:00</time>
<span className={styles.dot} aria-hidden="true" />
</div>
))}
</div>
<p className={styles.meta}>
<span className={styles.chip} ref={chipRef} style={{ color }}>경과 0분</span>
</p>
</section>
</main>
);
}09 팀 푸터 — 팀원별 시간대를 계산해 지금 몇 시인지 보여주는 줄 — 측정 누적면적 1.403% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 팀 소개 페이지 UI 9종 — 인원수·소개글 길이·시차까지 계산해서 보여주는
이 데모가 속한 글
팀 소개 페이지 UI 9종 — 인원수·소개글 길이·시차까지 계산해서 보여주는팀원이 몇 명이든 카드 줄이 어긋나지 않게 열 수를 계산하고, 소개글이 길면 재서 더보기를 붙이고, 파트너 로고는 개수와 무관하게 같은 속도로 흐르게 만드는 팀 소개 페이지 9종입니다. 이 글의 데모 전부 보기


