UIcraft / Craft

Craft / Animation

World Clock Board

Show three world clocks in fixed LED blocks, then shuffle each city name when its time changes.

Demo

Interactive
Board onlineWC–01

Copy this component. Each row has five separate blocks for HH:MM, a clear gap, and 15 separate blocks for the city. The 5×7 letter patterns live in the file, so there is no font or extra stylesheet to load. City names longer than 15 characters are clipped to fit.

world-clock-board.tsx
"use client";
 
import { useEffect, useRef, useState } from "react";
 
const FONT: Record<string, string> = {
  " ": "00000000000000000000000000000000000",
  "0": "01110100011001110101110011000101110",
  "1": "00100011000010000100001000010001110",
  "2": "01110100010000100010001000100011111",
  "3": "11110000010000101110000010000111110",
  "4": "00010001100101010010111110001000010",
  "5": "11111100001000011110000010000111110",
  "6": "00110010001000011110100011000101110",
  "7": "11111000010001000100010000100001000",
  "8": "01110100011000101110100011000101110",
  "9": "01110100011000101111000010001001100",
  ":": "00000001000010000000001000010000000",
  A: "01110100011000111111100011000110001",
  B: "11110100011000111110100011000111110",
  C: "01110100011000010000100001000101110",
  D: "11110100011000110001100011000111110",
  E: "11111100001000011110100001000011111",
  F: "11111100001000011110100001000010000",
  G: "01110100011000010111100011000101111",
  H: "10001100011000111111100011000110001",
  I: "01110001000010000100001000010001110",
  J: "00111000100001000010000101001001100",
  K: "10001100101010011000101001001010001",
  L: "10000100001000010000100001000011111",
  M: "10001110111010110101100011000110001",
  N: "10001110011010110011100011000110001",
  O: "01110100011000110001100011000101110",
  P: "11110100011000111110100001000010000",
  Q: "01110100011000110001101011001001101",
  R: "11110100011000111110101001001010001",
  S: "01111100001000001110000010000111110",
  T: "11111001000010000100001000010000100",
  U: "10001100011000110001100011000101110",
  V: "10001100011000110001010100010000100",
  W: "10001100011000110101101011010101010",
  X: "10001100010101000100010101000110001",
  Y: "10001100010101000100001000010000100",
  Z: "11111000010001000100010001000011111",
};
 
const CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
const CELL_W = 5;
const CELL_H = 7;
const TIME_SLOTS = 5;
const CITY_SLOTS = 15;
const HOLD_MS = 320;
 
const DEFAULT_CITIES = [
  { name: "NEW YORK CITY", timeZone: "America/New_York" },
  { name: "TOKYO", timeZone: "Asia/Tokyo" },
  { name: "NEW DELHI", timeZone: "Asia/Kolkata" },
];
 
type City = {
  name: string;
  timeZone: string;
};
 
type WorldClockBoardProps = {
  cities?: City[];
  staggerMs?: number;
  cycleMs?: number;
  replay?: number;
  announce?: boolean;
  className?: string;
};
 
function formatTime(timeZone: string, date: Date) {
  const parts = new Intl.DateTimeFormat("en-GB", {
    timeZone,
    hour: "2-digit",
    minute: "2-digit",
    hourCycle: "h23",
  }).formatToParts(date);
  const hour = parts.find((part) => part.type === "hour")?.value ?? "00";
  const minute = parts.find((part) => part.type === "minute")?.value ?? "00";
  return `${hour.slice(-2).padStart(2, "0")}:${minute.slice(-2).padStart(2, "0")}`;
}
 
function clockLines(cities: City[], date: Date | null) {
  return cities.map((city) => {
    const time = date ? formatTime(city.timeZone, date) : " ".repeat(TIME_SLOTS);
    const name = (city.name.trim().toUpperCase() || "CITY").slice(0, CITY_SLOTS);
    return `${time}${name.padEnd(CITY_SLOTS, " ")}`;
  });
}
 
function lit(glyph: string, x: number, y: number) {
  return glyph[y * CELL_W + x] === "1";
}
 
function LedGlyph({ glyph }: { glyph: string }) {
  return (
    <svg viewBox={`0 0 ${CELL_W} ${CELL_H}`} className="block w-full" focusable="false">
      {Array.from({ length: CELL_W * CELL_H }, (_, dot) => {
        const x = dot % CELL_W;
        const y = Math.floor(dot / CELL_W);
        return (
          <circle
            key={dot}
            cx={x + 0.5}
            cy={y + 0.5}
            r={0.38}
            className={lit(glyph, x, y) ? "fill-[#f2f2f2]" : "fill-[#434343]"}
          />
        );
      })}
    </svg>
  );
}
 
