Skip to main content

ns-ui

Team Grid Timezone Rail

A team grid that answers the question a team grid is actually asked (who is reachable right now) by running a 24-hour UTC rail above the cards with the team's shared working-hours overlap inked into it, while each card shows that person's live local time and dims when they're outside their own hours.

Usage

registry/core/team-grid-timezone-rail/demo.tsx
"use client";

import { TeamGridTimezoneRail, type TeamMember } from "./component";

const TEAM: TeamMember[] = [
  { id: "marcus", name: "Marcus Hale", role: "Staff Engineer", initials: "MH", utcOffset: -5 },
  { id: "ana", name: "Ana Reyes", role: "Design Lead", initials: "AR", utcOffset: -3 },
  { id: "joana", name: "Joana Duarte", role: "Support Lead", initials: "JD", utcOffset: 0 },
  { id: "lukas", name: "Lukas Brandt", role: "Infrastructure", initials: "LB", utcOffset: 1 },
  { id: "wanjiru", name: "Wanjiru Kimani", role: "Data Science", initials: "WK", utcOffset: 3 },
  { id: "darya", name: "Darya Sadeghi", role: "QA Engineer", initials: "DS", utcOffset: 3.5 },
];

/** core hours, and the viewer sitting in Berlin — fixed so the demo is stable */
const CORE_HOURS: [number, number] = [8, 20];
const VIEWER_OFFSET = 1;

export default function TeamGridTimezoneRailDemo() {
  return (
    <div className="flex min-h-screen flex-col items-center justify-center gap-7 px-6 py-16">
      <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
        ns-ui / team-grid-timezone-rail
      </p>

      <TeamGridTimezoneRail
        members={TEAM}
        workingHours={CORE_HOURS}
        viewerOffset={VIEWER_OFFSET}
        className="max-w-[880px]"
      />

      <p className="max-w-[52ch] text-center text-xs text-ns-muted">
        Six people, six timezones. The inked run on the UTC rail is the only window in which everyone
        is inside their own working hours at once — four hours, 13:00 to 17:00 UTC. Hover one person
        and the rail opens out to the hours just you two share; press to pin it.
      </p>
    </div>
  );
}

Component

registry/core/team-grid-timezone-rail/component.tsx
"use client";

import { useCallback, useEffect, useMemo, useRef, useState } from "react";

// ---------------------------------------------------------------------------
// TeamGridTimezoneRail — a team grid that answers "who is reachable right now".
//
// A 24-cell UTC hour track sits above the cards. Every cell is either dead
// (`·` in --border) or inked (`━` in --ns-accent), and the inked run is the
// INTERSECTION of everybody's working window — the viewer's included — mapped
// back onto UTC. At rest that run is the whole team's shared meeting window —
// the tightest one there is. Hover or focus one person and the run opens out
// to just your overlap with them; leave and a 220ms relax timer pulls it back
// to the team-wide run, so sweeping the grid doesn't strobe. A caret `▮` marks
// the current UTC hour.
//
// The clock is a clock, not an animation: one timer aligned to the real minute
// boundary drives the caret and every card's local time through direct DOM
// writes. No rAF anywhere. Zero dependencies, tokens only.
// ---------------------------------------------------------------------------

export interface TeamMember {
  /** stable id, also the value of [data-member] */
  id: string;
  name: string;
  role: string;
  /** 2–3 letters for the mark */
  initials: string;
  /** hours ahead of UTC; fractional allowed (5.5 = UTC+5:30) */
  utcOffset: number;
}

