World Clock Bar

Multi-timezone display for follow-the-sun teams and operational handoffs.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/world-clock-bar.json
bash

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

2 stories available:

Code

"use client";

import * as React from "react";

import { cn } from "../../lib/utils";
import { Badge } from "../badge";

export type WorldClockBarZone = {
  city: string;
  locale?: string;
  timeZone: string;
};

export type WorldClockBarProps = React.ComponentPropsWithoutRef<"div"> & {
  now?: Date | number | string;
  showDate?: boolean;
  title?: string;
  updateIntervalMs?: number;
  zones: WorldClockBarZone[];
};

function normalizeDate(input: Date | number | string): Date {
  if (input instanceof Date) {
    return new Date(input.getTime());
  }

  return new Date(input);
}

function useLiveDate(now: WorldClockBarProps["now"], updateIntervalMs: number) {
  const fixedNow = React.useMemo(
    () => (now ? normalizeDate(now) : undefined),
    [now],
  );
  const [liveNow, setLiveNow] = React.useState<Date>(fixedNow ?? new Date());

  React.useEffect(() => {
    if (fixedNow) {
      setLiveNow(fixedNow);
      return;
    }

    const interval = window.setInterval(() => {
      setLiveNow(new Date());
    }, updateIntervalMs);

    return () => {
      window.clearInterval(interval);
    };
  }, [fixedNow, updateIntervalMs]);

  return liveNow;
}

const TIME_FORMATTER_CACHE = new Map<string, Intl.DateTimeFormat>();
function getTimeFormatter(
  locale: string,
  timeZone: string,
): Intl.DateTimeFormat {
  const key = `${locale}|${timeZone}`;
  let formatter = TIME_FORMATTER_CACHE.get(key);
  if (!formatter) {
    formatter = new Intl.DateTimeFormat(locale, {
      hour: "numeric",
      minute: "2-digit",
      timeZone,
      timeZoneName: "short",
    });
    TIME_FORMATTER_CACHE.set(key, formatter);
  }
  return formatter;
}

const DATE_FORMATTER_CACHE = new Map<string, Intl.DateTimeFormat>();
function getDateFormatter(
  locale: string,
  timeZone: string,
): Intl.DateTimeFormat {
  const key = `${locale}|${timeZone}`;
  let formatter = DATE_FORMATTER_CACHE.get(key);
  if (!formatter) {
    formatter = new Intl.DateTimeFormat(locale, {
      day: "numeric",
      month: "short",
      timeZone,
      weekday: "short",
    });
    DATE_FORMATTER_CACHE.set(key, formatter);
  }
  return formatter;
}

function formatZoneDateTime(
  zone: WorldClockBarZone,
  date: Date,
  showDate: boolean,
) {
  const locale = zone.locale ?? "en-US";
  const timeFormatter = getTimeFormatter(locale, zone.timeZone);
  const dateFormatter = getDateFormatter(locale, zone.timeZone);

  return {
    date: showDate ? dateFormatter.format(date) : "",
    time: timeFormatter.format(date),
  };
}

function WorldClockCard({
  date,
  showDate,
  zone,
}: {
  date: Date;
  showDate: boolean;
  zone: WorldClockBarZone;
}) {
  const formatted = formatZoneDateTime(zone, date, showDate);

  return (
    <div className="min-w-[190px] rounded-lg border bg-card px-4 py-3 shadow-sm">
      <div className="text-sm font-medium">{zone.city}</div>
      <div className="mt-1 text-2xl font-semibold tracking-tight">
        {formatted.time}
      </div>
      {showDate ? (
        <div className="mt-1 text-xs text-muted-foreground">
          {formatted.date}
        </div>
      ) : null}
      <div className="mt-3 text-[11px] uppercase tracking-[0.16em] text-muted-foreground">
        {zone.timeZone}
      </div>
    </div>
  );
}

export const WorldClockBar = React.forwardRef<
  HTMLDivElement,
  WorldClockBarProps
>(
  (
    {
      className,
      now,
      showDate = true,
      title = "World clock",
      updateIntervalMs = 60_000,
      zones,
      ...props
    },
    ref,
  ) => {
    const liveNow = useLiveDate(now, updateIntervalMs);

    return (
      <div className={cn("space-y-3", className)} ref={ref} {...props}>
        <div className="flex items-center justify-between gap-3">
          <div>
            <h2 className="text-lg font-semibold tracking-tight">{title}</h2>
            <p className="text-sm text-muted-foreground">
              Synchronized time across distributed teams and regions.
            </p>
          </div>
          <Badge variant="outline">{zones.length} zones</Badge>
        </div>

        <div className="flex gap-3 overflow-x-auto pb-1">
          {zones.map((zone) => (
            <WorldClockCard
              date={liveNow}
              key={`${zone.city}-${zone.timeZone}`}
              showDate={showDate}
              zone={zone}
            />
          ))}
        </div>
      </div>
    );
  },
);

WorldClockBar.displayName = "WorldClockBar";
typescript

Dependencies

  • @vllnt/ui@^0.2.1