GodUIGodUI
129Follow on X

Anatomy of the GodUI Calendar

How the months drift past each other on React DayPicker's own animation hooks, why the caption sets the clock, how a picked day's fill carries its own number, how a range sweeps in the same time at any length, and how a hover preview of the range is committed without redrawing it.

01/06Two months, two speeds
tsx
weeks_after_enter: "animate-godui-calendar-in-from-end",weeks_before_exit: "animate-godui-calendar-out-to-start",@keyframes godui-calendar-in-from-end {from {  opacity: 0;  translate: calc(var(--godui-calendar-drift) * var(--godui-calendar-dir) * var(--godui-motion)) 0;}}/* in:  260ms, ease-spring-snappy, after --godui-calendar-delay out: 150ms, ease-out quad, to --godui-calendar-drift-out */// on the root"[--godui-calendar-drift:25%] [--godui-calendar-drift-out:12%][--godui-calendar-delay:20ms] [--godui-calendar-dir:1]rtl:[--godui-calendar-dir:-1]"
01

Two months, two speeds

React DayPicker already animates a month change; it just ships without a look. When the month changes it clones the old month, pins the clone over the new one with position: absolute, clips both with overflow: hidden and adds a class to each: an exit class on the clone's weeks and caption, an enter class on the new ones. GodUI fills those class names with its own keyframes.

The two months don't move as one strip. The old weeks leave first, fast and short: 150ms, 12% of their width. The new weeks start 20ms later from a quarter of the width away and settle on a spring in 260ms. Going forward both travel toward the start; RTL flips the sign. A quarter width keeps the direction readable without the page-turn blur of a full slide, and the exit, shorter and quicker than the enter, leaves room for the new month.

The exit fades on an ease-out quad rather than ease-out-expo. Expo empties the old month before the new one is there: the two together drop to 16% opacity for a few frames, which reads as a blink. On the quad they stay above 65%.

02/06The caption sets the clock
tsx
// react-day-picker, useAnimationpreviousCaptionEl.addEventListener("animationend", cleanUp);--animate-godui-calendar-caption-out-to-start:godui-calendar-caption-out-to-startcalc(var(--godui-duration-base) + var(--godui-calendar-delay) * var(--godui-motion))linear both;@keyframes godui-calendar-caption-out-to-start {0% { opacity: 1; translate: 0 0; animation-timing-function: … }30%, 100% { opacity: 0; translate: calc(var(--godui-calendar-caption-drift) * -0.5 …) 0; }}
02

The caption sets the clock

React DayPicker removes the clone, and strips the enter classes from the new month, when the old caption fires animationend. Whatever the weeks are doing at that moment gets cut. So the old caption's exit lasts exactly as long as the new weeks' delay plus their enter, and does all of its fading in the first 30%, then holds. It must be the only animation in the caption: a child's animationend would bubble up and trigger the cleanup early.

The caption is the depth cue. It drifts 14px where the grid drifts a quarter width, so it reads as a layer further back. And because two captions on top of each other are just jumbled text, it doesn't cross-fade the way the grid does: the old one is gone before the new one starts, 40ms after the weeks, and both end together.

With captionLayout="dropdown" the caption holds the month and year selects, which shouldn't fade or move with the page. The new caption shows at once and the old one is hidden for the same duration, so it still ends in the animationend the cleanup needs.

