跳至正文
组件

Local Time实时当地时间

按指定 IANA 时区显示实时时间:行内时间可带 UTC 偏移、昼夜图标与同访客的时差;另提供世界时钟(列表 / 卡片)和平滑转动的 SVG 模拟表盘。

正在加载…

安装

pnpm dlx shadcn@latest add https://yiui.dev/r/local-time.json

用法

组件分三种形态,都从同一个入口导入:

组件形态
LocalTime行内时间,可以放进任何一行文字
WorldClock + WorldClockItem多个时区的世界时钟,列表或卡片
AnalogClockSVG 模拟表盘

timeZone 接收 IANA 时区名,不是 +08:00 这类固定偏移。

app/header.tsx
import { LocalTime } from "@/components/ui/local-time/local-time"

export function Header() {
  return (
    <p className="font-mono text-sm">
      <LocalTime timeZone="Asia/Shanghai" />
    </p>
  )
}

输出形如 21:04:33 // 早 0.5 小时:前半段是目标时区的当前时间,// 后是它与访客时区的差值。

正在加载…

示例

行内时间的选项

showDayNight 在时间前加昼夜图标,showZone 附上 UTC 偏移,animated 让变化的数字轻微下落淡入,hour12 切换 12 小时制,showSeconds={false} 去掉秒(此时每分钟才刷新)。

正在加载…

世界时钟

WorldClock 接收 zones 数组(本页顶部的预览把它和 AnalogClock、LocalTime 拼成一块「协作时区」面板:左边是所在地的表盘,右边是四个协作城市),每一项一行:城市名、时区与 UTC 偏移、当前时间、昼夜图标,右下角是日期差(昨天 / 明天)和与访客的时差。highlighted 给某一项加上强调色竖条。

app/clocks.tsx
import { WorldClock } from "@/components/ui/local-time/local-time"

const zones = [
  { label: "上海", timeZone: "Asia/Shanghai", highlighted: true },
  { label: "伦敦", timeZone: "Europe/London" },
  { label: "纽约", timeZone: "America/New_York" },
]

export function Clocks() {
  return <WorldClock zones={zones} className="max-w-sm" />
}

用组合方式写

需要给某一项加自己的属性时,直接把 WorldClockItem 作为 children 写出来;hour12、showSeconds 等设置由 WorldClock 统一下发。

正在加载…

卡片形态

variant="cards" 把每个城市排成带模拟表盘的卡片,两列布局,最后一张落单时自动撑满整行。

正在加载…

模拟表盘

AnalogClock 默认平滑转动:指针每帧由 requestAnimationFrame 直接写入,不触发 React 渲染,离开视口时暂停。smooth={false} 或系统开启“减少动态效果”时改为按秒跳格;表盘随昼夜轻微变色,秒针使用主题强调色。

正在加载…

文案与区域格式

可见文案集中在 labels,locale 决定 Intl.DateTimeFormat 与 Intl.NumberFormat 的结果。旧版单独的文案 props(placeholderLabel、sameTimeLabel、aheadLabel、behindLabel、hourUnit)继续可用,优先级高于 labels 里对应的字段。

<LocalTime
  timeZone="Europe/London"
  locale="en-GB"
  labels={{
    pending: "local time",
    same: "same as you",
    ahead: "ahead",
    behind: "behind",
    hourUnit: "h",
  }}
/>
labels 字段默认值用于
pending当地时间挂载前占位时间之后
same与你同一时间与访客同一时间
ahead / behind早 / 晚目标时区更靠前 / 靠后
hourUnit小时时差的单位
day / night白天 / 夜间昼夜图标的读屏文字
yesterday / today / tomorrow昨天 / 今天 / 明天世界时钟的日期差
worldClock / liveWORLD CLOCK / LIVE世界时钟标题栏
clock当地时间模拟表盘的读屏名称

时差怎么算

组件把目标时区的 UTC 偏移和 Date.prototype.getTimezoneOffset() 相减得到小时数,夏令时(例如 Europe/London)自动跟随。差值为 0 时显示 与你同一时间。昼夜以目标时区的 6:00–17:59 为白天,只是视觉提示,不区分半球与季节。

API

LocalTime

元素data-slot其他属性
根元素local-timedata-state="pending" / "ready";开启 showDayNight 时有 data-daylight="day" / "night"
当前时间 <time>local-time-timedateTime(ISO 字符串)
UTC 偏移local-time-zone—
// 后的说明local-time-note—
昼夜图标day-night-markdata-daylight

WorldClock

根元素是 role="group" 的 <div data-slot="world-clock">,带 data-variant;标题栏 world-clock-header,列表 world-clock-list。

WorldClockItem

根元素是 <li data-slot="world-clock-item">,带 data-state(pending / ready)、data-highlighted 与 data-daylight。必须放在 WorldClock 里。

