跳至正文
组件

Greeting时段问候

按访客本地时间问候:时段图标与色调、可配置的分段时间与称呼;附带四时段指示条和会轮换副标题的问候卡片,首屏用静态文案避免水合差异。

正在加载…

安装

pnpm dlx shadcn@latest add https://yiui.dev/r/greeting.json

用法

组件分三层,按需取用:

组件形态
Greeting行内文字,放进任何标题或段落
GreetingPeriods早 / 中 / 下午 / 晚四个时段的指示条,当前时段亮起并显示进度
GreetingCard问候卡片:图标、色调、大字问候、轮换的副标题,加上时段指示

Greeting 默认输出 你好,很高兴在这里遇见你。,挂载后换成与访客时段相符的问候语。

app/greeting.tsx
import { Greeting } from "@/components/ui/greeting/greeting"

export function Intro() {
  return (
    <h2 className="text-xl font-medium">
      <Greeting />
    </h2>
  )
}

你好,很高兴在这里遇见你。

你好,今天想写点什么?

示例

图标与称呼

showIcon 在问候语前加当前时段的图标(日出 / 太阳 / 日落 / 月亮),颜色随时段变化;name 拼上称呼,nameSeparator 控制两者之间的分隔。四个时段的问候语、fallback 与 suffix 都可以覆盖,boundaries 重新划分时段。

正在加载…
<Greeting
  showIcon
  name="Travins"
  evening="夜深了"
  boundaries={{ morning: 6, noon: 12, afternoon: 15, evening: 20 }}
  suffix=",今天也辛苦了。"
/>

时段指示

GreetingPeriods 单独使用时就是一条时间轴:每格有图标、名称和区间,当前时段亮起主题之外的专属色调,格子底部的细线是这个时段已经过去的比例(每分钟更新)。区间文字跟着 boundaries 走。

正在加载…

问候卡片

GreetingCard 把前面的东西组合起来(也就是本页顶部的预览):右上角的大图标与光晕随时段换色,副标题每 6 秒轮换一次(淡入淡出),底部是四时段指示。showPeriods={false} 去掉指示,rotate={false} 固定在第一条副标题。

固定在某个时刻

hour 接收 0–24 的小时数(可带小数,如 12.5)。传入后结果是确定的,服务端也会直接渲染,不再等待客户端挂载——设计预览、截图和测试都用得上。下面的按钮在真实时间和四个固定时刻之间切换:

正在加载…

时段划分

默认本地时间问候语对应 prop色调
05:00–10:59早上好morning琥珀
11:00–13:59中午好noon天蓝
14:00–17:59下午好afternoon玫红
18:00–次日 04:59晚上好evening紫

boundaries 的四个值是各时段的起始小时,必须严格递增且落在 0–23;传错时回落到默认划分。evening 持续到次日 morning 开始。

色调来自 --flow-amber / --flow-blue / --flow-rose / --flow-violet,安装时随条目一起写入全局样式(与流程动画图示共用同一组变量),也可以在自己的样式里改写。没有这些变量时回落到主题强调色。

文案

Greeting 的文案用单独的 props;GreetingPeriods 与 GreetingCard 的文案集中在 labels,按字段整体覆盖:

labels 字段用于
eyebrow / pending卡片顶部小标题,以及挂载前的占位
periods时段指示的读屏名称
names四个时段的短名称({ morning: "早上", … })
tags小标题后的英文标记(MORNING …)
messages各时段轮换的副标题,每个时段至少一条
fallbackMessage挂载前的副标题

API

Greeting

根元素是 <span data-slot="greeting">,data-state 在挂载前是 pending、拿到时段后变成 ready,同时带上 data-period(morning / noon / afternoon / evening)。

GreetingPeriods

根元素是 <ol data-slot="greeting-periods">,每格 greeting-period(data-period、data-active),进度条 greeting-progress。

GreetingCard

根元素是 <div data-slot="greeting-card">,带 data-state 与 data-period。

useGreetingPeriod

import { useGreetingPeriod } from "@/components/ui/greeting/greeting"

const { period, progress } = useGreetingPeriod() // period 在挂载前为 null

返回当前时段和已过去的比例(0–1),第一个参数是 boundaries,第二个是 hour。时间来自随条目安装的 useNow hook,全页面共用一个定时器。

