WebberUI

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.

Loading preview…
npx shadcn@latest add https://webberui.com/r/year-pulse-grid.json

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
<YearPulseGrid />

Installation

npx shadcn@latest add https://webberui.com/r/year-pulse-grid.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 | nullControlled: the currently selected date
defaultSelectedDatestring | nullnullInitially selected date in uncontrolled mode
onSelectDate(date: string | null, day: YearPulseDay | null) => voidFires when the selection changes
showSidebarbooleantrueWhether to show the linked sidebar list
sidebarTitleReact.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
emptyLabelstring"這一天沒有事件"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.ReactNodeCustom rendering of a single sidebar event
localestring"zh-TW"Intl locale, used for formatting months / weekdays / dates
aria-labelstring"<year> 年度脈動格"Accessible label of the outermost element (the built-in default is in Traditional Chinese, so pass this prop to localize it)
classNamestringAppended to the outermost container class
sidebarClassNamestringAppended 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

On this page