AnalogClock

根元素是 <svg data-slot="analog-clock">,带 data-state 与 data-daylight;尺寸用 className 控制(默认 size-28)。

useNow

时钟共用的 hook,随条目一起安装到 hooks/use-now.ts:

import { useNow } from "@/hooks/use-now"

const now = useNow(1000) // 毫秒时间戳;服务端与水合期间为 null

全页面共用一个对齐整秒的定时器,多个时钟同时存在也只有一个 setTimeout 在跑,所有实例的秒数在同一帧跳动。

可访问性

  • 时间用语义化的 <time> 加机器可读的 dateTime,读屏与复制粘贴拿到的都是标准格式。
  • 每秒变化的内容没有加 aria-live:真正的实时时钟如果每秒播报会完全打断读屏用户。需要提醒时请在外层自行加 aria-live="polite",并考虑把 showSeconds 关掉降低更新频率。
  • 昼夜图标是装饰,同时输出 白天 / 夜间 的 sr-only 文字,颜色不是唯一信息。
  • 世界时钟是 role="group",用标题栏文字作名称;AnalogClock 是 role="img",名称是 label 加上分钟精度的时间,指针每秒的变化不会被播报。
  • 开启“减少动态效果”后:数字过渡关闭、模拟表盘按秒跳格、标题栏的“实时”圆点不再闪烁。
  • 占位文案(--:--:--)与就位后的文本都使用 tabular-nums 且宽度一致,切换时不引起布局跳动。

注意事项

为什么必须在客户端渲染

时间依赖访客的浏览器时钟,服务端渲染出来的秒数一定会过期。useNow 在服务端和水合期间返回 null,组件输出 --:--:-- 占位,水合完成后再换成真实时间。这样静态导出的 HTML 与客户端首屏完全一致,不会出现水合警告,也不会先闪出一个错误的时间。

其他

  • timeZone 必须是合法的 IANA 名称,传错会在浏览器抛 RangeError。
  • 所有实例共用一个定时器,同一页面放多个时钟的开销只来自各自的格式化;Intl.DateTimeFormat 实例按参数缓存。
  • AnalogClock 的平滑模式用 requestAnimationFrame,每帧只做三次 setAttribute;整页放很多块表盘时可以给部分设置 smooth={false}。
  • 12 小时制由 hour12 控制,24 小时制使用 hourCycle: "h23",两者的上午 / 下午文字由 locale 决定。

源码

components/ui/local-time/local-time.tsx
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import { useNow } from "@/hooks/use-now"

import { AnalogClock, type AnalogClockProps } from "./analog-clock"
import { DEFAULT_LABELS, type LocalTimeLabels } from "./config"
import { DayNightMark, TimeDigits } from "./time-parts"
import {
  WorldClock,
  WorldClockItem,
  type WorldClockItemProps,
  type WorldClockProps,
  type WorldClockVariant,
  type WorldClockZone,
} from "./world-clock"
import {
  clockParts,
  describeDifference,
  hourDifference,
  isDaytime,
  offsetMinutes,
  utcLabel,
  zoneHour,
} from "./zone"

type LocalTimeProps = Omit<React.ComponentProps<"span">, "children"> & {
  /** IANA 时区名,例如 `Asia/Shanghai`,不支持 `+08:00` 这类固定偏移 */
  timeZone: string
  /**
   * BCP 47 区域标识,决定时间与数字的格式
   * @default "zh-CN"
   */
  locale?: string
  /**
   * 12 小时制(带上午/下午),默认 24 小时制
   * @default false
   */
  hour12?: boolean
  /**
   * 显示秒;关闭后每分钟才刷新一次
   * @default true
   */
  showSeconds?: boolean
  /**
   * 在 `//` 后显示与访客时区的时差
   * @default true
   */
  showDifference?: boolean
  /**
   * 在时间后显示 UTC 偏移,例如 `UTC+8`
   * @default false
   */
  showZone?: boolean
  /**
   * 在时间前显示昼夜图标(6:00–17:59 为白天)
   * @default false
   */
  showDayNight?: boolean
  /**
   * 数字变化时播放轻微的下落淡入;系统开启“减少动态效果”时自动关闭
   * @default false
   */
  animated?: boolean
  /** 覆盖部分可见文案 */
  labels?: Partial<LocalTimeLabels>
  /**
   * 挂载前的占位时间;默认随 `showSeconds` 为 `--:--:--` 或 `--:--`
   */
  placeholder?: string
  /** @deprecated 等价于 `labels.pending` */
  placeholderLabel?: string
  /** @deprecated 等价于 `labels.same` */
  sameTimeLabel?: string
  /** @deprecated 等价于 `labels.ahead` */
  aheadLabel?: string
  /** @deprecated 等价于 `labels.behind` */
  behindLabel?: string
  /** @deprecated 等价于 `labels.hourUnit` */
  hourUnit?: string
}

