Skip to content

Conversation

@devongovett
Copy link
Member

@devongovett devongovett commented Oct 22, 2025

(Opening for team discussion)

This started as an idea for a docs example: a calendar where you could swipe to navigate between months like the native iOS calendar. It is possible to build today with our existing API: https://stackblitz.com/edit/rac-swipeable-calendar?file=src%2FCalendar.tsx. But then I thought: why not just add this as a built-in component?

The new CalendarCarousel component (name TBD) uses CSS scroll snapping to allow native swipe gestures between months. The current month is centered in the viewport, with one extra month on either side, along with a placeholder div that takes up a large number of pages.

image

As you swipe, the months update and the placeholder shrinks giving the illusion of infinite pages. When you stop scrolling, the scroll position is re-centered and the placeholders become equal size again. This avoids changing the scroll positioning while the user is scrolling, which causes jumpiness. The placeholders are large to make it unlikely that the user will reach the end without stopping.

Questions

  • Do we want to add this component?
  • What should we call it?
  • Should it somehow respect pageBehavior?
  • Are the built-in inline styles too specific? We haven't had a component like this before where some styles are set by us.
  • Inline comments

{date => <CalendarCell date={date} />}
</CalendarGridBody>
</CalendarGrid>
<CalendarCarousel> (optional)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we show that this component is optional? I guess we don't do this anywhere else, but I'm not sure we have other optional components that accept children?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this could make sense for Virtualizer in some examples, e.g. Virtualized Select, to illustrate where the Virtualizer goes.

{/* contain: 'inline-size' makes these extra pages not affect the width of the parent */}
<div inert style={{width: '100%', flexShrink: 0, contain: 'inline-size', scrollSnapAlign: 'start', scrollSnapStop: 'always'}}>
<CalendarGridContext.Provider value={{offset: {months: -state.visibleDuration.months!}}}>
{props.children}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that this duplicates the children 3 times, and uses context to offset the months