可访问性

  • 渲染的是内联 <span>,可以放进 <p>、<h2> 等任何文本容器,不改变标题层级。
  • 挂载后的文案替换没有加 aria-live:它是页面的主文案而不是通知,逐字播报会打断读屏用户。确实需要播报时,在外层容器上自己加 aria-live="polite"。
  • 时段图标与光晕都是装饰,aria-hidden;颜色之外,当前时段还有 aria-current="true" 和文字名称。
  • GreetingPeriods 是带 aria-label 的有序列表,进度条只是视觉辅助,不进入无障碍树。
  • 开启“减少动态效果”后副标题不再轮换,固定显示第一条;其余部分没有位移动画。
  • 首屏文案与静态 HTML 一致,读屏和搜索引擎看到的是同一段文字,不会出现空内容。

注意事项

为什么必须在客户端渲染

问候语取决于访客设备上的本地时间,服务端渲染时无从得知。静态导出会把 HTML 在构建时生成一次,如果首屏直接用浏览器时间渲染,客户端水合时拿到的文本就会和 HTML 不一致。

组件因此把 fallback 作为初始文案,挂载后再替换,并每分钟校准一次。

把 fallback 写成中性问候

副作用是首屏会短暂显示 fallback:把它设成一句中性、通用的问候(例如默认的“你好”)就不会显得突兀。传入 hour 可以绕开这个问题。

其他

  • 问候语和 fallback、suffix 都是纯字符串,不接受 JSX。需要强调某一段时,在外层用 <span> 包裹。
  • 时区以访客设备为准,不做服务端时区推断,因此不需要 timeZone 参数。
  • Greeting 返回内联 <span>,不是块级容器。

源码

components/ui/greeting/greeting.tsx
"use client"

import * as React from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { Moon, Sun, Sunrise, Sunset, type LucideIcon } from "lucide-react"

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

import { DEFAULT_LABELS, type GreetingLabels } from "./config"
import {
  PERIODS,
  normalizeBoundaries,
  periodRange,
  type GreetingBoundaries,
  type GreetingPeriod,
} from "./period"
import { useGreetingPeriod } from "./use-greeting-period"

const PERIOD_ICONS: Record<GreetingPeriod, LucideIcon> = {
  morning: Sunrise,
  noon: Sun,
  afternoon: Sunset,
  evening: Moon,
}

// 每个时段一种强调色:日出琥珀、正午天蓝、日落玫红、夜晚紫。
// 取自 flow-diagram 的多彩色变量;没有安装它们时回落到主题强调色。
const PERIOD_TONES: Record<GreetingPeriod, string> = {
  morning: "var(--flow-amber, var(--primary))",
  noon: "var(--flow-blue, var(--primary))",
  afternoon: "var(--flow-rose, var(--primary))",
  evening: "var(--flow-violet, var(--primary))",
}

function toneStyle(period: GreetingPeriod | null) {
  return {
    "--greeting-tone": period ? PERIOD_TONES[period] : "var(--primary)",
  } as React.CSSProperties
}

type GreetingTextProps = {
  /**
   * 首屏与挂载前显示的静态文案,需保持中性以避免水合差异
   * @default "你好"
   */
  fallback?: string
  /**
   * 05:00–10:59 的问候语
   * @default "早上好"
   */
  morning?: string
  /**
   * 11:00–13:59 的问候语
   * @default "中午好"
   */
  noon?: string
  /**
   * 14:00–17:59 的问候语
   * @default "下午好"
   */
  afternoon?: string
  /**
   * 18:00–次日 04:59 的问候语
   * @default "晚上好"
   */
  evening?: string
  /** 称呼,拼在问候语之后,例如 `Travins` */
  name?: string
  /**
   * 问候语与称呼之间的分隔
   * @default ","
   */
  nameSeparator?: string
  /**
   * 各时段的起始小时,必须严格递增;传错时回落到默认划分
   * @default { morning: 5, noon: 11, afternoon: 14, evening: 18 }
   */
  boundaries?: Partial<GreetingBoundaries>
  /**
   * 指定当前小时(可带小数)。传入后结果是确定的,不再等待客户端挂载,适合演示和测试
   */
  hour?: number
}

type GreetingProps = Omit<React.ComponentProps<"span">, "children"> &
  GreetingTextProps & {
    /**
     * 问候语之后拼接的文本
     * @default ",很高兴在这里遇见你。"
     */
    suffix?: string
    /**
     * 在问候语前显示当前时段的图标(日出 / 太阳 / 日落 / 月亮)
     * @default false
     */
    showIcon?: boolean
  }