/** 以指定 IANA 时区显示时间,并可附带 UTC 偏移、昼夜图标与同访客的时差。 */
function LocalTime({
  timeZone,
  className,
  locale = "zh-CN",
  hour12 = false,
  showSeconds = true,
  showDifference = true,
  showZone = false,
  showDayNight = false,
  animated = false,
  labels,
  placeholder,
  placeholderLabel,
  sameTimeLabel,
  aheadLabel,
  behindLabel,
  hourUnit,
  ...props
}: LocalTimeProps) {
  // 不显示秒时按分钟对齐,减少无谓的重新渲染
  const now = useNow(showSeconds ? 1000 : 60_000)
  const text = {
    ...DEFAULT_LABELS,
    ...labels,
    ...(placeholderLabel !== undefined && { pending: placeholderLabel }),
    ...(sameTimeLabel !== undefined && { same: sameTimeLabel }),
    ...(aheadLabel !== undefined && { ahead: aheadLabel }),
    ...(behindLabel !== undefined && { behind: behindLabel }),
    ...(hourUnit !== undefined && { hourUnit }),
  }

  if (now === null)
    return (
      <span
        data-slot="local-time"
        data-state="pending"
        className={cn("tabular-nums", className)}
        {...props}
      >
        {showDayNight ? (
          <span aria-hidden="true" className="mr-1.5 inline-block size-[1em]" />
        ) : null}
        {placeholder ?? (showSeconds ? "--:--:--" : "--:--")}
        {showDifference ? (
          <>
            {" "}
            <span
              data-slot="local-time-note"
              className="text-muted-foreground"
            >{`// ${text.pending}`}</span>
          </>
        ) : null}
      </span>
    )

  const date = new Date(now)
  const daylight = isDaytime(zoneHour(timeZone, date))
  return (
    <span
      data-slot="local-time"
      data-state="ready"
      data-daylight={showDayNight ? (daylight ? "day" : "night") : undefined}
      className={cn("tabular-nums", className)}
      {...props}
    >
      {showDayNight ? (
        <DayNightMark
          daylight={daylight}
          label={daylight ? text.day : text.night}
          className="mr-1.5"
        />
      ) : null}
      <time data-slot="local-time-time" dateTime={date.toISOString()}>
        <TimeDigits
          parts={clockParts(date, { locale, timeZone, hour12, showSeconds })}
          animated={animated}
        />
      </time>
      {showZone ? (
        <>
          {" "}
          <span data-slot="local-time-zone" className="text-muted-foreground">
            {utcLabel(offsetMinutes(timeZone, date))}
          </span>
        </>
      ) : null}
      {showDifference ? (
        <>
          {" "}
          <span
            data-slot="local-time-note"
            className="text-muted-foreground"
          >{`// ${describeDifference(hourDifference(timeZone, date), locale, text)}`}</span>
        </>
      ) : null}
    </span>
  )
}

export { AnalogClock, LocalTime, WorldClock, WorldClockItem }
export type {
  AnalogClockProps,
  LocalTimeLabels,
  LocalTimeProps,
  WorldClockItemProps,
  WorldClockProps,
  WorldClockVariant,
  WorldClockZone,
}
components/ui/local-time/world-clock.tsx
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import { useNow } from "@/hooks/use-now"

import { AnalogClock } from "./analog-clock"
import { DEFAULT_LABELS, type LocalTimeLabels } from "./config"
import { DayNightMark, TimeDigits } from "./time-parts"
import {
  clockParts,
  dayOffset,
  describeDifference,
  hourDifference,
  isDaytime,
  offsetMinutes,
  utcLabel,
  zoneHour,
} from "./zone"

/** `list` 一行一个城市;`cards` 每个城市是一张带模拟表盘的卡片,两列排布。 */
type WorldClockVariant = "list" | "cards"

type WorldClockZone = {
  /** IANA 时区名 */
  timeZone: string
  /** 城市名 */
  label: React.ReactNode
  /** 高亮这一项(例如“我所在的城市”) */
  highlighted?: boolean
}

type WorldClockSettings = {
  locale: string
  hour12: boolean
  showSeconds: boolean
  showZone: boolean
  animated: boolean
  variant: WorldClockVariant
  labels: LocalTimeLabels
}

const WorldClockContext = React.createContext<WorldClockSettings>({
  locale: "zh-CN",
  hour12: false,
  showSeconds: true,
  showZone: true,
  animated: true,
  variant: "list",
  labels: DEFAULT_LABELS,
})

