Data Storytelling

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.

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 year-pulse-grid.en

New project or installation trouble? Start here →

Playground

Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.

12
3
2
0.008
Active edited usageDefault props
<YearPulseGrid/>

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/year-pulse-grid.en.json

Or, 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

PropTypeDefaultDescription
daysYearPulseDay[]—A whole year of day data; dates you do not list are filled in as empty cells with value 0
yearnumberthe first entry's date, or the current yearThe year to display
weekStartsOn0 | 10First day of the week: 0 = Sunday, 1 = Monday
levelsnumber5Number of color levels (including level 0 for empty cells)
levelClassNamesstring[]emerald scaleColor level classes from light to dark; the length must match levels
getLevel(value: number, max: number) => numberevenly divided bandsCustom mapping from intensity value to color level
cellSizenumber12Side length of a single cell (px)
cellGapnumber3Spacing between cells (px)
radiusnumber2Corner radius of a single cell (px)
waveStaggernumber0.008Delay coefficient per step of the load wave (seconds)
waveDurationnumber0.4How long a single cell takes to light up (seconds)
rowWeightnumber2Weight of the row direction in the wave step count, deciding the diagonal tilt of the wave
animateOnLoadbooleantrueWhether to play the load wave reveal
selectedDatestring | null—Controlled: the currently selected date
defaultSelectedDatestring | nullnullInitially selected date in uncontrolled mode
onSelectDate(date: string | null, day: YearPulseDay | null) => void—Fires when the selection changes
showSidebarbooleantrueWhether to show the linked sidebar list
sidebarTitleReact.ReactNode"Year-Round Dynamics"Sidebar title when nothing is selected — the English source includes this default, so pass this prop to localize it
emptyLabelstring"No Events This Day"Message shown when the day has no events — the English source includes this default, so pass this prop to localize it
renderEvent(event: YearPulseEvent, day: YearPulseDay) => React.ReactNode—Custom rendering of a single sidebar event
localestring"zh-TW"Intl locale, used for formatting months / weekdays / dates
aria-labelstring"<year> Annual Pulse Pattern"Accessible label of the outermost element (the English source includes this default, so pass this prop to localize it)
classNamestring—Appended to the outermost container class
sidebarClassNamestring—Appended to the sidebar container class

YearPulseDay

FieldTypeDescription
datestringISO date (YYYY-MM-DD)
valuenumberIntensity value, deciding the depth of the color level; <= 0 counts as an empty cell
eventsYearPulseEvent[]List of that day's events (optional)

YearPulseEvent

FieldTypeDescription
idstringUnique event id
titlestringEvent title
detailstringSecondary 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 with waveStagger and the per-cell duration with waveDuration; setting animateOnLoad={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-none and its coordinates are computed from a live getBoundingClientRect, 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 an aria-label; each day cell is a button with an aria-label carrying the full date, intensity, and event count, and marks its selected state with aria-pressed
  • It uses a roving tabindex: arrow keys navigate between cells, Home / End jump to the start / end of the year, Enter / Space selects, and Esc clears 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 marked aria-hidden, and every focusable element has a focus-visible outline