/** 按时段选出问候语;时段未知(挂载前)时返回 `fallback`。 */
function pickGreeting(
  period: GreetingPeriod | null,
  texts: Pick<
    GreetingTextProps,
    "fallback" | "morning" | "noon" | "afternoon" | "evening"
  >,
) {
  const {
    fallback = "你好",
    morning = "早上好",
    noon = "中午好",
    afternoon = "下午好",
    evening = "晚上好",
  } = texts
  if (period === null) return fallback
  return { morning, noon, afternoon, evening }[period]
}

/** 根据访客当地时间问候,首屏使用一致的静态文案以避免水合差异。 */
function Greeting({
  fallback,
  suffix = ",很高兴在这里遇见你。",
  morning,
  noon,
  afternoon,
  evening,
  name,
  nameSeparator = ",",
  boundaries,
  hour,
  showIcon = false,
  className,
  ...props
}: GreetingProps) {
  const { period } = useGreetingPeriod(boundaries, hour)
  const Icon = period ? PERIOD_ICONS[period] : null
  return (
    <span
      data-slot="greeting"
      data-state={period === null ? "pending" : "ready"}
      data-period={period ?? undefined}
      className={className}
      {...props}
    >
      {showIcon ? (
        <span
          data-slot="greeting-icon"
          style={toneStyle(period)}
          className="mr-[0.4em] inline-block size-[1em] align-[-0.125em] text-(--greeting-tone)"
        >
          {Icon ? <Icon aria-hidden="true" className="size-full" /> : null}
        </span>
      ) : null}
      {pickGreeting(period, { fallback, morning, noon, afternoon, evening })}
      {name ? `${nameSeparator}${name}` : null}
      {suffix}
    </span>
  )
}

type GreetingPeriodsProps = Omit<React.ComponentProps<"ol">, "children"> &
  Pick<GreetingTextProps, "boundaries" | "hour"> & {
    /**
     * 在当前时段格里显示时段进度条
     * @default true
     */
    showProgress?: boolean
    /** 覆盖部分可见文案(用到 `periods`、`names`) */
    labels?: Partial<GreetingLabels>
  }

/** 四个时段的指示条:当前时段亮起,格内细线表示这个时段已经过去多少。 */
function GreetingPeriods({
  boundaries,
  hour,
  showProgress = true,
  labels,
  className,
  ...props
}: GreetingPeriodsProps) {
  const text = { ...DEFAULT_LABELS, ...labels }
  const normalized = normalizeBoundaries(boundaries)
  const { period: active, progress } = useGreetingPeriod(boundaries, hour)
  return (
    <ol
      data-slot="greeting-periods"
      aria-label={text.periods}
      className={cn("grid grid-cols-4 gap-1.5", className)}
      {...props}
    >
      {PERIODS.map((period) => {
        const Icon = PERIOD_ICONS[period]
        const current = period === active
        return (
          <li
            key={period}
            data-slot="greeting-period"
            data-period={period}
            data-active={current ? "" : undefined}
            aria-current={current ? "true" : undefined}
            style={toneStyle(period)}
            className={cn(
              "relative flex flex-col items-center gap-1 overflow-hidden rounded-md border border-edge bg-background px-1 py-2 text-muted-foreground transition-colors",
              "data-[active]:border-(--greeting-tone)/60 data-[active]:bg-(--greeting-tone)/10 data-[active]:text-foreground",
            )}
          >
            <Icon
              aria-hidden="true"
              strokeWidth={1.5}
              className="size-4 in-data-[active]:text-(--greeting-tone)"
            />
            <span className="text-xs">{text.names[period]}</span>
            <span className="font-mono text-[10px] opacity-70">
              {periodRange(period, normalized)}
            </span>
            {showProgress && current ? (
              <span
                aria-hidden="true"
                data-slot="greeting-progress"
                className="absolute inset-x-0 bottom-0 h-0.5 bg-(--greeting-tone)/20"
              >
                <span
                  className="block h-full origin-left bg-(--greeting-tone)"
                  style={{ transform: `scaleX(${progress})` }}
                />
              </span>
            ) : null}
          </li>
        )
      })}
    </ol>
  )
}

type GreetingCardProps = Omit<React.ComponentProps<"div">, "children"> &
  Omit<GreetingTextProps, "nameSeparator"> & {
    /**
     * 显示四时段指示条
     * @default true
     */
    showPeriods?: boolean
    /**
     * 副标题每 6 秒轮换一次;系统开启“减少动态效果”时固定显示第一条
     * @default true
     */
    rotate?: boolean
    /** 覆盖部分可见文案 */
    labels?: Partial<GreetingLabels>
  }