type WorldClockProps = Omit<React.ComponentProps<"div">, "title"> & {
  /** 用数据描述城市;与 `children` 二选一,同时传入时两者都会渲染 */
  zones?: readonly WorldClockZone[]
  /**
   * 排布方式
   * @default "list"
   */
  variant?: WorldClockVariant
  /**
   * 标题栏内容;传 `null` 隐藏整条标题栏
   * @default "WORLD CLOCK"
   */
  title?: React.ReactNode
  /**
   * BCP 47 区域标识,决定时间与数字的格式
   * @default "zh-CN"
   */
  locale?: string
  /**
   * 12 小时制(带上午/下午),默认 24 小时制
   * @default false
   */
  hour12?: boolean
  /**
   * 显示秒
   * @default true
   */
  showSeconds?: boolean
  /**
   * 在城市名下显示 UTC 偏移
   * @default true
   */
  showZone?: boolean
  /**
   * 数字变化时播放轻微的下落淡入;系统开启“减少动态效果”时自动关闭
   * @default true
   */
  animated?: boolean
  /** 覆盖部分可见文案 */
  labels?: Partial<LocalTimeLabels>
}

/** 世界时钟:多个时区同时显示,右侧标出与访客的时差、昼夜和日期差。 */
function WorldClock({
  zones,
  variant = "list",
  title,
  locale = "zh-CN",
  hour12 = false,
  showSeconds = true,
  showZone = true,
  animated = true,
  labels,
  className,
  children,
  ...props
}: WorldClockProps) {
  const text = React.useMemo(() => ({ ...DEFAULT_LABELS, ...labels }), [labels])
  const settings = React.useMemo(
    () => ({
      locale,
      hour12,
      showSeconds,
      showZone,
      animated,
      variant,
      labels: text,
    }),
    [locale, hour12, showSeconds, showZone, animated, variant, text],
  )
  const heading = title === undefined ? text.worldClock : title
  return (
    <WorldClockContext.Provider value={settings}>
      <div
        data-slot="world-clock"
        data-variant={variant}
        role="group"
        aria-label={typeof heading === "string" ? heading : text.worldClock}
        className={cn(
          "overflow-hidden rounded-lg border border-edge bg-background",
          className,
        )}
        {...props}
      >
        {heading === null ? null : (
          <div
            data-slot="world-clock-header"
            className="flex items-center justify-between gap-3 border-b border-edge px-3 py-2 font-mono text-[10px] tracking-[0.08em] text-muted-foreground"
          >
            <span className="min-w-0 truncate">{heading}</span>
            <span className="flex shrink-0 items-center gap-1.5">
              <span
                aria-hidden="true"
                className="size-1.5 rounded-full bg-primary motion-safe:animate-pulse"
              />
              {text.live}
            </span>
          </div>
        )}
        <ul
          data-slot="world-clock-list"
          className={cn(
            variant === "list"
              ? "divide-y divide-edge"
              : // 单元格自带右、下边线,用负外边距把最外一圈挤出外框的裁剪区,避免与外框叠成双线
                "-mr-px -mb-px grid grid-cols-2 *:border-r *:border-b *:border-edge [&>li:last-child:nth-child(odd)]:col-span-2",
          )}
        >
          {zones?.map((zone) => (
            <WorldClockItem
              key={zone.timeZone}
              timeZone={zone.timeZone}
              label={zone.label}
              highlighted={zone.highlighted}
            />
          ))}
          {children}
        </ul>
      </div>
    </WorldClockContext.Provider>
  )
}

type WorldClockItemProps = Omit<React.ComponentProps<"li">, "children"> & {
  /** IANA 时区名 */
  timeZone: string
  /** 城市名 */
  label: React.ReactNode
  /**
   * 高亮这一项
   * @default false
   */
  highlighted?: boolean
}