03/06A fill with its own number
tsx
{fillLayer ? (  // filled, or still leaving<span  aria-hidden="true"  data-calendar-layer="fill"  data-state={filled ? "on" : "off"}  data-animate={motion.fillAnimate || undefined}  onAnimationEnd={settleFill} // comes to rest, or goes  className={cn(    "absolute inset-0 isolate flex … text-primary-foreground     before:absolute before:inset-0 before:-z-10 before:bg-primary",    settled && "-z-10", // at rest: under the number, over the track    filled      ? "…:data-animate:animate-godui-calendar-fill-in"      : "opacity-0 …:data-animate:animate-godui-calendar-fade-out         …:data-animate:before:animate-godui-calendar-fill-shrink",  )}>  {motion.fillMoving ? children : null}</span>) : null}
03

A fill with its own number

A picked day turns from dark text on nothing into light text on a dark fill. Colour can't animate on the GPU, and switching the number to the light colour at the start would show white text on a fill that hasn't arrived yet. So the fill is a layer over the day button that carries its own copy of the number in the light colour. It grows from 60% on a bouncy spring and fades in over its first third, while the button's own number under it keeps the unselected colour. When the pop ends, the layer drops under the number (still above the range's track), the copy goes and the number turns light: the same pixels, with the number in the DOM once. The copy is aria-hidden.

The day you leave lifts its layer back over the number with a copy for its exit: the fill shrinks to 85% while the layer fades out in 150ms, then it unmounts. If the animation never runs (your CSS cancels it), the layer settles at once. Only the fill shrinks, not the copy, so the copy never drifts out of line with the real number under it.

Nothing pops on first paint or when you page to a month: each day remembers what it last drew, and React DayPicker mounts every day of a newly shown month fresh. The clone of the old month copies the attributes too, so the animation is switched off inside it.

04/06The range sweeps
tsx
// when an ease-out-cubic front reaches xconst reachedAt = (x: number) => 1 - (1 - x) ** (1 / 3);function sweep(unit, first, last, forward): Wave {const length = last - first + 1;const index = forward ? unit - first : last - unit;const at = reachedAt(index / length);return { at, span: reachedAt((index + 1) / length) - at, forward };}--animate-godui-calendar-track-in: godui-calendar-track-incalc(var(--godui-calendar-track-span) * var(--godui-calendar-sweep) …)linearcalc(var(--godui-calendar-track-at) * var(--godui-calendar-sweep) …)backwards;
04

The range sweeps

Each day button holds two half-cell strips of the range's accent: the half toward earlier days and the half toward later ones. The range's ends only have their inner half, under the pill. A strip grows across with scaleX, from the side the sweep comes from.

Only the new part of a range sweeps, outward from the part already drawn. Picking a second day sweeps away from the first, and pulling the start earlier sweeps backward. The strips run back to back, each linear over its own slice of one ease-out curve, so the front is a single edge that starts fast and slows into the day you picked. The slices are fractions of one sweep duration, so a three-day range and a three-week one, across the month boundary, both take 240ms. Strips that leave fade out.

The fade-out is a keyframe, not a transition. After an opacity transition on an element, Chrome won't composite a later opacity keyframe on that element, so a strip that faded out could never sweep back in on the GPU.

05/06A preview under the pointer
tsx
// What a click on the hovered day would select, by React DayPicker's rulesconst next = addToRange(target.date, range, min, max, required);if (excludeDisabled && rangeContainsModifiers(next, disabled)) return null;// ...plus the ghost end's outer half (the cap)return { ...spanOf(next), cap: next.to === target ? "to" : "from" };@keyframes godui-calendar-ghost-glide {from {  translate: calc(var(--godui-calendar-ghost-from) * 100%    * var(--godui-calendar-dir) * var(--godui-motion)) 0;}}/* --godui-calendar-preview-sweep (160ms), ease-out cubic: the front's */
05

A preview under the pointer

Once a range's first day is picked, the day under the pointer (or the focus) draws the range a click there would make. It asks React DayPicker's own range rules, so with min, max or excludeDisabled a day that would start the range over previews nothing. It's drawn with the same track halves and the same sweep as a picked range, on a quicker clock. Hairline edges in the ghost pill's colour outline a lighter tint, because in light mode the tint alone is too close to the range's accent to tell apart. As the pointer moves on, only the halves newly covered sweep out from the part already drawn, and the ones it leaves fade.

The end is a ghost pill. It pops in when the preview starts; along a row it glides from the previous end with the same duration and ease-out curve as the track's front, and the track runs on under the ghost's outer half, so the pill's leading edge rides the front. Across rows, and across the first day, it jumps: a slide over other weeks would read as a zip.

Clicking commits what's already drawn. The halves keep their classes and timing, so nothing sweeps again; the range's accent, a pseudo-element hidden under the preview's tint, fades in over the tint and its edges while the end pops. Leaving the grid fades the preview out where it is, after an 80ms grace in case the pointer is crossing to the other month. The hovered day lives in a small store outside React DayPicker, and every day carries its layers from the start, so a pointer crossing the grid re-renders the days and changes attributes, never the DOM. Chrome traces caught two layouts that came back: an absolute pseudo-element inside a sweeping half lays out every frame, and an opacity reaching 1 drops a paint layer, so the accent is an in-flow block with isolation: isolate.

06/06Result
October 2026
November 2026
06

The result

The first day is picked. Move over the others to preview the range, click to commit it, then click its start to pick a new one. Page through the months; arrow keys preview from the focused day and move the focus ring without a fade.

What's animated

InteractionKeyframe / mechanismPropertiesEasingDuration
Next / Previous: old weeksgodui-calendar-out-to-start / -end on React DayPicker's clone, to 12%translate, opacityease-out quad150ms
Next / Previous: new weeksgodui-calendar-in-from-end / -start, from 25%, after 20mstranslate, opacityease-spring-snappy260ms
Next / Previous: captiongodui-calendar-caption-out-* (gone by 30%), then godui-calendar-caption-in-* from 14pxtranslate, opacityease-out quad / ease-spring-snappy280ms / 220ms
Next / Previous: dropdown captionthe old one hidden (godui-calendar-caption-hide), the new one shown at onceopacitynone280ms
Pick a daygodui-calendar-fill-in on the fill layer, from 60%scale, opacityease-spring-bouncy260ms
The day you leavegodui-calendar-fill-shrink on its fill, godui-calendar-fade-out on the layerscale to 85%, opacityease-out-expo150ms
Range trackgodui-calendar-track-in, per half-cell, back to back from the part already drawnscale (x)linear slices of an ease-out cubic240ms in all
Range track leavinggodui-calendar-fade-outopacityease-out-expo150ms
Range preview: trackgodui-calendar-track-in, a lighter tint with hairline edges, from the part already drawn (from the anchor at first)scale (x)linear slices of an ease-out cubic160ms in all
Range preview: ghost end appearsgodui-calendar-fill-in on the ghost layer, from 60%scale, opacityease-spring-bouncy260ms
Range preview: ghost end moves along a rowgodui-calendar-ghost-glide from the previous end (snaps across rows and across the anchor)translateease-out cubic, with the track's front160ms
Range preview: leaving the grid (after 80ms), Escapegodui-calendar-fade-out on the track and the ghostopacityease-out-expo150ms
Range preview: committhe range's tint (::after) fades in over the preview's; the end pops; the ghost fadesopacity, scaleease-out / ease-spring-bouncy150ms / 260ms
Hover::after overlayopacityease-out100ms
Focus ring::before ring, fades in entering the grid, jumps on arrow keysopacity, scale from 98%ease-out-expo150ms
Weekdays, nav buttons, month heightstatic / snapnonenonenone

React DayPicker removes the old month on its caption's animationend, so the old caption's exit lasts until the new weeks land (delay + 260ms), with its fade done in the first 30%. A second Next during a change moves to the next month without a second animation, and a month change made from the keyboard doesn't animate.

There's no blur: Chrome won't composite a filter that moves pixels, so a blurred month change would run on the main thread.

A month with six weeks after one with five makes the calendar a row taller at once; nothing animates the height. Pass fixedWeeks to always show six weeks and keep the height fixed.

The range preview is visual only: React DayPicker's selection, aria-selected and modifiers don't change until you click, and your onDayMouseEnter, onDayFocus and other handlers still run. It follows React DayPicker's own rules for what a click would select, so with min, max, excludeDisabled or resetOnSelect a day that would start the range over shows no preview, and a disabled day never ends one. Moving between days only changes attributes on layers every day already has: no layout, and only the days re-render, not the picker. Crossing a row gap or from one month's grid to the other keeps the preview; leaving the grid ends it after an 80ms grace (time to cross the gap between the two months).

Tuning

Set these on the root className to retune the month change and the range sweep. They're inherited custom properties that the weeks, the caption and the days all read, so the root is the one place that reaches every part. Set on classNames.weeks or classNames.month_caption, a variable changes only that part, and the weeks and caption can fall out of step:

VariableDefaultWhat it sets
--godui-calendar-drift25%How far the new weeks travel in, as a share of their width (100% is a full page turn)
--godui-calendar-drift-out12%How far the old weeks travel out
--godui-calendar-caption-drift14pxThe caption's trip in (it leaves by half of it)
--godui-calendar-delay20msThe new month's beat after the old one starts leaving
--godui-calendar-caption-lag40msHow much later than the weeks the new caption starts (it ends with them)
--godui-calendar-ease-outcubic-bezier(0.25,0.46,0.45,0.94)The old weeks' exit curve
--godui-calendar-sweep240msHow long a range's track takes to draw
--godui-calendar-preview-sweep160msHow long a hover preview's track takes to draw, and the ghost end's glide
tsx
 
<Calendar className="[--godui-calendar-drift:100%] [--godui-calendar-drift-out:100%]" />

Why GPU-only

The weeks and caption animate translate and opacity, the day layers scale and opacity, hover and the focus ring opacity and scale on pseudo-elements. React DayPicker writes position and overflow once when a month change starts and clears them when it ends; nothing in between needs layout or paint. There's no blur because a filter that moves pixels doesn't composite. A month with one more week makes the calendar a row taller at once; pass fixedWeeks to keep six rows. A range preview moving from day to day lays out nothing at all.

Reduced motion

Every distance, scale and delay is multiplied by --godui-motion, which is 0 under prefers-reduced-motion. The months cross-fade in 150ms without travelling, a picked day's fill fades in at full size, the range's track fades in all at once, a range preview fades in and out with its ghost end jumping from day to day, and the focus ring fades without growing.

Replacing shadcn

npx shadcn add @godui/calendar overwrites components/ui/calendar.tsx. Exports, props and data-slot attributes match shadcn/ui new-york-v4, so existing imports keep working. It also installs godui-motion (easings, keyframes, the FLIP hook) into your project and the GodUI button the day cells and nav buttons are built on.

Two additive changes: animate (React DayPicker's own prop) defaults to true, so pass animate={false} to turn the month change off; and Root, Chevron and WeekNumber are declared once at module level instead of inline, so a controlled re-render doesn't remount the grid. Your own classNames and components still override GodUI's, key by key: weeks_after_enter and the other enter/exit keys replace the animation itself. The timing lives in the keyframes and in a few variables on the root, so overriding weeks or month_caption restyles them without breaking the month change.

The day button draws shadcn's fills on layers instead of its own background: the selected fill, the range's accent track, the hover tint and the focus ring. The selected fill carries its own copy of the number only while it pops in or shrinks away; at rest it lies under the button's number and the copy is gone, so a day's text is its number once and queries like getByText("14") keep working. At rest it looks the same as shadcn's, pixel for pixel; in RTL the accent behind a range's ends sits on the inner side, where shadcn draws it on the outer one. The one other visible difference: in dark mode, hovering a selected day keeps its fill, as it does in light mode.