/** 问候卡片:时段图标与色调、大字问候、轮换的副标题,以及四时段指示。 */
function GreetingCard({
  fallback,
  morning,
  noon,
  afternoon,
  evening,
  name,
  boundaries,
  hour,
  showPeriods = true,
  rotate = true,
  labels,
  className,
  ...props
}: GreetingCardProps) {
  const text = { ...DEFAULT_LABELS, ...labels }
  const { period } = useGreetingPeriod(boundaries, hour)
  const reduced = useReducedMotion()
  const tick = useNow(6000)
  const messages = period ? text.messages[period] : []
  const index =
    rotate && !reduced && tick !== null && messages.length
      ? Math.floor(tick / 6000) % messages.length
      : 0
  const message = messages[index] ?? text.fallbackMessage
  const Icon = period ? PERIOD_ICONS[period] : null

  return (
    <div
      data-slot="greeting-card"
      data-state={period === null ? "pending" : "ready"}
      data-period={period ?? undefined}
      style={toneStyle(period)}
      className={cn(
        "relative flex flex-col gap-5 overflow-hidden rounded-lg border border-edge bg-background p-5",
        className,
      )}
      {...props}
    >
      <div
        aria-hidden="true"
        className="pointer-events-none absolute -top-16 -right-12 size-48 rounded-full bg-(--greeting-tone)/10 blur-3xl"
      />
      {Icon ? (
        <Icon
          aria-hidden="true"
          strokeWidth={1}
          className="pointer-events-none absolute top-3 right-3 size-16 text-(--greeting-tone)/50"
        />
      ) : null}
      <div className="relative">
        <p className="flex items-center gap-2 font-mono text-[10px] tracking-[0.08em] text-muted-foreground">
          <span
            aria-hidden="true"
            className="size-1.5 rounded-full bg-(--greeting-tone)"
          />
          {text.eyebrow} / {period ? text.tags[period] : text.pending}
        </p>
        <p className="mt-3 pr-14 text-4xl font-semibold tracking-tight">
          <Greeting
            fallback={fallback}
            morning={morning}
            noon={noon}
            afternoon={afternoon}
            evening={evening}
            name={name}
            boundaries={boundaries}
            hour={hour}
            suffix=""
          />
          <span className="text-(--greeting-tone)">.</span>
        </p>
        <div className="mt-2 min-h-5 text-sm text-muted-foreground">
          <AnimatePresence mode="wait" initial={false}>
            <motion.p
              key={message}
              initial={{ opacity: 0, y: 4 }}
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0, y: -4 }}
              transition={{ duration: 0.2 }}
            >
              {message}
            </motion.p>
          </AnimatePresence>
        </div>
      </div>
      {showPeriods ? (
        <GreetingPeriods
          boundaries={boundaries}
          hour={hour}
          labels={labels}
          className="relative"
        />
      ) : null}
    </div>
  )
}

export { Greeting, GreetingCard, GreetingPeriods, useGreetingPeriod }
export type {
  GreetingBoundaries,
  GreetingCardProps,
  GreetingLabels,
  GreetingPeriod,
  GreetingPeriodsProps,
  GreetingProps,
}
components/ui/greeting/use-greeting-period.ts
"use client"

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

import {
  normalizeBoundaries,
  resolvePeriod,
  type GreetingBoundaries,
  type GreetingPeriod,
} from "./period"

type GreetingState = {
  /** 当前时段;服务端与水合期间为 `null` */
  period: GreetingPeriod | null
  /** 当前时段已经过去的比例(0–1) */
  progress: number
}

/**
 * 访客本地时间所在的时段,每分钟校准一次。
 * 传入 `hour` 时直接用它(可带小数,如 `13.5`),此时结果是确定的,服务端也能渲染。
 */
function useGreetingPeriod(
  boundaries?: Partial<GreetingBoundaries>,
  hour?: number,
): GreetingState {
  const now = useNow(60_000)
  const normalized = normalizeBoundaries(boundaries)
  if (hour !== undefined)
    return resolvePeriod(((hour % 24) + 24) % 24, normalized)
  if (now === null) return { period: null, progress: 0 }
  const date = new Date(now)
  return resolvePeriod(date.getHours() + date.getMinutes() / 60, normalized)
}