/** 世界时钟中的一个城市;必须放在 `WorldClock` 里。 */
function WorldClockItem({
  timeZone,
  label,
  highlighted = false,
  className,
  ...props
}: WorldClockItemProps) {
  const { locale, hour12, showSeconds, showZone, animated, variant, labels } =
    React.useContext(WorldClockContext)
  const now = useNow(showSeconds ? 1000 : 60_000)
  const date = now === null ? null : new Date(now)
  const daylight = date ? isDaytime(zoneHour(timeZone, date)) : true
  const days = date ? dayOffset(timeZone, date) : 0
  const meta = date
    ? [
        days === 0 ? null : days > 0 ? labels.tomorrow : labels.yesterday,
        describeDifference(hourDifference(timeZone, date), locale, labels),
      ]
        .filter(Boolean)
        .join(" · ")
    : ""
  const zone = [
    timeZone.replaceAll("_", " "),
    date && showZone ? utcLabel(offsetMinutes(timeZone, date)) : null,
  ]
    .filter(Boolean)
    .join(" · ")
  const time = date ? (
    <time data-slot="world-clock-time" dateTime={date.toISOString()}>
      <TimeDigits
        parts={clockParts(date, { locale, timeZone, hour12, showSeconds })}
        animated={animated}
      />
    </time>
  ) : (
    <span data-slot="world-clock-time">
      {showSeconds ? "--:--:--" : "--:--"}
    </span>
  )
  const mark = date ? (
    <DayNightMark
      daylight={daylight}
      label={daylight ? labels.day : labels.night}
      className="size-3.5"
    />
  ) : null

  return (
    <li
      data-slot="world-clock-item"
      data-state={date ? "ready" : "pending"}
      data-highlighted={highlighted ? "" : undefined}
      data-daylight={date ? (daylight ? "day" : "night") : undefined}
      className={cn(
        "relative min-w-0 data-[highlighted]:bg-primary/5 data-[highlighted]:before:absolute data-[highlighted]:before:inset-y-0 data-[highlighted]:before:left-0 data-[highlighted]:before:w-0.5 data-[highlighted]:before:bg-primary",
        variant === "list"
          ? "flex items-center justify-between gap-3 px-3 py-2"
          : "flex flex-col items-center gap-2 px-3 py-4 text-center",
        className,
      )}
      {...props}
    >
      {variant === "cards" ? (
        <AnalogClock
          timeZone={timeZone}
          locale={locale}
          label={typeof label === "string" ? label : labels.clock}
          showSeconds={showSeconds}
          className="size-20"
        />
      ) : null}
      <div
        className={cn("min-w-0", variant === "list" ? "flex-1" : "max-w-full")}
      >
        <p className="truncate text-sm leading-5 font-medium">{label}</p>
        <p className="truncate font-mono text-[10px] leading-4 text-muted-foreground">
          {zone}
        </p>
      </div>
      <div
        className={cn(
          "shrink-0 font-mono",
          variant === "list" ? "text-right" : "text-center",
        )}
      >
        <p
          className={cn(
            "flex items-center gap-1.5 text-sm leading-5 tabular-nums",
            variant === "list" ? "justify-end" : "justify-center",
          )}
        >
          {mark}
          {time}
        </p>
        <p className="min-h-4 text-[10px] leading-4 whitespace-nowrap text-muted-foreground">
          {meta}
        </p>
      </div>
    </li>
  )
}

export { WorldClock, WorldClockItem }
export type {
  WorldClockItemProps,
  WorldClockProps,
  WorldClockVariant,
  WorldClockZone,
}
components/ui/local-time/analog-clock.tsx
"use client"

import * as React from "react"
import { useAnimationFrame, useInView, useReducedMotion } from "motion/react"

import { cn } from "@/lib/utils"
import { useNow } from "@/hooks/use-now"

import { DEFAULT_LABELS } from "./config"
import { isDaytime, offsetMinutes, zoneHour } from "./zone"

type AnalogClockProps = Omit<React.ComponentProps<"svg">, "children"> & {
  /** IANA 时区名,例如 `Asia/Shanghai` */
  timeZone: string
  /**
   * BCP 47 区域标识,决定读屏标签里的时间格式
   * @default "zh-CN"
   */
  locale?: string
  /**
   * 显示秒针
   * @default true
   */
  showSeconds?: boolean
  /**
   * 指针每帧平滑转动;关闭后按秒跳格。系统开启“减少动态效果”时自动按秒跳格
   * @default true
   */
  smooth?: boolean
  /**
   * 读屏名称,后面会拼上当前时间
   * @default "当地时间"
   */
  label?: string
}

/** 时间折算成三根指针的角度(度);`seconds` 带毫秒,用于平滑转动。 */
function handAngles(zonedSeconds: number) {
  const day = ((zonedSeconds % 86_400) + 86_400) % 86_400
  return {
    hour: (day % 43_200) / 120,
    minute: (day % 3600) / 10,
    second: (day % 60) * 6,
  }
}

/**
 * SVG 模拟表盘。表盘随昼夜轻微变色,秒针用主题强调色。
 * 平滑模式下指针由 `requestAnimationFrame` 直接写入 transform,不经过 React 渲染;
 * 离开视口时暂停。
 */