* can be keyboard navigated and selected by the user.
*/
export const CalendarGrid = /*#__PURE__*/ (forwardRef as forwardRefType)(function CalendarGrid(props: CalendarGridProps, ref: ForwardedRef<HTMLTableElement>) {
// Merge offset from context with props.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Weird? Otherwise I have to add a render prop function to the CalendarCarousel, pass in the offset, and make the user apply it manually.

Copy link
Contributor

@nwidynski nwidynski Oct 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have precedence of doing something similar to this for layoutOptions, so I think fine 👍

@rspbot
Copy link

rspbot commented Oct 22, 2025

@rspbot
Copy link

rspbot commented Oct 22, 2025

@rspbot
Copy link

rspbot commented Oct 22, 2025

## API Changes

react-aria-components

/react-aria-components:CalendarState

 CalendarState {
   focusNextDay: () => void
   focusNextPage: () => void
   focusNextRow: () => void
   focusNextSection: (boolean) => void
   focusPreviousDay: () => void
   focusPreviousPage: () => void
   focusPreviousRow: () => void
   focusPreviousSection: (boolean) => void
   focusSectionEnd: () => void
   focusSectionStart: () => void
   focusedDate: CalendarDate
   getDatesInWeek: (number, CalendarDate) => Array<CalendarDate | null>
   isCellDisabled: (CalendarDate) => boolean
   isCellFocused: (CalendarDate) => boolean
   isCellUnavailable: (CalendarDate) => boolean
   isDisabled: boolean
   isFocused: boolean
   isInvalid: (CalendarDate) => boolean
   isNextVisibleRangeInvalid: () => boolean
   isPreviousVisibleRangeInvalid: () => boolean
   isReadOnly: boolean
   isSelected: (CalendarDate) => boolean
   isValueInvalid: boolean
   maxValue?: DateValue | null
   minValue?: DateValue | null
   selectDate: (CalendarDate) => void
   selectFocusedDate: () => void
   setFocused: (boolean) => void
-  setFocusedDate: (CalendarDate) => void
+  setFocusedDate: (CalendarDate, 'start' | 'center' | 'end') => void
   setValue: (CalendarDate | null) => void
   timeZone: string
   value: CalendarDate | null
+  visibleDuration: DateDuration
   visibleRange: RangeValue<CalendarDate>
 }

/react-aria-components:RangeCalendarState

 RangeCalendarState <T extends DateValue = DateValue> {
   anchorDate: CalendarDate | null
   focusNextDay: () => void
   focusNextPage: () => void
   focusNextRow: () => void
   focusNextSection: (boolean) => void
   focusPreviousDay: () => void
   focusPreviousPage: () => void
   focusPreviousRow: () => void
   focusPreviousSection: (boolean) => void
   focusSectionEnd: () => void
   focusSectionStart: () => void
   focusedDate: CalendarDate
   getDatesInWeek: (number, CalendarDate) => Array<CalendarDate | null>
   highlightDate: (CalendarDate) => void
   highlightedRange: RangeValue<CalendarDate> | null
   isCellDisabled: (CalendarDate) => boolean
   isCellFocused: (CalendarDate) => boolean
   isCellUnavailable: (CalendarDate) => boolean
   isDisabled: boolean
   isDragging: boolean
   isFocused: boolean
   isInvalid: (CalendarDate) => boolean
   isNextVisibleRangeInvalid: () => boolean
   isPreviousVisibleRangeInvalid: () => boolean
   isReadOnly: boolean
   isSelected: (CalendarDate) => boolean
   isValueInvalid: boolean
   maxValue?: DateValue | null
   minValue?: DateValue | null
   selectDate: (CalendarDate) => void
   selectFocusedDate: () => void
   setAnchorDate: (CalendarDate | null) => void
   setDragging: (boolean) => void
   setFocused: (boolean) => void
-  setFocusedDate: (CalendarDate) => void
+  setFocusedDate: (CalendarDate, 'start' | 'center' | 'end') => void
   setValue: (RangeValue<DateValue> | null) => void
   timeZone: string
   value: RangeValue<DateValue> | null
+  visibleDuration: DateDuration
   visibleRange: RangeValue<CalendarDate>
 }

/react-aria-components:CalendarCarousel

+CalendarCarousel {
+  children: ReactNode
+  className?: string = 'react-aria-CalendarCarousel'
+  style?: CSSProperties
+}

/react-aria-components:CalendarCarouselProps

+CalendarCarouselProps {
+  children: ReactNode
+  className?: string = 'react-aria-CalendarCarousel'
+  style?: CSSProperties
+}

@react-stately/calendar

/@react-stately/calendar:CalendarState

 CalendarState {
   focusNextDay: () => void
   focusNextPage: () => void
   focusNextRow: () => void
   focusNextSection: (boolean) => void
   focusPreviousDay: () => void
   focusPreviousPage: () => void
   focusPreviousRow: () => void
   focusPreviousSection: (boolean) => void
   focusSectionEnd: () => void
   focusSectionStart: () => void
   focusedDate: CalendarDate
   getDatesInWeek: (number, CalendarDate) => Array<CalendarDate | null>
   isCellDisabled: (CalendarDate) => boolean
   isCellFocused: (CalendarDate) => boolean
   isCellUnavailable: (CalendarDate) => boolean
   isDisabled: boolean
   isFocused: boolean
   isInvalid: (CalendarDate) => boolean
   isNextVisibleRangeInvalid: () => boolean
   isPreviousVisibleRangeInvalid: () => boolean
   isReadOnly: boolean
   isSelected: (CalendarDate) => boolean
   isValueInvalid: boolean
   maxValue?: DateValue | null
   minValue?: DateValue | null
   selectDate: (CalendarDate) => void
   selectFocusedDate: () => void
   setFocused: (boolean) => void
-  setFocusedDate: (CalendarDate) => void
+  setFocusedDate: (CalendarDate, 'start' | 'center' | 'end') => void
   setValue: (CalendarDate | null) => void
   timeZone: string
   value: CalendarDate | null
+  visibleDuration: DateDuration
   visibleRange: RangeValue<CalendarDate>
 }

/@react-stately/calendar:RangeCalendarState

 RangeCalendarState <T extends DateValue = DateValue> {
   anchorDate: CalendarDate | null
   focusNextDay: () => void
   focusNextPage: () => void
   focusNextRow: () => void
   focusNextSection: (boolean) => void
   focusPreviousDay: () => void
   focusPreviousPage: () => void
   focusPreviousRow: () => void
   focusPreviousSection: (boolean) => void
   focusSectionEnd: () => void
   focusSectionStart: () => void
   focusedDate: CalendarDate
   getDatesInWeek: (number, CalendarDate) => Array<CalendarDate | null>
   highlightDate: (CalendarDate) => void
   highlightedRange: RangeValue<CalendarDate> | null
   isCellDisabled: (CalendarDate) => boolean
   isCellFocused: (CalendarDate) => boolean
   isCellUnavailable: (CalendarDate) => boolean
   isDisabled: boolean
   isDragging: boolean
   isFocused: boolean
   isInvalid: (CalendarDate) => boolean
   isNextVisibleRangeInvalid: () => boolean
   isPreviousVisibleRangeInvalid: () => boolean
   isReadOnly: boolean
   isSelected: (CalendarDate) => boolean
   isValueInvalid: boolean
   maxValue?: DateValue | null
   minValue?: DateValue | null
   selectDate: (CalendarDate) => void
   selectFocusedDate: () => void
   setAnchorDate: (CalendarDate | null) => void
   setDragging: (boolean) => void
   setFocused: (boolean) => void
-  setFocusedDate: (CalendarDate) => void
+  setFocusedDate: (CalendarDate, 'start' | 'center' | 'end') => void
   setValue: (RangeValue<DateValue> | null) => void
   timeZone: string
   value: RangeValue<DateValue> | null
+  visibleDuration: DateDuration
   visibleRange: RangeValue<CalendarDate>
 }

Comment on lines +362 to +369
clearTimeout(timeout.current);
timeout.current = setTimeout(() => {
let index = el.scrollLeft / el.offsetWidth;
if (Math.abs(Math.round(index) - index) < 0.01) {
dispatch({type: 'SCROLL_END', visibleMonths: state.visibleDuration.months!});
}
}, 500);
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could probably work the same performance optimization here as in useScrollView, aka. rescheduling only when getting close to the end of the interval?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants