Solar Terms Timeline
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.
Bring this into your project
Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.
npx webberui@latest add solar-terms-timeline.enPlayground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<SolarTermsTimeline/>
The complete example includes the preview layout and data. Install the component first, then paste this file into your project.
Installation
npx webberui@latest add https://webberui.com/r/solar-terms-timeline.en.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: "Rain Water", date: "2026-02-18" }]}
descriptions={{ Rain Water: "Spring teaPre-orderstarts now, Enjoy warm tea on cold, damp days. " }}
/>
// The helpers can be used on their own
getSolarTerms(2026);
// [{ index: 0, name: "Minor Cold", en: "Minor Cold", season: "winter", date: 2026-01-05 }, … 24 entries]
const { current, next, daysUntilNext } = currentSolarTerm(new Date(2026, 7, 18));
// current.name === "Beginning of Autumn" (Start of Autumn), next.name === "End of Heat" (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 (Minor Cold) and 23 is Winter Solstice (Winter Solstice) |
name | string | — | Term name in Traditional Chinese |
en | string | — | English name, e.g. "Start of Spring" |
season | "spring" | "summer" | "autumn" | "winter" | — | Season: Beginning of Spring–Grain Rain spring, Beginning of Summer–Great Heat summer, Beginning of Autumn–Frost's Descent autumn, The Beginning of Winter–Great Cold 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 Minor Cold (early January) it is last year's Winter Solstice |
next | SolarTerm | — | The following term; after Winter Solstice it is next year's Minor Cold |
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 | "Twenty-Four Solar Terms Timeline" (24 solar terms timeline) | Accessible name of the role="region" wrapper |
terms | string | "Twenty-Four Solar Terms" (The 24 solar terms) | Accessible name of the role="radiogroup" track |
current | string | "Today's Solar Term" (Today's term) | Summary-card heading when showing today's term |
selected | string | "Solar Term" (Solar term) | Summary-card heading when showing a term the user picked |
nextUp | string | "Next Solar Term" (Next term) | Countdown heading |
dayUnit | string | "days" (day) | Day unit, singular |
daysUnit | string | "days" (days) | Day unit, plural |
daysUntil | string | "{days} days until "{name}"" ({days} days until {name}) | Distance text when a future term is selected |
daysAgo | string | "{days} days have passed since "{name}"" ({name} was {days} days ago) | Distance text when a past term is selected |
isToday | string | "Today is the day "{name}" begins its solar term" (Today is {name}) | Text shown when the anchor date is exactly the day a term begins |
ordinal | string | "The {n}th Solar Term" (Term {n} of 24) | Ordinal of the term |
backToToday | string | "Back to Today" (Back to today) | Label of the back-to-today button |
pending | string | "Retrieving today's date..." (Getting today's date…) | Placeholder before the date is known (pre-mount) |
currentMark | string | "Current Solar Term" (current term) | "Current term" note inside each node's aria-label |
spring / summer / autumn / winter | string | "Spring" / "Summer" / "Autumn" / "Winter" (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 Minor Cold to Winter Solstice), 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 Winter Solstice and next year's Minor Cold — 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 (Minor Cold 5.4055, Great Cold 20.12, Beginning of Spring 3.87, Rain Water 18.73, Awakening of Insects 5.63, Spring Equinox 20.646, Qingming Festival 4.81, Grain Rain 20.1, Beginning of Summer 5.52, Lesser Fullness 21.04, Grain in Ear 5.678, Summer Solstice 21.37, Lesser Heat 7.108, Great Heat 22.83, Beginning of Autumn 7.5, End of Heat 23.13, White Dew 7.646, Autumn Equinox 23.042, Cold Dew 8.318, Frost's Descent 23.438, The Beginning of Winter 7.438, Lesser Snow 22.36, Heavy Snow 7.18, Winter Solstice 21.94). L is the leap-year correction: the January / February terms (Minor Cold, Great Cold, Beginning of Spring, Rain Water) 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 Rain Water 2026, Lesser Heat 2016 and Winter Solstice 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 Winter Solstice, and the component still highlights this year's Winter Solstice node; after Winter Solstice the "next term" is next year's Minor Cold 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 Minor Cold / Winter Solstice, 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
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.