function AnalogClock({
  timeZone,
  locale = "zh-CN",
  showSeconds = true,
  smooth = true,
  label = DEFAULT_LABELS.clock,
  className,
  ...props
}: AnalogClockProps) {
  const reduced = useReducedMotion()
  const continuous = smooth && !reduced
  const now = useNow(continuous ? 60_000 : 1000)
  const ref = React.useRef<SVGSVGElement>(null)
  const inView = useInView(ref)
  const hands = React.useRef<
    Record<"hour" | "minute" | "second", SVGGElement | null>
  >({ hour: null, minute: null, second: null })
  const date = now === null ? null : new Date(now)
  const offset = date ? offsetMinutes(timeZone, date) : null
  // 动画帧里不能再算时区:偏移随渲染(至少每分钟)更新到 ref,帧回调只做加法
  const offsetRef = React.useRef<number | null>(null)
  React.useEffect(() => {
    offsetRef.current = offset
  }, [offset])

  useAnimationFrame(() => {
    if (!continuous || !inView || offsetRef.current === null) return
    const angles = handAngles(Date.now() / 1000 + offsetRef.current * 60)
    hands.current.hour?.setAttribute(
      "transform",
      `rotate(${angles.hour} 50 50)`,
    )
    hands.current.minute?.setAttribute(
      "transform",
      `rotate(${angles.minute} 50 50)`,
    )
    hands.current.second?.setAttribute(
      "transform",
      `rotate(${angles.second} 50 50)`,
    )
  })

  // 非平滑模式由 React 按秒写入角度;平滑模式只写初始值 0,之后交给动画帧
  const stepped =
    !continuous && date && offset !== null
      ? handAngles(Math.floor(date.getTime() / 1000) + offset * 60)
      : { hour: 0, minute: 0, second: 0 }
  const daylight = date ? isDaytime(zoneHour(timeZone, date)) : true
  const description = date
    ? `${label} ${new Intl.DateTimeFormat(locale, {
        timeZone,
        hour: "2-digit",
        minute: "2-digit",
        hourCycle: "h23",
      }).format(date)}`
    : label

  return (
    <svg
      ref={ref}
      data-slot="analog-clock"
      data-state={date ? "ready" : "pending"}
      data-daylight={daylight ? "day" : "night"}
      viewBox="0 0 100 100"
      role="img"
      aria-label={description}
      className={cn("size-28 text-foreground", className)}
      {...props}
    >
      <circle
        cx="50"
        cy="50"
        r="48.5"
        className={cn(
          "stroke-edge",
          daylight ? "fill-background" : "fill-foreground/5",
        )}
        strokeWidth="1"
      />
      {Array.from({ length: 12 }, (_, index) => (
        <line
          key={index}
          x1="50"
          x2="50"
          y1={index % 3 === 0 ? 6 : 8}
          y2={index % 3 === 0 ? 13 : 11.5}
          transform={`rotate(${index * 30} 50 50)`}
          className={
            index % 3 === 0 ? "stroke-foreground/70" : "stroke-foreground/30"
          }
          strokeWidth={index % 3 === 0 ? 1.6 : 1}
          strokeLinecap="round"
        />
      ))}
      <g
        ref={(node) => void (hands.current.hour = node)}
        transform={`rotate(${stepped.hour} 50 50)`}
      >
        <line
          x1="50"
          y1="54"
          x2="50"
          y2="29"
          className="stroke-foreground"
          strokeWidth="3"
          strokeLinecap="round"
        />
      </g>
      <g
        ref={(node) => void (hands.current.minute = node)}
        transform={`rotate(${stepped.minute} 50 50)`}
      >
        <line
          x1="50"
          y1="56"
          x2="50"
          y2="17"
          className="stroke-foreground"
          strokeWidth="2"
          strokeLinecap="round"
        />
      </g>
      {showSeconds ? (
        <g
          ref={(node) => void (hands.current.second = node)}
          transform={`rotate(${stepped.second} 50 50)`}
        >
          <line
            x1="50"
            y1="60"
            x2="50"
            y2="13"
            className="stroke-primary"
            strokeWidth="1"
            strokeLinecap="round"
          />
        </g>
      ) : null}
      <circle cx="50" cy="50" r="2.4" className="fill-primary" />
      <circle cx="50" cy="50" r="0.9" className="fill-background" />
    </svg>
  )
}

export { AnalogClock }
export type { AnalogClockProps }
components/ui/local-time/time-parts.tsx
"use client"

import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { Moon, Sun } from "lucide-react"

import { cn } from "@/lib/utils"

/**
 * 一位数字;变化时旧数字向下淡出、新数字从上方落入。
 * 新旧两位叠在同一个 1ch 宽的格子里(grid 同格堆叠),过渡期间宽度不变,整行不会左右晃动。
 * 首次出现不播放,避免水合后整排数字同时闪一下。
 */
function TimeDigit({ value, animated }: { value: string; animated: boolean }) {
  if (!animated) return <>{value}</>
  return (
    <span
      data-slot="time-digit"
      className="inline-grid w-[1ch] justify-items-center *:col-start-1 *:row-start-1"
    >
      <AnimatePresence initial={false}>
        <motion.span
          key={value}
          initial={{ opacity: 0, y: "-0.35em" }}
          animate={{ opacity: 1, y: 0 }}
          exit={{ opacity: 0, y: "0.35em" }}
          transition={{ duration: 0.2, ease: "easeOut" }}
        >
          {value}
        </motion.span>
      </AnimatePresence>
    </span>
  )
}

