Solar Terms Timeline (React)
A horizontal timeline of the 24 solar terms for React: locates the current term for any date, counts down to the next one, with seasonal colour bands and phenology / seasonal-food cards; term dates come from the standard 21st-century approximation, getSolarTerms and currentSolarTerm are exported, and almanac values can override the formula.
A content component for tea, produce, wellness and calendar-style sites. The 24 solar terms sit in order on a horizontally scrollable track, split into four seasonal colour bands (spring green, summer vermilion, autumn gold, winter blue); the component locates the current term from date (defaults to today, resolved after mount) — that node scales up, breathes with a soft glow and is scrolled into view automatically. A summary card above shows today's term, how many days remain until the next one, and a one-line phenology / seasonal-food note (24 built-in Traditional Chinese lines, each overridable). Term dates are computed with the common 21st-century approximation (the "Shouxing" formula), which is occasionally off by ±1 day, so almanac values can be passed in as overrides; getSolarTerms() and currentSolarTerm() are exported separately for use elsewhere.
npx shadcn@latest add https://webberui.com/r/solar-terms-timeline.jsonPlayground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<SolarTermsTimeline />
Installation
npx shadcn@latest add https://webberui.com/r/solar-terms-timeline.jsonOr, once registries are configured in components.json, install it as @webberui/solar-terms-timeline.
Usage
import {
SolarTermsTimeline,
getSolarTerms,
currentSolarTerm,
} from "@/components/ui/solar-terms-timeline";
// Simplest form: with no date it locates "today" after mount
<SolarTermsTimeline onSelect={(term) => console.log(term.name, term.date)} />
// Fixed date, horizontal term names, almanac override for the formula's ±1-day error, custom note
<SolarTermsTimeline
date="2026-02-18"
orientation="horizontal"
terms={[{ name: "雨水", date: "2026-02-18" }]}
descriptions={{ 雨水: "春茶預購開跑,濕冷天請多喝溫熱的茶。" }}
/>
// The helpers can be used on their own
getSolarTerms(2026);
// [{ index: 0, name: "小寒", en: "Minor Cold", season: "winter", date: 2026-01-05 }, … 24 entries]
const { current, next, daysUntilNext } = currentSolarTerm(new Date(2026, 7, 18));
// current.name === "立秋" (Start of Autumn), next.name === "處暑" (End of Heat), daysUntilNext === 5Props
| Prop | Type | Default | Description |
|---|---|---|---|
date | Date | string | today (resolved after mount) | Anchor date; strings use YYYY-MM-DD. When omitted or unparseable, "today" is taken after mount, and SSR / the first render show a placeholder state |
year | number | the year of date | Which year's 24 terms the timeline shows; when it differs from the year of date, no node is highlighted |
terms | SolarTermOverride[] | — | Almanac overrides for specific term dates, correcting the formula's ±1-day error |
descriptions | Record<string, string> | 24 built-in Traditional Chinese notes | Override the phenology / food notes, keyed by term name; terms not overridden keep the built-in text |
orientation | "vertical" | "horizontal" | "vertical" | Layout of the term name on each node: vertical (top-to-bottom) or horizontal |
showSummary | boolean | true | Show the summary card above the track (today's / selected term, countdown, note, back-to-today) |
locale | "zh-TW" | "en" | "zh-TW" | Language of the built-in copy; with en the node title becomes the English term name and the Chinese name is shown small underneath |
labels | Partial<SolarTermsTimelineLabels> | — | Override individual strings; takes precedence over the locale defaults |
value | string | null | — | Controlled: name of the selected term; null means "follow today's term". Uncontrolled when omitted |
defaultValue | string | — | Initially selected term name in uncontrolled mode |
onSelect | (term: SolarTerm) => void | — | Fires when a node is clicked, the keyboard moves the selection, or "Back to today" is pressed, with that term's data |
className | string | — | Forwarded to the outermost container |
SolarTerm
| Field | Type | Default | Description |
|---|---|---|---|
index | number | — | Position within the year, 0 is 小寒 (Minor Cold) and 23 is 冬至 (Winter Solstice) |
name | string | — | Term name in Traditional Chinese |
en | string | — | English name, e.g. "Start of Spring" |
season | "spring" | "summer" | "autumn" | "winter" | — | Season: 立春–穀雨 spring, 立夏–大暑 summer, 立秋–霜降 autumn, 立冬–大寒 winter |
date | Date | — | Date the term begins (local midnight) |
SolarTermOverride
| Field | Type | Default | Description |
|---|---|---|---|
name | string | — | Name of the term to override; must be one of the 24 |
date | string | — | Almanac date as YYYY-MM-DD; an unparseable entry is ignored and the formula value is kept |
SolarTermStatus
Return value of currentSolarTerm(date, terms?):
| Field | Type | Default | Description |
|---|---|---|---|
current | SolarTerm | — | The term the date falls in; before 小寒 (early January) it is last year's 冬至 |
next | SolarTerm | — | The following term; after 冬至 it is next year's 小寒 |
daysUntilNext | number | — | Days until the next term, 0 on the day the term begins |
daysSinceStart | number | — | Days since the current term began, 0 on the day it begins |
SolarTermsTimelineLabels
Strings overridable through labels; fields containing {name}, {days} or {n} placeholders are filled in at render time:
| Field | Type | Default | Description |
|---|---|---|---|
timeline | string | "二十四節氣時間軸" (24 solar terms timeline) | Accessible name of the role="region" wrapper |
terms | string | "二十四節氣" (The 24 solar terms) | Accessible name of the role="radiogroup" track |
current | string | "今日節氣" (Today's term) | Summary-card heading when showing today's term |
selected | string | "節氣" (Solar term) | Summary-card heading when showing a term the user picked |
nextUp | string | "下一節氣" (Next term) | Countdown heading |
dayUnit | string | "天" (day) | Day unit, singular |
daysUnit | string | "天" (days) | Day unit, plural |
daysUntil | string | "距「{name}」還有 {days} 天" ({days} days until {name}) | Distance text when a future term is selected |
daysAgo | string | "「{name}」已過 {days} 天" ({name} was {days} days ago) | Distance text when a past term is selected |
isToday | string | "今天正是「{name}」交節之日" (Today is {name}) | Text shown when the anchor date is exactly the day a term begins |
ordinal | string | "第 {n} 節氣" (Term {n} of 24) | Ordinal of the term |
backToToday | string | "回到今日" (Back to today) | Label of the back-to-today button |
pending | string | "正在取得今天的日期…" (Getting today's date…) | Placeholder before the date is known (pre-mount) |
currentMark | string | "目前節氣" (current term) | "Current term" note inside each node's aria-label |
spring / summer / autumn / winter | string | "春" / "夏" / "秋" / "冬" (Spring / Summer / Autumn / Winter) | Season names shown at the start of each band and on the summary-card chip |
getSolarTerms(year, overrides?)
getSolarTerms(year: number, overrides?: SolarTermOverride[]): SolarTerm[] — returns the 24 terms of that year (from 小寒 to 冬至), computed with the Shouxing formula, most accurate for 2001–2100. Pass overrides to replace specific dates with almanac values.
currentSolarTerm(date, terms?)
currentSolarTerm(date: Date, terms?: SolarTerm[]): SolarTermStatus — finds the term a date falls in, the next term and the distances in days. terms may be the (already overridden) term list for that year; when omitted the formula is used. The cross-year values — last year's 冬至 and next year's 小寒 — are always computed by the formula.
The constants SOLAR_TERM_NAMES (the 24 names in year order) and SOLAR_TERM_DESCRIPTIONS (the 24 built-in notes keyed by term name), plus the SolarTermSeason type, are also named exports.
How it works
- Date formula: day = int(Y × 0.2422 + C) − L. Y is the last two digits of the year (2001–2100 → 1–100), C is each term's 21st-century constant (小寒 5.4055, 大寒 20.12, 立春 3.87, 雨水 18.73, 驚蟄 5.63, 春分 20.646, 清明 4.81, 穀雨 20.1, 立夏 5.52, 小滿 21.04, 芒種 5.678, 夏至 21.37, 小暑 7.108, 大暑 22.83, 立秋 7.5, 處暑 23.13, 白露 7.646, 秋分 23.042, 寒露 8.318, 霜降 23.438, 立冬 7.438, 小雪 22.36, 大雪 7.18, 冬至 21.94). L is the leap-year correction: the January / February terms (小寒, 大寒, 立春, 雨水) use int((Y − 1) / 4), the rest use int(Y / 4) — in January and February that year's 29 February has not happened yet, so one fewer accumulated leap day counts; using a single L for all terms would shift a whole batch of them by a day in every leap year
- ±1-day error: the approximation occasionally differs from the almanac by one day (known cases include 雨水 2026, 小暑 2016 and 冬至 2021); for production, override with almanac values via
terms. The demo and playground show formula values - Year boundary: the timeline is cyclic — in early January the "current term" is really last year's 冬至, and the component still highlights this year's 冬至 node; after 冬至 the "next term" is next year's 小寒 and the countdown crosses the year correctly
- Selection and summary card: with nothing selected the card shows today's term and "N days until the next term"; after picking another node it shows that term, how many days before / after today it falls, and a "Back to today" button. A
valueofnullmeans "follow today's term" — in controlled mode, sync it insideonSelect - Auto-scroll: only the track's own horizontal scrollbar moves (it computes
offsetLeftand callsscrollToon the track) rather thanscrollIntoView— the latter also scrolls the whole page, so the page would jump whenever the component sits off-screen - SSR: without a
date, the server and the first render list the 24 term names only, with no dates and a placeholder in the summary card; today is resolved after mount and the dates fill in without layout shift because the node height is reserved
Accessibility
- The outer wrapper is
role="region"named by a visually hidden heading (labels.timeline); the track isrole="radiogroup"and the 24 nodes are nativetype="button"elements withrole="radio"—aria-checkedmarks the user's selection,aria-current="date"marks today's term, and eacharia-labelcontains the term name, date and a "current term" note - Keyboard: roving tabindex — only the current node is in the Tab order; the arrow keys (left / right, up / down) move through the 24 nodes cyclically and select as they go,
Home/Endjump to 小寒 / 冬至, and the track scrolls along with focus - The summary-card note lives in an
aria-live="polite"region, so switching terms is announced by screen readers; the track points at the card viaaria-describedby - Seasonal bands and the glow are
aria-hiddendecoration; seasons are not conveyed by colour alone — each band starts with the season name and the summary-card chip carries text and an icon - When the user has "reduce motion" enabled at the system level, the breathing glow on the current node is replaced by a static faint halo, node scaling and note fades switch instantly, and the auto-scroll uses
behavior: "auto"to jump straight to the position
Moon Blocks Toss
Throw two crescent divination blocks with a 3D tumbling animation; the landing resolves to sheng-bei / xiao-bei / yin-bei and fires onResult for playful yes/no decisions
Taiwan Tile Map (React)
A React tile-grid map of Taiwan's 22 counties and cities that needs no geo data: value-driven choropleth colouring, hover tooltip cards, click callbacks and arrow-key navigation, usable as a visual filter.