import {
AnalogClock,
LocalTime,
WorldClock,
} from "@/components/ui/local-time/local-time"
const ZONES = [
{ label: "伦敦", timeZone: "Europe/London" },
{ label: "纽约", timeZone: "America/New_York" },
{ label: "旧金山", timeZone: "America/Los_Angeles" },
{ label: "悉尼", timeZone: "Australia/Sydney" },
]
// 「协作时区」面板:左侧是本地的模拟表盘,右侧是四个协作城市的世界时钟。
// 强调色(LIVE 圆点、表盘秒针)统一指向 --flow-blue,亮暗与颜色主题下都保持一致
export default function LocalTimeWorldClockDemo() {
return (
<div className="grid w-full max-w-2xl overflow-hidden rounded-lg border border-edge bg-card [--primary:var(--flow-blue)] sm:grid-cols-[14rem_1fr]">
<div className="relative flex flex-col items-center justify-center gap-3 border-b border-edge bg-[radial-gradient(circle_at_50%_40%,color-mix(in_oklab,var(--flow-blue)_14%,transparent),transparent_70%)] px-4 py-5 sm:border-r sm:border-b-0">
<p className="self-start font-mono text-[10px] tracking-[0.08em] text-muted-foreground">
BASE / 所在地
</p>
<AnalogClock
timeZone="Asia/Shanghai"
label="上海当前时间"
className="size-24 sm:size-32"
/>
<div className="text-center">
<p className="text-sm font-medium">上海</p>
<LocalTime
timeZone="Asia/Shanghai"
className="mt-0.5 block font-mono text-[11px] text-muted-foreground"
/>
</div>
</div>
<WorldClock
zones={ZONES}
title="PARTNERS / 协作城市"
className="rounded-none border-0 bg-transparent max-sm:[&_li:nth-child(n+4)]:hidden"
/>
</div>
)
}
安装
pnpm dlx shadcn@latest add https://yiui.dev/r/local-time.json用法
组件分三种形态,都从同一个入口导入:
| 组件 | 形态 |
|---|---|
LocalTime | 行内时间,可以放进任何一行文字 |
WorldClock + WorldClockItem | 多个时区的世界时钟,列表或卡片 |
AnalogClock | SVG 模拟表盘 |
timeZone 接收 IANA 时区名,不是 +08:00 这类固定偏移。
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 小时:前半段是目标时区的当前时间,// 后是它与访客时区的差值。
import { LocalTime } from "@/components/ui/local-time/local-time"
const zones = [
{ label: "上海", timeZone: "Asia/Shanghai" },
{ label: "伦敦", timeZone: "Europe/London" },
{ label: "旧金山", timeZone: "America/Los_Angeles" },
]
export default function LocalTimeDemo() {
return (
<dl className="w-full max-w-sm divide-y divide-border border-y border-edge font-mono text-sm">
{zones.map((zone) => (
<div
key={zone.timeZone}
className="flex items-center justify-between gap-4 py-2.5"
>
<dt className="text-muted-foreground">{zone.label}</dt>
<dd className="tabular-nums">
<LocalTime timeZone={zone.timeZone} />
</dd>
</div>
))}
</dl>
)
}
示例
行内时间的选项
showDayNight 在时间前加昼夜图标,showZone 附上 UTC 偏移,animated 让变化的数字轻微下落淡入,hour12 切换 12 小时制,showSeconds={false} 去掉秒(此时每分钟才刷新)。
import { LocalTime } from "@/components/ui/local-time/local-time"
const rows = [
{
title: "昼夜图标 + UTC 偏移",
node: (
<LocalTime
timeZone="Asia/Tokyo"
showDayNight
showZone
showDifference={false}
/>
),
},
{
title: "数字翻动",
node: <LocalTime timeZone="Europe/Paris" animated />,
},
{
title: "12 小时制,不显示秒",
node: (
<LocalTime
timeZone="America/New_York"
hour12
showSeconds={false}
showZone
showDayNight
/>
),
},
{
title: "英文文案",
node: (
<LocalTime
timeZone="Europe/London"
locale="en-GB"
labels={{
pending: "local time",
same: "same as you",
ahead: "ahead",
behind: "behind",
hourUnit: "h",
}}
/>
),
},
]
export default function LocalTimeInlineDemo() {
return (
<dl className="w-full max-w-md divide-y divide-edge border-y border-edge text-sm">
{rows.map((row) => (
<div
key={row.title}
className="flex flex-col gap-1 py-2.5 sm:flex-row sm:items-center sm:justify-between sm:gap-4"
>
<dt className="text-muted-foreground">{row.title}</dt>
<dd className="font-mono text-xs sm:text-right">{row.node}</dd>
</div>
))}
</dl>
)
}
世界时钟
WorldClock 接收 zones 数组(本页顶部的预览把它和 AnalogClock、LocalTime 拼成一块「协作时区」面板:左边是所在地的表盘,右边是四个协作城市),每一项一行:城市名、时区与 UTC 偏移、当前时间、昼夜图标,右下角是日期差(昨天 / 明天)和与访客的时差。highlighted 给某一项加上强调色竖条。
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 统一下发。
import { WorldClock, WorldClockItem } from "@/components/ui/local-time/local-time"
export default function LocalTimeComposeDemo() {
return (
<WorldClock
hour12
showSeconds={false}
title="TEAM / 团队时区"
className="w-full max-w-sm"
>
<WorldClockItem timeZone="Asia/Shanghai" label="上海" highlighted />
<WorldClockItem timeZone="Asia/Kolkata" label="班加罗尔" />
<WorldClockItem timeZone="Europe/Berlin" label="柏林" />
<WorldClockItem timeZone="Pacific/Auckland" label="奥克兰" />
</WorldClock>
)
}
卡片形态
variant="cards" 把每个城市排成带模拟表盘的卡片,两列布局,最后一张落单时自动撑满整行。
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" },
{ label: "悉尼", timeZone: "Australia/Sydney" },
]
export default function LocalTimeCardsDemo() {
return (
<WorldClock
variant="cards"
zones={ZONES}
title="CITIES / 城市"
className="w-full max-w-md"
/>
)
}
模拟表盘
AnalogClock 默认平滑转动:指针每帧由 requestAnimationFrame 直接写入,不触发 React 渲染,离开视口时暂停。smooth={false} 或系统开启“减少动态效果”时改为按秒跳格;表盘随昼夜轻微变色,秒针使用主题强调色。
import { AnalogClock } from "@/components/ui/local-time/local-time"
const CLOCKS = [
{ label: "东京", timeZone: "Asia/Tokyo", props: {} },
{ label: "巴黎", timeZone: "Europe/Paris", props: { smooth: false } },
{
label: "洛杉矶",
timeZone: "America/Los_Angeles",
props: { showSeconds: false },
},
] as const
export default function LocalTimeAnalogDemo() {
return (
<ul className="flex flex-wrap items-start justify-center gap-6">
{CLOCKS.map((clock) => (
<li key={clock.timeZone} className="flex flex-col items-center gap-2">
<AnalogClock
timeZone={clock.timeZone}
label={clock.label}
className="size-28"
{...clock.props}
/>
<span className="text-xs text-muted-foreground">
{clock.label}
{"smooth" in clock.props ? " · 按秒跳格" : ""}
{"showSeconds" in clock.props ? " · 无秒针" : ""}
</span>
</li>
))}
</ul>
)
}
文案与区域格式
可见文案集中在 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 / live | WORLD CLOCK / LIVE | 世界时钟标题栏 |
clock | 当地时间 | 模拟表盘的读屏名称 |
时差怎么算
组件把目标时区的 UTC 偏移和 Date.prototype.getTimezoneOffset() 相减得到小时数,夏令时(例如 Europe/London)自动跟随。差值为 0 时显示 与你同一时间。昼夜以目标时区的 6:00–17:59 为白天,只是视觉提示,不区分半球与季节。
API
LocalTime
IANA 时区名,例如 Asia/Shanghai,不支持 +08:00 这类固定偏移
stringBCP 47 区域标识,决定时间与数字的格式
类型string | undefined"zh-CN"12 小时制(带上午/下午),默认 24 小时制
类型boolean | undefinedfalse显示秒;关闭后每分钟才刷新一次
类型boolean | undefinedtrue在 // 后显示与访客时区的时差
boolean | undefinedtrue在时间后显示 UTC 偏移,例如 UTC+8
boolean | undefinedfalse在时间前显示昼夜图标(6:00–17:59 为白天)
类型boolean | undefinedfalse数字变化时播放轻微的下落淡入;系统开启“减少动态效果”时自动关闭
类型boolean | undefinedfalse覆盖部分可见文案
类型Partial<LocalTimeLabels> | undefined挂载前的占位时间;默认随 showSeconds 为 --:--:-- 或 --:--
string | undefinedstring | undefinedstring | undefinedstring | undefinedstring | undefinedstring | undefined| 元素 | data-slot | 其他属性 |
|---|---|---|
| 根元素 | local-time | data-state="pending" / "ready";开启 showDayNight 时有 data-daylight="day" / "night" |
当前时间 <time> | local-time-time | dateTime(ISO 字符串) |
| UTC 偏移 | local-time-zone | — |
// 后的说明 | local-time-note | — |
| 昼夜图标 | day-night-mark | data-daylight |
WorldClock
用数据描述城市;与 children 二选一,同时传入时两者都会渲染
readonly WorldClockZone[] | undefined排布方式
类型WorldClockVariant | undefined"list"标题栏内容;传 null 隐藏整条标题栏
React.ReactNode"WORLD CLOCK"BCP 47 区域标识,决定时间与数字的格式
类型string | undefined"zh-CN"12 小时制(带上午/下午),默认 24 小时制
类型boolean | undefinedfalse显示秒
类型boolean | undefinedtrue在城市名下显示 UTC 偏移
类型boolean | undefinedtrue数字变化时播放轻微的下落淡入;系统开启“减少动态效果”时自动关闭
类型boolean | undefinedtrue覆盖部分可见文案
类型Partial<LocalTimeLabels> | undefined根元素是 role="group" 的 <div data-slot="world-clock">,带 data-variant;标题栏 world-clock-header,列表 world-clock-list。
WorldClockItem
IANA 时区名
类型string城市名
类型React.ReactNode高亮这一项
类型boolean | undefinedfalse根元素是 <li data-slot="world-clock-item">,带 data-state(pending / ready)、data-highlighted 与 data-daylight。必须放在 WorldClock 里。
AnalogClock
IANA 时区名,例如 Asia/Shanghai
stringBCP 47 区域标识,决定读屏标签里的时间格式
类型string | undefined"zh-CN"显示秒针
类型boolean | undefinedtrue指针每帧平滑转动;关闭后按秒跳格。系统开启“减少动态效果”时自动按秒跳格
类型boolean | undefinedtrue读屏名称,后面会拼上当前时间
类型string | undefined"当地时间"根元素是 <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决定。
源码
"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,
}
"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,
}
"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 }
"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 }
// 时区计算:全部基于 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 }
/** 时钟类组件的可见文案;各组件通过 `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 }
"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 }