function LedMatrix({ lines }: { lines: string[] }) {
  return (
    <div
      className="grid w-full gap-y-[clamp(14px,2.4cqi,28px)]"
    >
      {lines.map((line, rowIndex) => (
        <div
          key={rowIndex}
          className="grid items-start gap-x-[clamp(4px,0.7cqi,8px)]"
          style={{ gridTemplateColumns: `repeat(${TIME_SLOTS}, minmax(0, 1fr)) minmax(0, 1fr) repeat(${CITY_SLOTS}, minmax(0, 1fr))` }}
        >
          {[...line].map((character, column) => (
            <div
              key={column}
              style={column === TIME_SLOTS ? { gridColumn: column + 2 } : undefined}
            >
              <LedGlyph glyph={FONT[character] ?? FONT[" "]} />
            </div>
          ))}
        </div>
      ))}
    </div>
  );
}
 
function useFlapRows(rows: string[], staggerMs: number, cycleMs: number, replay: number) {
  const signature = rows.join("\n");
  const [shown, setShown] = useState(() => rows.map((row) => " ".repeat(row.length)));
  const previous = useRef<string[] | null>(null);
  const replaySeen = useRef(replay);
 
  useEffect(() => {
    const next = signature.length === 0 ? [] : signature.split("\n");
    const stagger = Math.min(160, Math.max(12, staggerMs));
    const cycle = Math.min(180, Math.max(32, cycleMs));
    const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const prior = previous.current;
    const force = replay !== replaySeen.current;
 
    const dirty = next.map((row, rowIndex) =>
      [...row].map((character, column) => {
        if (reduce) return false;
        if (force || prior === null) return true;
        if (column >= TIME_SLOTS && prior[rowIndex]?.slice(0, TIME_SLOTS) !== row.slice(0, TIME_SLOTS)) return true;
        return prior[rowIndex]?.[column] !== character;
      }),
    );
 
    if (!dirty.some((row) => row.some(Boolean))) {
      previous.current = next;
      replaySeen.current = replay;
      setShown(next);
      return;
    }
 
    const started = performance.now();
    let timer = 0;
 
    const paint = () => {
      const now = performance.now();
      let pending = false;
      const frame = next.map((row, rowIndex) => {
        let line = "";
        for (let column = 0; column < row.length; column += 1) {
          const goal = row[column] ?? " ";
          const city = column >= TIME_SLOTS;
          const position = city ? column - TIME_SLOTS : column;
          const last = city ? CITY_SLOTS - 1 : TIME_SLOTS - 1;
          const delay = stagger * last * (1 - Math.cos(Math.PI * position / last)) / 2;
          if (!dirty[rowIndex]?.[column] || now >= started + delay + HOLD_MS) {
            line += goal;
          } else if (now < started + delay) {
            pending = true;
            line += prior?.[rowIndex]?.[column] ?? " ";
          } else {
            pending = true;
            line += CHARSET[Math.floor(Math.random() * CHARSET.length)];
          }
        }
        return line;
      });
      setShown(frame);
      if (!pending) {
        previous.current = next;
        replaySeen.current = replay;
        window.clearInterval(timer);
      }
      return pending;
    };
 
    if (paint()) timer = window.setInterval(paint, cycle);
    return () => window.clearInterval(timer);
  }, [signature, staggerMs, cycleMs, replay]);
 
  return shown;
}
 
export function WorldClockBoard({
  cities = DEFAULT_CITIES,
  staggerMs = 70,
  cycleMs = 80,
  replay = 0,
  announce = true,
  className,
}: WorldClockBoardProps) {
  const [date, setDate] = useState<Date | null>(null);
 
  useEffect(() => {
    const publish = () => {
      const next = new Date();
      setDate((current) => {
        if (current && clockLines(cities, current).join("\n") === clockLines(cities, next).join("\n")) {
          return current;
        }
        return next;
      });
    };
 
    publish();
    const timer = window.setInterval(publish, 1000);
    return () => window.clearInterval(timer);
  }, [cities]);
 
  const rows = clockLines(cities, date);
  const shown = useFlapRows(rows, staggerMs, cycleMs, replay);
  const label = date
    ? rows
        .map((row) => `${row.slice(0, TIME_SLOTS)} ${row.slice(TIME_SLOTS).trim()}`)
        .filter(Boolean)
        .join(". ")
    : "";
 
  return (
    <div
      className={`w-full overflow-hidden rounded-xl border border-white/10 bg-black px-3 py-6 shadow-[inset_0_0_16px_rgba(0,0,0,0.9)] sm:px-5 sm:py-7${className ? ` ${className}` : ""}`}
      style={{ containerType: "inline-size" }}
    >
      <p className="sr-only" aria-live={announce ? "polite" : undefined}>
        {label}
      </p>
      <div aria-hidden="true">
        <LedMatrix lines={shown} />
      </div>
    </div>
  );
}

Pass cities to change the rows. Each entry needs a label and an IANA time zone, such as America/New_York. staggerMs is the delay between shuffling blocks, and replay is a number you increment to shuffle the board again.