export { useGreetingPeriod }
export type { GreetingState }
components/ui/greeting/period.ts
// 时段划分:纯函数,不依赖 React 和当前时间,方便测试与服务端使用。

type GreetingPeriod = "morning" | "noon" | "afternoon" | "evening"

/** 每个时段的起始小时(0–23)。`evening` 持续到次日 `morning` 开始。 */
type GreetingBoundaries = Record<GreetingPeriod, number>

const PERIODS: readonly GreetingPeriod[] = [
  "morning",
  "noon",
  "afternoon",
  "evening",
]

const DEFAULT_BOUNDARIES: GreetingBoundaries = {
  morning: 5,
  noon: 11,
  afternoon: 14,
  evening: 18,
}

/** 起始小时必须严格递增且落在 0–23;传错时回落到默认划分,而不是渲染出错乱的时段。 */
function normalizeBoundaries(
  boundaries?: Partial<GreetingBoundaries>,
): GreetingBoundaries {
  const merged = { ...DEFAULT_BOUNDARIES, ...boundaries }
  const hours = PERIODS.map((period) => merged[period])
  const valid = hours.every(
    (hour, index) =>
      Number.isFinite(hour) &&
      hour >= 0 &&
      hour < 24 &&
      (index === 0 || hour > hours[index - 1]),
  )
  return valid ? merged : DEFAULT_BOUNDARIES
}

/** 时刻(小时,可带小数)所在的时段,以及已经过去的比例(0–1)。 */
function resolvePeriod(time: number, boundaries: GreetingBoundaries) {
  const shifted = time < boundaries.morning ? time + 24 : time
  const starts = PERIODS.map((period) => boundaries[period])
  let index = PERIODS.length - 1
  while (index > 0 && shifted < starts[index]) index--
  const start = starts[index]
  const end = index === PERIODS.length - 1 ? starts[0] + 24 : starts[index + 1]
  return {
    period: PERIODS[index],
    progress: Math.min(1, Math.max(0, (shifted - start) / (end - start))),
  }
}

/** `05–11` 这样的区间文字;`evening` 的终点是次日 `morning` 的起点。 */
function periodRange(period: GreetingPeriod, boundaries: GreetingBoundaries) {
  const index = PERIODS.indexOf(period)
  const end = boundaries[PERIODS[(index + 1) % PERIODS.length]]
  const pad = (hour: number) => String(hour).padStart(2, "0")
  return `${pad(boundaries[period])}–${pad(end)}`
}

export {
  DEFAULT_BOUNDARIES,
  PERIODS,
  normalizeBoundaries,
  periodRange,
  resolvePeriod,
}
export type { GreetingBoundaries, GreetingPeriod }
components/ui/greeting/config.ts
import type { GreetingPeriod } from "./period"

/** 问候卡片与时段指示的可见文案;`labels` 按字段整体覆盖(`names` 等对象不做深合并)。 */
type GreetingLabels = {
  /** 卡片顶部的小标题 */
  eyebrow: string
  /** 挂载前小标题后的占位 */
  pending: string
  /** 时段指示的读屏名称 */
  periods: string
  /** 各时段的短名称 */
  names: Record<GreetingPeriod, string>
  /** 各时段的英文标记,显示在小标题后 */
  tags: Record<GreetingPeriod, string>
  /** 各时段轮换的副标题,至少一条 */
  messages: Record<GreetingPeriod, readonly string[]>
  /** 挂载前的副标题 */
  fallbackMessage: string
}

const DEFAULT_LABELS: GreetingLabels = {
  eyebrow: "GREETING",
  pending: "LOCAL TIME",
  periods: "一天中的时段",
  names: { morning: "早上", noon: "中午", afternoon: "下午", evening: "晚上" },
  tags: {
    morning: "MORNING",
    noon: "NOON",
    afternoon: "AFTERNOON",
    evening: "EVENING",
  },
  messages: {
    morning: ["新的一天,先从最重要的一件事开始。", "今天想先做点什么?"],
    noon: ["记得吃饭,也给眼睛放个假。", "上午辛苦了,下午继续。"],
    afternoon: ["喝杯水,起来走动一下。", "下午的专注力很宝贵。"],
    evening: ["今天辛苦了,慢慢来。", "收个尾,早点休息。"],
  },
  fallbackMessage: "很高兴在这里遇见你。",
}

export { DEFAULT_LABELS }
export type { GreetingLabels }
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 }