The Kit/Components

Time Widgets

Two SSR-safe time components: LocalTime renders an ISO instant in the viewer's own timezone (mount-gated, no hydration mismatch), and CountdownClock ticks down to an absolute instant (correct regardless of clock skew). Built on @zq/kit's useMounted/useNow.

loading…
time.tsx
"use client";

import * as React from "react";
import { useMounted, useNow } from "@zq/kit/hooks";

/**
 * Kickoff time/date rendered in the USER's local timezone. Server components can't know the
 * viewer's TZ, so we gate on mount: the first (SSR-matching) render shows a stable placeholder,
 * then we fill the browser-local value. Fixes the "everything shows the server's timezone" bug.
 */
export function LocalTime({
  iso,
  kind = "time",
  fallback = "·",
}: {
  iso: string | null | undefined;
  kind?: "time" | "date" | "daytime" | "weekday";
  fallback?: string;
}) {
  const mounted = useMounted();
  if (!iso) return <>{""}</>;
  if (!mounted) return <span suppressHydrationWarning>{fallback}</span>;
  const d = new Date(iso);
  const opts: Intl.DateTimeFormatOptions =
    kind === "date"
      ? { month: "short", day: "numeric" }
      : kind === "daytime"
        ? { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }
        : kind === "weekday"
          ? { weekday: "long", month: "long", day: "numeric" }
          : { hour: "numeric", minute: "2-digit" };
  return <span suppressHydrationWarning>{d.toLocaleString([], opts)}</span>;
}

function parts(ms: number): { d: number; h: number; m: number; s: number } {
  const s = Math.max(0, Math.floor(ms / 1000));
  return { d: Math.floor(s / 86400), h: Math.floor((s % 86400) / 3600), m: Math.floor((s % 3600) / 60), s: s % 60 };
}

/**
 * Countdown to an absolute instant (ISO). Ticks every second client-side. The target is an
 * absolute UTC instant so it's correct regardless of the viewer's clock skew/TZ. Renders nothing
 * once the instant has passed (the caller swaps to a live/result state).
 */
export function CountdownClock({ iso, className }: { iso: string; className?: string }) {
  const mounted = useMounted();
  const target = React.useMemo(() => (iso ? new Date(iso).getTime() : 0), [iso]);
  const now = useNow(mounted);
  if (!mounted || !target) return <span className={className} suppressHydrationWarning />;
  const remaining = target - now;
  if (remaining <= 0) return <span className={className} suppressHydrationWarning />;
  const { d, h, m, s } = parts(remaining);
  const text = d > 0 ? `${d}d ${h}h ${m}m` : h > 0 ? `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}` : `${m}:${String(s).padStart(2, "0")}`;
  return (
    <span className={className} suppressHydrationWarning>
      {text}
    </span>
  );
}
← The whole kit