Year Pulse Grid
A GitHub-style wall of day cells for a whole year — a wave sweeps across on load lighting cells up by intensity, hovering a day floats an event card, and clicking a cell selects it and filters the linked sidebar list to that day's events.
npx shadcn@latest add https://webberui.com/r/year-pulse-grid.jsonPlayground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<YearPulseGrid />
Installation
npx shadcn@latest add https://webberui.com/r/year-pulse-grid.jsonOr, once registries are configured in components.json, install it as @webberui/year-pulse-grid.
Usage
Pass a whole year of day data through days, each entry carrying date (YYYY-MM-DD), value (intensity), and optional events. Dates you do not list are filled in as empty cells automatically. The selected date can be managed by the built-in sidebar, or driven in controlled mode through selectedDate / onSelectDate.
import {
YearPulseGrid,
type YearPulseDay,
} from "@/components/ui/year-pulse-grid";
const days: YearPulseDay[] = [
{ date: "2025-01-06", value: 6, events: [{ id: "e1", title: "v1.0 released" }] },
{ date: "2025-01-07", value: 2 },
{ date: "2025-01-08", value: 0 },
// …a whole year
];
<YearPulseGrid days={days} year={2025} weekStartsOn={1} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
days | YearPulseDay[] | — | A whole year of day data; dates you do not list are filled in as empty cells with value 0 |
year | number | the first entry's date, or the current year | The year to display |
weekStartsOn | 0 | 1 | 0 | First day of the week: 0 = Sunday, 1 = Monday |
levels | number | 5 | Number of color levels (including level 0 for empty cells) |
levelClassNames | string[] | emerald scale | Color level classes from light to dark; the length must match levels |
getLevel | (value: number, max: number) => number | evenly divided bands | Custom mapping from intensity value to color level |
cellSize | number | 12 | Side length of a single cell (px) |
cellGap | number | 3 | Spacing between cells (px) |
radius | number | 2 | Corner radius of a single cell (px) |
waveStagger | number | 0.008 | Delay coefficient per step of the load wave (seconds) |
waveDuration | number | 0.4 | How long a single cell takes to light up (seconds) |
rowWeight | number | 2 | Weight of the row direction in the wave step count, deciding the diagonal tilt of the wave |
animateOnLoad | boolean | true | Whether to play the load wave reveal |
selectedDate | string | null | — | Controlled: the currently selected date |
defaultSelectedDate | string | null | null | Initially selected date in uncontrolled mode |
onSelectDate | (date: string | null, day: YearPulseDay | null) => void | — | Fires when the selection changes |
showSidebar | boolean | true | Whether to show the linked sidebar list |
sidebarTitle | React.ReactNode | "全年動態" | Sidebar title when nothing is selected — the built-in default is Traditional Chinese for "activity across the year", so pass this prop to localize it |
emptyLabel | string | "這一天沒有事件" | Message shown when the day has no events — the built-in default is Traditional Chinese for "no events on this day", so pass this prop to localize it |
renderEvent | (event: YearPulseEvent, day: YearPulseDay) => React.ReactNode | — | Custom rendering of a single sidebar event |
locale | string | "zh-TW" | Intl locale, used for formatting months / weekdays / dates |
aria-label | string | "<year> 年度脈動格" | Accessible label of the outermost element (the built-in default is in Traditional Chinese, so pass this prop to localize it) |
className | string | — | Appended to the outermost container class |
sidebarClassName | string | — | Appended to the sidebar container class |
YearPulseDay
| Field | Type | Description |
|---|---|---|
date | string | ISO date (YYYY-MM-DD) |
value | number | Intensity value, deciding the depth of the color level; <= 0 counts as an empty cell |
events | YearPulseEvent[] | List of that day's events (optional) |
YearPulseEvent
| Field | Type | Description |
|---|---|---|
id | string | Unique event id |
title | string | Event title |
detail | string | Secondary caption (time, category, and so on; optional) |
Animation details
- Load wave: each cell's reveal delay is decided by
col + row × rowWeight, so the wave sweeps diagonally from the top left to the bottom right, with each cell scaling up and fading in to its intensity color. Adjust the speed withwaveStaggerand the per-cell duration withwaveDuration; settinganimateOnLoad={false}presents the final state directly. - Floating event card: hovering or keyboard-focusing a day floats an event card above that cell (below it when close to the top), fading and scaling in to show the date, the intensity, and the first few events. The card is marked
pointer-events-noneand its coordinates are computed from a livegetBoundingClientRect, so it positions correctly even under horizontal scrolling. - Selection linking: clicking a day cell toggles the selection (clicking again clears it), the selected cell lifts and takes a focus ring, and the sidebar filters in sync to that day's events; with nothing selected, the sidebar lists the whole year's events, and clicking a list item selects the matching date again.
Accessibility
- When the user has "reduce motion" enabled at the system level, the wave and the floating card position instantly (only the animation parameters are disabled — the DOM structure is unchanged)
- The cell wall is
role="group"with anaria-label; each day cell is a button with anaria-labelcarrying the full date, intensity, and event count, and marks its selected state witharia-pressed - It uses a roving tabindex: arrow keys navigate between cells,
Home/Endjump to the start / end of the year,Enter/Spaceselects, andEscclears the selection and the event card - The sidebar title announces selection changes through
aria-live="polite"; the month and weekday labels and the decorative dots are all markedaria-hidden, and every focusable element has afocus-visibleoutline
Weighted Metric Wall
A wall of KPI tiles whose sizes are computed automatically from the weight of each value, revealed largest-first in a cascade, expanding a detail layer on hover.
Rank Shuffle Board
A data-driven rank shuffle leaderboard — rows swap positions with a FLIP animation when scores update, arrows flash for rank gains and losses, and the top three carry gold, silver, and bronze podium vocabulary.