/**
 * 逐位渲染 `Intl.DateTimeFormat.formatToParts` 的结果。
 * 数字用 `tabular-nums` 等宽;分隔符与上午/下午等 dayPeriod 原样输出。
 */
function TimeDigits({
  parts,
  animated = false,
}: {
  parts: readonly Intl.DateTimeFormatPart[]
  /** 数字变化时是否播放过渡;系统开启“减少动态效果”时自动关闭 */
  animated?: boolean
}) {
  const reduced = useReducedMotion()
  const animate = animated && !reduced
  return (
    <span data-slot="time-digits" className="tabular-nums">
      {parts.map((part, index) =>
        part.type === "hour" ||
        part.type === "minute" ||
        part.type === "second" ? (
          <span key={part.type} data-part={part.type}>
            {Array.from(part.value).map((char, position) => (
              <TimeDigit key={position} value={char} animated={animate} />
            ))}
          </span>
        ) : (
          <span
            key={`${part.type}-${index}`}
            data-part={part.type}
            className={
              part.type === "dayPeriod" ? "mx-[0.2em] text-[0.8em]" : undefined
            }
          >
            {part.value}
          </span>
        ),
      )}
    </span>
  )
}

/** 昼夜标记:白天是太阳(主题强调色),夜间是月亮(弱化色);图标只是装饰,文字给读屏。 */
function DayNightMark({
  daylight,
  label,
  className,
}: {
  daylight: boolean
  /** 读屏文字,如“白天” / “夜间” */
  label: string
  className?: string
}) {
  const Icon = daylight ? Sun : Moon
  return (
    <span
      data-slot="day-night-mark"
      data-daylight={daylight ? "day" : "night"}
      className={cn(
        "inline-flex shrink-0 align-[-0.125em]",
        daylight ? "text-primary" : "text-muted-foreground",
        className,
      )}
    >
      <Icon aria-hidden="true" className="size-[1em]" />
      <span className="sr-only">{label}</span>
    </span>
  )
}

export { DayNightMark, TimeDigits }
components/ui/local-time/zone.ts
// 时区计算:全部基于 Intl,不引入时区库。Intl.DateTimeFormat 构造较慢,按参数缓存。

type ClockOptions = {
  locale: string
  timeZone: string
  /** 12 小时制;默认 24 小时制 */
  hour12?: boolean
  showSeconds?: boolean
}

const formatters = new Map<string, Intl.DateTimeFormat>()

function formatter(
  locale: string,
  options: Intl.DateTimeFormatOptions,
): Intl.DateTimeFormat {
  const key = `${locale}|${JSON.stringify(options)}`
  let cached = formatters.get(key)
  if (!cached) {
    cached = new Intl.DateTimeFormat(locale, options)
    formatters.set(key, cached)
  }
  return cached
}

/** 把时间拆成可逐位渲染的片段(hour / minute / second / literal / dayPeriod)。 */
function clockParts(date: Date, options: ClockOptions) {
  return formatter(options.locale, {
    timeZone: options.timeZone,
    hour: "2-digit",
    minute: "2-digit",
    second: options.showSeconds === false ? undefined : "2-digit",
    hourCycle: options.hour12 ? "h12" : "h23",
  }).formatToParts(date)
}

/** 目标时区相对 UTC 的偏移分钟数,夏令时自动跟随。 */
function offsetMinutes(timeZone: string, date: Date) {
  const zone =
    formatter("en-US", { timeZone, timeZoneName: "longOffset" })
      .formatToParts(date)
      .find((part) => part.type === "timeZoneName")?.value ?? "GMT"
  const match = zone.match(/GMT([+-])(\d{2}):(\d{2})/)
  if (!match) return 0
  const minutes = Number(match[2]) * 60 + Number(match[3])
  return match[1] === "+" ? minutes : -minutes
}

/** 目标时区比访客时区快(正)或慢(负)几个小时。 */
function hourDifference(timeZone: string, date: Date) {
  return (offsetMinutes(timeZone, date) + date.getTimezoneOffset()) / 60
}

/** `UTC+8`、`UTC+5:30`、`UTC`。 */
function utcLabel(minutes: number) {
  if (minutes === 0) return "UTC"
  const sign = minutes > 0 ? "+" : "-"
  const abs = Math.abs(minutes)
  const hours = Math.floor(abs / 60)
  const rest = abs % 60
  return `UTC${sign}${hours}${rest ? `:${String(rest).padStart(2, "0")}` : ""}`
}