export interface TeamGridTimezoneRailProps {
  /** the team, one card each */
  members?: TeamMember[];
  /** local working window as [startHour, endHour), applied to everyone */
  workingHours?: [number, number];
  /** the viewer's own offset; defaults to the browser's, resolved after mount */
  viewerOffset?: number;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

const DEFAULT_MEMBERS: TeamMember[] = [
  { id: "marcus", name: "Marcus Hale", role: "Staff Engineer", initials: "MH", utcOffset: -5 },
  { id: "ana", name: "Ana Reyes", role: "Design Lead", initials: "AR", utcOffset: -3 },
  { id: "joana", name: "Joana Duarte", role: "Support Lead", initials: "JD", utcOffset: 0 },
  { id: "lukas", name: "Lukas Brandt", role: "Infrastructure", initials: "LB", utcOffset: 1 },
  { id: "wanjiru", name: "Wanjiru Kimani", role: "Data Science", initials: "WK", utcOffset: 3 },
  { id: "darya", name: "Darya Sadeghi", role: "QA Engineer", initials: "DS", utcOffset: 3.5 },
];

const DEFAULT_HOURS: [number, number] = [9, 18];

/** ms of pointer-out before the band relaxes back to the team-wide window */
const RELAX_MS = 220;
/** rail cells below this many ch of root width collapse to 2h per cell */
const COMPACT_CH = 30;

const mod = (n: number, m: number) => ((n % m) + m) % m;

function insideWindow(localHour: number, start: number, end: number) {
  const h = mod(localHour, 24);
  return start <= end ? h >= start && h < end : h >= start || h < end;
}

/** shortest circular distance in hours from `localHour` to the working window */
function distanceToWindow(localHour: number, start: number, end: number) {
  if (insideWindow(localHour, start, end)) return 0;
  const h = mod(localHour, 24);
  const gap = (a: number, b: number) => {
    const d = mod(a - b, 24);
    return Math.min(d, 24 - d);
  };
  return Math.min(gap(h, mod(start, 24)), gap(h, mod(end, 24)));
}

/** live[c] = some hour in cell c is inside the window for EVERY offset */
function bandFor(offsets: number[], start: number, end: number, cells: number, step: number) {
  const out: boolean[] = [];
  for (let c = 0; c < cells; c++) {
    let live = false;
    for (let k = 0; k < step && !live; k++) {
      const h = c * step + k;
      live = offsets.every((o) => insideWindow(h + o, start, end));
    }
    out.push(live);
  }
  return out;
}

type Run = { start: number; length: number };

/** maximal runs of set bits, wrapping across midnight */
function runsOf(mask: boolean[]): Run[] {
  const n = mask.length;
  if (mask.every(Boolean)) return [{ start: 0, length: n }];
  const runs: Run[] = [];
  for (let i = 0; i < n; i++) {
    if (!mask[i] || mask[mod(i - 1, n)]) continue;
    let len = 1;
    while (len < n && mask[mod(i + len, n)]) len++;
    runs.push({ start: i, length: len });
  }
  return runs;
}

const pad2 = (n: number) => String(n).padStart(2, "0");

function fmtClock(totalMinutes: number) {
  const m = mod(Math.round(totalMinutes), 1440);
  return `${pad2(Math.floor(m / 60))}:${pad2(m % 60)}`;
}

function fmtOffset(o: number) {
  const sign = o < 0 ? "-" : "+";
  const abs = Math.abs(o);
  const h = Math.floor(abs);
  const mm = Math.round((abs - h) * 60);
  return `UTC${sign}${h}${mm ? `:${pad2(mm)}` : ""}`;
}

export function TeamGridTimezoneRail({
  members = DEFAULT_MEMBERS,
  workingHours = DEFAULT_HOURS,
  viewerOffset,
  className = "",
}: TeamGridTimezoneRailProps) {
  const [whStart, whEnd] = workingHours;

  const rootRef = useRef<HTMLDivElement>(null);
  const chRef = useRef<HTMLSpanElement>(null);
  const relaxRef = useRef<number | undefined>(undefined);

  const [viewer, setViewer] = useState(viewerOffset ?? 0);
  const [cols, setCols] = useState(3);
  const [compact, setCompact] = useState(false);
  const [hoverId, setHoverId] = useState<string | null>(null);
  const [pinnedId, setPinnedId] = useState<string | null>(null);

  // the viewer's real offset is client-only; adopting it after mount keeps the
  // server and first client render byte-identical.
  useEffect(() => {
    if (viewerOffset !== undefined) {
      setViewer(viewerOffset);
      return;
    }
    setViewer(-new Date().getTimezoneOffset() / 60);
  }, [viewerOffset]);

  const activeId = hoverId ?? pinnedId;
  const activeMember = useMemo(
    () => members.find((m) => m.id === activeId) ?? null,
    [members, activeId]
  );

  /** offsets currently being intersected: the whole team, or you ∩ one person
   *  (a pair always shares at least as many hours as the team, so the band can
   *  only open out from rest — rest is the tightest run the grid ever shows) */
  const offsets = useMemo(
    () =>
      activeMember
        ? [viewer, activeMember.utcOffset]
        : [viewer, ...members.map((m) => m.utcOffset)],
    [activeMember, members, viewer]
  );

  const step = compact ? 2 : 1;
  const cells = compact ? 12 : 24;

  const band = useMemo(
    () => bandFor(offsets, whStart, whEnd, cells, step),
    [offsets, whStart, whEnd, cells, step]
  );

  /** full-resolution band, used for the spoken readout regardless of density */
  const hourBand = useMemo(
    () => bandFor(offsets, whStart, whEnd, 24, 1),
    [offsets, whStart, whEnd]
  );

  const runs = useMemo(() => runsOf(hourBand), [hourBand]);
  const shared = runs.reduce((n, r) => n + r.length, 0);

  /** when nothing intersects: the UTC hour of minimum total distance-to-window */
  const nearest = useMemo(() => {
    if (shared > 0) return null;
    const people = [{ name: "you", utcOffset: viewer }, ...members];
    let best = { hour: 0, cost: Infinity, who: people[0]?.name ?? "", gap: 0 };
    for (let h = 0; h < 24; h++) {
      let cost = 0;
      let worst = { name: "", gap: 0 };
      for (const p of people) {
        const g = distanceToWindow(h + p.utcOffset, whStart, whEnd);
        cost += g;
        if (g > worst.gap) worst = { name: p.name.split(" ")[0], gap: g };
      }
      if (cost < best.cost) best = { hour: h, cost, who: worst.name, gap: worst.gap };
    }
    return best;
  }, [shared, members, viewer, whStart, whEnd]);

  const readout = useMemo(() => {
    if (nearest) {
      return `no shared hours — nearest overlap ${pad2(nearest.hour)}:00 UTC (${nearest.who} +${nearest.gap}h)`;
    }
    const window =
      shared >= 24
        ? "every hour UTC"
        : runs
            .map((r) => `${pad2(r.start)}:00–${pad2(mod(r.start + r.length, 24))}:00 UTC`)
            .join(", ");
    const who = activeMember ? `you and ${activeMember.name}` : `all ${members.length} + you`;
    return `${who} free ${window} · ${shared}h shared`;
  }, [nearest, runs, activeMember, members.length, shared]);

  // ---- the clock: one minute-aligned timer, direct DOM writes ---------------

  const paint = useCallback(() => {
    const root = rootRef.current;
    if (!root) return;
    const now = new Date();
    const utcMinutes = now.getUTCHours() * 60 + now.getUTCMinutes();

    const utcEl = root.querySelector<HTMLElement>("[data-clock-utc]");
    if (utcEl) utcEl.textContent = `${fmtClock(utcMinutes)} UTC`;

    const caret = root.querySelector<HTMLElement>("[data-caret]");
    if (caret) {
      const idx = Math.floor(utcMinutes / 60 / step);
      caret.style.left = `${idx}ch`;
      caret.style.opacity = "1";
    }

    for (const m of members) {
      const btn = root.querySelector<HTMLElement>(`[data-member="${m.id}"]`);
      if (!btn) continue;
      const localMinutes = utcMinutes + m.utcOffset * 60;
      const awake = insideWindow(localMinutes / 60, whStart, whEnd);
      const clock = fmtClock(localMinutes);
      const time = btn.querySelector<HTMLElement>("[data-time]");
      if (time) time.textContent = clock;
      const dot = btn.querySelector<HTMLElement>("[data-dot]");
      if (dot) dot.textContent = awake ? "●" : "○";
      btn.style.opacity = awake ? "1" : "0.5";
      btn.setAttribute(
        "aria-label",
        `${m.name}, ${m.role}, local time ${clock}, ${fmtOffset(m.utcOffset)}, currently ${awake ? "available" : "outside working hours"}`
      );
    }
  }, [members, whStart, whEnd, step]);

  useEffect(() => {
    paint();
    let interval: number | undefined;
    const timeout = window.setTimeout(
      () => {
        paint();
        interval = window.setInterval(paint, 60000);
      },
      60000 - (Date.now() % 60000)
    );
    return () => {
      window.clearTimeout(timeout);
      if (interval !== undefined) window.clearInterval(interval);
    };
  }, [paint]);

  // ---- responsive: columns, and a coarser rail in a narrow frame ------------

  useEffect(() => {
    const el = rootRef.current;
    if (!el || typeof ResizeObserver === "undefined") return;
    const measure = () => {
      const w = el.clientWidth;
      setCols(w < 480 ? 1 : w < 860 ? 2 : 3);
      const ch = (chRef.current?.offsetWidth ?? 100) / 10;
      setCompact(w < COMPACT_CH * ch);
    };
    measure();
    const ro = new ResizeObserver(measure);
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  useEffect(() => () => window.clearTimeout(relaxRef.current), []);

  const enter = (id: string) => {
    window.clearTimeout(relaxRef.current);
    setHoverId(id);
  };
  const leave = () => {
    window.clearTimeout(relaxRef.current);
    relaxRef.current = window.setTimeout(() => setHoverId(null), RELAX_MS);
  };

  const tickEvery = compact ? 3 : 6;
  const tickLabels = Array.from({ length: 4 }, (_, k) => ({
    idx: k * tickEvery,
    text: pad2(k * 6),
  }));

  const fade =
    "transition-opacity duration-[180ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none";

  return (
    <div ref={rootRef} data-tz-rail className={`relative flex w-full flex-col gap-4 ${className}`}>
      <span
        ref={chRef}
        aria-hidden
        className="pointer-events-none invisible absolute left-0 top-0 h-0 w-[10ch] font-mono text-[18px]"
      />

      {/* ---- the rail ------------------------------------------------------ */}
      <div className="rounded-sm border border-border bg-surface px-4 py-3">
        <div className="flex items-baseline justify-between gap-4">
          <span className="font-mono text-[10px] uppercase tracking-[0.2em] text-ns-muted">
            utc day
          </span>
          <span className="font-mono text-[10px] uppercase tracking-[0.2em] text-ns-muted">
            core {pad2(whStart)}:00–{pad2(whEnd)}:00 local
          </span>
        </div>

        <div className="mt-2.5 flex items-end justify-between gap-4">
          <div aria-hidden className="relative select-none font-mono text-[18px] leading-none">
            <div className="flex whitespace-pre">
              {Array.from({ length: cells }, (_, i) => {
                const divider = i % tickEvery === 0;
                const on = band[i];
                return (
                  <span key={i} className="relative inline-block w-[1ch] text-center">
                    <span
                      style={{ opacity: on ? 0 : 1 }}
                      className={`block ${fade} ${divider ? "text-ns-muted" : "text-border"}`}
                    >
                      {divider ? "│" : "·"}
                    </span>
                    <span
                      style={{ opacity: on ? 1 : 0 }}
                      className={`absolute inset-0 text-ns-accent ${fade}`}
                    >
                      ━
                    </span>
                  </span>
                );
              })}
            </div>
            <span
              data-caret
              className="absolute top-0 w-[1ch] text-center leading-none text-ns-accent opacity-0"
            >
              ▮
            </span>
            <div className="relative mt-1 h-[1em] text-[10px] leading-none text-ns-muted">
              {tickLabels.map((t) => (
                <span
                  key={t.idx}
                  className="absolute top-0 font-mono text-[18px] leading-none"
                  style={{ left: `${t.idx}ch` }}
                >
                  <span className="text-[10px]">{t.text}</span>
                </span>
              ))}
            </div>
          </div>

          <span
            data-clock-utc
            className="shrink-0 font-mono text-[13px] tabular-nums text-foreground"
          >
            --:-- UTC
          </span>
        </div>

        <p
          aria-live="polite"
          className={`mt-2 font-mono text-[11px] tabular-nums ${nearest ? "text-ns-muted" : "text-foreground"}`}
        >
          {readout}
        </p>
      </div>

      {/* ---- the team ------------------------------------------------------ */}
      <div
        className="grid gap-2"
        style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}
      >
        {members.map((m) => (
          <button
            key={m.id}
            type="button"
            data-member={m.id}
            aria-pressed={pinnedId === m.id}
            aria-label={`${m.name}, ${m.role}, ${fmtOffset(m.utcOffset)}`}
            onPointerEnter={() => enter(m.id)}
            onPointerLeave={leave}
            onFocus={() => enter(m.id)}
            onBlur={leave}
            onClick={() => setPinnedId((p) => (p === m.id ? null : m.id))}
            className="flex items-center gap-3 rounded-sm border border-border bg-surface px-3 py-3 text-left outline-none transition-[opacity,border-color] duration-200 motion-reduce:transition-none hover:border-ns-muted focus-visible:ring-2 focus-visible:ring-ns-accent aria-pressed:border-foreground"
          >
            <span
              aria-hidden
              className="flex h-9 w-9 shrink-0 items-center justify-center rounded-sm border border-border font-mono text-[11px] tracking-[0.06em] text-ns-muted"
            >
              {m.initials}
            </span>
            <span className="min-w-0 flex-1">
              <span className="block truncate text-[13px] text-foreground">{m.name}</span>
              <span className="block truncate text-[11px] text-ns-muted">{m.role}</span>
            </span>
            <span className="flex shrink-0 flex-col items-end gap-0.5">
              <span className="flex items-center gap-1.5 font-mono text-[13px] tabular-nums text-foreground">
                <span data-time>--:--</span>
                <span data-dot className="text-[9px] leading-none">
                  ○
                </span>
              </span>
              <span className="font-mono text-[10px] tabular-nums text-ns-muted">
                {fmtOffset(m.utcOffset)}
              </span>
            </span>
          </button>
        ))}
      </div>
    </div>
  );
}

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/team-grid-timezone-rail.tsx, so point the import there.

Use when Pick team-grid-timezone-rail for an about/team section that has to stay honest about reachability — live local clocks per person, cards dimmed outside their own hours, and one inked UTC run showing when the whole team overlaps. schedule-ascii-freebusy is the better pick when the input is real per-slot busy/free calendar data and the output is a bookable meeting window; this one needs nothing but each person's UTC offset.

Install

npx shadcn add https://design.helpmarq.com/r/team-grid-timezone-rail.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

Build spec

Build <TeamGridTimezoneRail members? workingHours? viewerOffset? className?> as a plain DOM component — no canvas, no rAF, zero dependencies. MEMBERS are {id, name, role, initials, utcOffset} where utcOffset is hours ahead of UTC and may be fractional (5.5 renders as 'UTC+5:30'); workingHours is a local [start, end) window applied to everyone, default [9, 18]; viewerOffset defaults to -new Date().getTimezoneOffset()/60 but is adopted in a mount effect rather than at render time, so the server and the first client render stay byte-identical instead of throwing a hydration mismatch. THE CLOCK: exactly one timer, aligned to the real minute boundary — setTimeout(60000 - (Date.now() % 60000)) and then setInterval(60000) — and it writes through DIRECT DOM mutation, not React state: on each tick it moves the rail caret, rewrites every card's local time, flips its status glyph, sets the card's opacity and rebuilds its aria-label. This is a clock, not an animation, so there is deliberately no requestAnimationFrame anywhere and no per-second re-render. THE RAIL: an aria-hidden mono track of 24 cells at 1ch pitch where the index IS the UTC hour. Base glyph is '·' in --border, with '│' in --ns-muted at indices 0/6/12/18 and an absolutely positioned label row ('00 06 12 18') beneath at the same ch pitch so the ticks line up exactly; the current UTC hour carries a '▮' caret in --ns-accent, positioned as a single absolutely placed span whose style.left is set to `${index}ch` on the minute tick — one element moves rather than 24 being re-rendered. THE OVERLAP BAND is the mechanic: a cell is live for member m when ((hour + m.utcOffset) mod 24) falls inside [workingHours[0], workingHours[1]), and the run inked in --ns-accent is the INTERSECTION across the entire team plus the viewer — so the resting rail, with nothing hovered, already answers 'when can we all meet'. Each cell is two stacked layers: the '·' base and an accent '━' overlay, cross-faded by opacity over 180ms on cubic-bezier(0.22, 1, 0.36, 1), so cells leaving the band fade back to --border while cells still shared simply hold with no animation at all. On pointerenter or focus of a member card the intersection drops to viewer ∩ that one member, which is necessarily a superset of the team-wide run, so the band visibly OPENS OUT from rest — the resting run is the tightest the rail ever shows, and hovering is how you see what a single pairing would buy you; on pointerleave/blur a 220ms relax timer (cleared by any re-entry) pulls it back to the team-wide window, so sweeping across the grid does not strobe. If the intersection is empty the rail renders no accent cells and a mono line reads 'no shared hours — nearest overlap 07:00 UTC (Ana +2h)', computed by scanning all 24 UTC hours for the minimum total circular distance between each person's local hour and their working window, then naming whoever is furthest out. THE CARDS: each member is a real <button data-member aria-pressed> — pressing PINS that person's band so it survives pointer-out and pressing again unpins, which is a genuine function and also makes every card keyboard-reachable with a visible --ns-accent focus ring. A card carries an initials mark, name, role, a tabular-nums mono local time '14:32' derived as UTC-now + utcOffset, the printed offset, and a status glyph '●' inside working hours or '○' outside; cards outside their own hours render at opacity 0.5, so half the grid is visibly asleep at a glance. The aria-label is a full sentence — 'Ana Reyes, Design Lead, local time 14:32, UTC+2, currently available' — rebuilt on every tick, and a polite live region below the rail states the shared window in words since the rail itself is aria-hidden. RESPONSIVE: a ResizeObserver on the root switches the grid to 1 / 2 / 3 columns at 480px and 860px, and below 30ch of root width the rail collapses to a compact 12-cell track at 2 hours per cell, with the caret index becoming floor(utcHour / 2) and the band recomputed on the coarser grid. REDUCED MOTION: the band snaps between states with no crossfade (motion-reduce disables the opacity transitions), but the minute clock keeps ticking, because a stopped clock would be wrong rather than calm. TOKENS ONLY — --border, --foreground, --ns-muted, --surface, --ns-accent, with accent used on the caret, the overlap band and focus rings and nowhere else, so both themes invert cleanly. Props: members (default six-person team spanning UTC-5 to UTC+3:30), workingHours (default [9, 18]), viewerOffset, className.

Props

PropTypeDefaultDescription
members?TeamMember[]DEFAULT_MEMBERSthe team, one card each
workingHours?[number, number]DEFAULT_HOURSlocal working window as [startHour, endHour), applied to everyone
viewerOffset?number—the viewer's own offset; defaults to the browser's, resolved after mount
className?string—extra classes merged onto the rendered root element