Team Grid Timezone Rail

Team

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.

Install
npx shadcn add https://design.helpmarq.com/r/team-grid-timezone-rail.json
Source
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 --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 {
  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;
  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-muted">
            utc day
          </span>
          <span className="font-mono text-[10px] uppercase tracking-[0.2em] text-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-muted" : "text-border"}`}
                    >
                      {divider ? "│" : "·"}
                    </span>
                    <span
                      style={{ opacity: on ? 1 : 0 }}
                      className={`absolute inset-0 text-accent ${fade}`}
                    >
                      ━
                    </span>
                  </span>
                );
              })}
            </div>
            <span
              data-caret
              className="absolute top-0 w-[1ch] text-center leading-none text-accent opacity-0"
            >
              ▮
            </span>
            <div className="relative mt-1 h-[1em] text-[10px] leading-none text-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-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-muted focus-visible:ring-2 focus-visible:ring-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-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-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-muted">
                {fmtOffset(m.utcOffset)}
              </span>
            </span>
          </button>
        ))}
      </div>
    </div>
  );
}
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.

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 --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 --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 --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 --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, --muted, --surface, --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.

Tags
teamgridabouttimezoneavailabilityclockmarketing