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.
↓Scroll to step through it
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%.
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.
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.
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.
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.
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.
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]"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.
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]"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%.
// 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; }}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.
{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}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.
// 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;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.
// 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 */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.
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
| Interaction | Keyframe / mechanism | Properties | Easing | Duration |
|---|---|---|---|---|
| Next / Previous: old weeks | godui-calendar-out-to-start / -end on React DayPicker's clone, to 12% | translate, opacity | ease-out quad | 150ms |
| Next / Previous: new weeks | godui-calendar-in-from-end / -start, from 25%, after 20ms | translate, opacity | ease-spring-snappy | 260ms |
| Next / Previous: caption | godui-calendar-caption-out-* (gone by 30%), then godui-calendar-caption-in-* from 14px | translate, opacity | ease-out quad / ease-spring-snappy | 280ms / 220ms |
| Next / Previous: dropdown caption | the old one hidden (godui-calendar-caption-hide), the new one shown at once | opacity | none | 280ms |
| Pick a day | godui-calendar-fill-in on the fill layer, from 60% | scale, opacity | ease-spring-bouncy | 260ms |
| The day you leave | godui-calendar-fill-shrink on its fill, godui-calendar-fade-out on the layer | scale to 85%, opacity | ease-out-expo | 150ms |
| Range track | godui-calendar-track-in, per half-cell, back to back from the part already drawn | scale (x) | linear slices of an ease-out cubic | 240ms in all |
| Range track leaving | godui-calendar-fade-out | opacity | ease-out-expo | 150ms |
| Range preview: track | godui-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 cubic | 160ms in all |
| Range preview: ghost end appears | godui-calendar-fill-in on the ghost layer, from 60% | scale, opacity | ease-spring-bouncy | 260ms |
| Range preview: ghost end moves along a row | godui-calendar-ghost-glide from the previous end (snaps across rows and across the anchor) | translate | ease-out cubic, with the track's front | 160ms |
| Range preview: leaving the grid (after 80ms), Escape | godui-calendar-fade-out on the track and the ghost | opacity | ease-out-expo | 150ms |
| Range preview: commit | the range's tint (::after) fades in over the preview's; the end pops; the ghost fades | opacity, scale | ease-out / ease-spring-bouncy | 150ms / 260ms |
| Hover | ::after overlay | opacity | ease-out | 100ms |
| Focus ring | ::before ring, fades in entering the grid, jumps on arrow keys | opacity, scale from 98% | ease-out-expo | 150ms |
| Weekdays, nav buttons, month height | static / snap | none | none | none |
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:
| Variable | Default | What it sets |
|---|---|---|
--godui-calendar-drift | 25% | How far the new weeks travel in, as a share of their width (100% is a full page turn) |
--godui-calendar-drift-out | 12% | How far the old weeks travel out |
--godui-calendar-caption-drift | 14px | The caption's trip in (it leaves by half of it) |
--godui-calendar-delay | 20ms | The new month's beat after the old one starts leaving |
--godui-calendar-caption-lag | 40ms | How much later than the weeks the new caption starts (it ends with them) |
--godui-calendar-ease-out | cubic-bezier(0.25,0.46,0.45,0.94) | The old weeks' exit curve |
--godui-calendar-sweep | 240ms | How long a range's track takes to draw |
--godui-calendar-preview-sweep | 160ms | How long a hover preview's track takes to draw, and the ghost end's glide |
<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.