/** 目标时区此刻的小时数(0–23)。 */
function zoneHour(timeZone: string, date: Date) {
  const hour = formatter("en-US", {
    timeZone,
    hour: "numeric",
    hourCycle: "h23",
  }).format(date)
  return Number(hour) % 24
}

/** 6:00–17:59 算白天;与时区所在半球和季节无关,只作视觉提示。 */
function isDaytime(hour: number) {
  return hour >= 6 && hour < 18
}

/** 目标时区的日期比访客本地日期晚一天(1)、早一天(-1)或同一天(0)。 */
function dayOffset(timeZone: string, date: Date) {
  const key = (parts: Intl.DateTimeFormatPart[]) => {
    const value = (type: string) =>
      parts.find((part) => part.type === type)?.value ?? ""
    return `${value("year")}-${value("month")}-${value("day")}`
  }
  const options = {
    year: "numeric",
    month: "2-digit",
    day: "2-digit",
  } as const
  const target = key(
    formatter("en-US", { ...options, timeZone }).formatToParts(date),
  )
  const local = key(formatter("en-US", options).formatToParts(date))
  return target === local ? 0 : target > local ? 1 : -1
}

/** 时差的文字描述:与访客同一时间时返回 `same`,否则 `早/晚 N 小时`。 */
function describeDifference(
  difference: number,
  locale: string,
  text: { same: string; ahead: string; behind: string; hourUnit: string },
) {
  if (difference === 0) return text.same
  const amount = new Intl.NumberFormat(locale, {
    maximumFractionDigits: 2,
  }).format(Math.abs(difference))
  return `${difference > 0 ? text.ahead : text.behind} ${amount} ${text.hourUnit}`
}

export {
  clockParts,
  dayOffset,
  describeDifference,
  hourDifference,
  isDaytime,
  offsetMinutes,
  utcLabel,
  zoneHour,
}
export type { ClockOptions }
components/ui/local-time/config.ts
/** 时钟类组件的可见文案;各组件通过 `labels` 部分覆盖。 */
type LocalTimeLabels = {
  /** 挂载前,占位时间之后的说明 */
  pending: string
  /** 与访客同一时间 */
  same: string
  /** 目标时区更靠前时的前缀 */
  ahead: string
  /** 目标时区更靠后时的前缀 */
  behind: string
  /** 时差的单位 */
  hourUnit: string
  /** 白天标记的读屏文字 */
  day: string
  /** 夜间标记的读屏文字 */
  night: string
  /** 目标时区日期比访客早一天 */
  yesterday: string
  /** 与访客同一天 */
  today: string
  /** 目标时区日期比访客晚一天 */
  tomorrow: string
  /** 世界时钟的默认标题 */
  worldClock: string
  /** 世界时钟标题旁的“实时”标记 */
  live: string
  /** 模拟表盘的默认读屏名称 */
  clock: string
}

const DEFAULT_LABELS: LocalTimeLabels = {
  pending: "当地时间",
  same: "与你同一时间",
  ahead: "早",
  behind: "晚",
  hourUnit: "小时",
  day: "白天",
  night: "夜间",
  yesterday: "昨天",
  today: "今天",
  tomorrow: "明天",
  worldClock: "WORLD CLOCK",
  live: "LIVE",
  clock: "当地时间",
}

export { DEFAULT_LABELS }
export type { LocalTimeLabels }
hooks/use-now.ts
"use client"

import * as React from "react"

// 全页面共用一个对齐整秒的定时器:多个时钟同时挂载也只有一个 setTimeout 在跑,
// 并且所有实例的秒数在同一帧跳动。
const listeners = new Set<() => void>()
let timer: number | undefined

function schedule() {
  timer = window.setTimeout(
    () => {
      listeners.forEach((listener) => listener())
      if (listeners.size) schedule()
    },
    1005 - (Date.now() % 1000),
  ) // 多等 5ms,避免定时器提前触发时读到上一秒
}

function subscribe(listener: () => void) {
  listeners.add(listener)
  if (listeners.size === 1) schedule()
  return () => {
    listeners.delete(listener)
    if (!listeners.size) window.clearTimeout(timer)
  }
}

/**
 * 当前时间的毫秒时间戳,按 `step` 对齐(默认每秒)。
 * 服务端渲染与水合期间返回 `null`,水合完成后才给出真实时间,
 * 静态导出的 HTML 因此永远不会带着过期的时间。
 *
 * @param step 对齐粒度(毫秒);快照不变时组件不会重新渲染,所以只显示分钟的组件传 `60_000` 即可
 */
function useNow(step = 1000): number | null {
  return React.useSyncExternalStore(
    subscribe,
    () => Math.floor(Date.now() / step) * step,
    () => null,
  )
}

export { useNow }