diff --git a/packages/react-notion-x/src/styles.css b/packages/react-notion-x/src/styles.css
index b6f9ec81..bad5735c 100644
--- a/packages/react-notion-x/src/styles.css
+++ b/packages/react-notion-x/src/styles.css
@@ -1813,6 +1813,466 @@ svg.notion-page-icon {
height: 180px;
}
+.notion-calendar-collection {
+ width: 100%;
+ max-width: 100%;
+ align-self: center;
+ box-sizing: border-box;
+ overflow: visible;
+}
+
+.notion-calendar-view {
+ position: relative;
+ padding-left: 0;
+ transition: padding 200ms ease-out;
+ max-width: 100%;
+ width: 100%;
+ overflow-x: auto;
+ overflow-y: visible;
+ box-sizing: border-box;
+}
+
+.notion-calendar-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 0 16px 0;
+ margin-bottom: 0;
+ border-bottom: none;
+}
+
+.notion-calendar-header-title {
+ font-size: 20px;
+ font-weight: 600;
+ color: var(--fg-color);
+ line-height: 1.5;
+}
+
+.notion-calendar-header-controls {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+}
+
+.notion-calendar-header-nav {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.notion-calendar-nav-button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ border: 1px solid rgba(55, 53, 47, 0.16);
+ background: var(--bg-color);
+ border-radius: 3px;
+ cursor: pointer;
+ font-size: 16px;
+ color: var(--fg-color);
+ transition: background 100ms ease-in, border-color 100ms ease-in;
+ user-select: none;
+ padding: 0;
+}
+
+.notion-calendar-nav-button:hover {
+ background: rgba(55, 53, 47, 0.08);
+ border-color: rgba(55, 53, 47, 0.2);
+}
+
+.notion-calendar-nav-button:active {
+ background: rgba(55, 53, 47, 0.12);
+}
+
+.notion-calendar-nav-today-button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 48px;
+ height: 28px;
+ border: 1px solid rgba(55, 53, 47, 0.16);
+ background: var(--bg-color);
+ border-radius: 3px;
+ cursor: pointer;
+ font-size: 14px;
+ color: var(--fg-color);
+ transition: background 100ms ease-in, border-color 100ms ease-in;
+ user-select: none;
+ padding: 0 8px;
+}
+
+.notion-calendar-nav-today-button:hover {
+ background: rgba(55, 53, 47, 0.08);
+ border-color: rgba(55, 53, 47, 0.2);
+}
+
+.notion-calendar-nav-today-button:active {
+ background: rgba(55, 53, 47, 0.12);
+}
+
+.notion-calendar-grid {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ min-width: 0;
+ overflow: visible;
+ box-sizing: border-box;
+}
+
+.notion-calendar-weekdays {
+ display: grid;
+ grid-template-columns: repeat(7, minmax(0, 1fr));
+ gap: 0;
+ margin-bottom: 0;
+ min-width: 0;
+ box-sizing: border-box;
+ border-top: 1px solid var(--fg-color-1);
+ border-left: 1px solid var(--fg-color-1);
+}
+
+.notion-calendar-weekday {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 8px;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--fg-color-3);
+ text-transform: uppercase;
+ border-right: 1px solid var(--fg-color-1);
+ border-bottom: 1px solid var(--fg-color-1);
+ background: var(--bg-color);
+}
+
+.notion-calendar-days {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+ border-top: 1px solid var(--fg-color-1);
+ border-left: 1px solid var(--fg-color-1);
+ position: relative;
+ overflow: visible;
+ min-width: 0;
+ box-sizing: border-box;
+}
+
+.notion-calendar-weeks {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+ overflow: visible;
+}
+
+.notion-calendar-week-row {
+ position: relative;
+ display: grid;
+ grid-template-columns: repeat(7, minmax(0, 1fr));
+ gap: 0;
+ border-top: 1px solid var(--fg-color-1);
+ border-left: 1px solid var(--fg-color-1);
+ overflow: visible;
+ min-width: 0;
+ box-sizing: border-box;
+ isolation: isolate;
+}
+
+.notion-calendar-day {
+ border-right: 1px solid var(--fg-color-1);
+ border-bottom: 1px solid var(--fg-color-1);
+ padding: 6px 4px;
+ background: var(--bg-color);
+ position: relative;
+ overflow: visible;
+ z-index: 0;
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ box-sizing: border-box;
+ height: 100%;
+ clip-path: none;
+}
+
+.notion-calendar-day::-webkit-scrollbar {
+ width: 4px;
+}
+
+.notion-calendar-day::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.notion-calendar-day::-webkit-scrollbar-thumb {
+ background-color: var(--fg-color-1);
+ border-radius: 2px;
+}
+
+.notion-calendar-day::-webkit-scrollbar-thumb:hover {
+ background-color: var(--fg-color-2);
+}
+
+.notion-calendar-day-has-multiday {
+ overflow: visible;
+ z-index: 1;
+ isolation: isolate;
+}
+
+.notion-calendar-day-other-month {
+ background: var(--bg-color);
+ color: var(--fg-color-3);
+}
+
+.notion-calendar-day-today .notion-calendar-day-number {
+ color: #fff;
+ font-weight: 600;
+ background: #ff0000;
+ border-radius: 50%;
+ width: 32px;
+ height: 32px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ margin-bottom: 4px;
+ padding: 0;
+ font-size: 14px;
+}
+
+.notion-calendar-day-number {
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--fg-color);
+ margin-bottom: 2px;
+ padding: 2px 4px;
+ display: inline-block;
+ line-height: 1.2;
+}
+
+.notion-calendar-day-events {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ overflow: visible;
+ position: relative;
+ flex: 1;
+ min-height: 0;
+ padding-top: 0;
+}
+
+.notion-calendar-event-card {
+ position: absolute;
+ display: flex;
+ flex-direction: column;
+ padding: 4px 6px;
+ margin: 0;
+ background: #ffffff;
+ border-radius: 3px;
+ border: 1px solid rgba(55, 53, 47, 0.16);
+ cursor: pointer;
+ transition: background 100ms ease-in, border-color 100ms ease-in,
+ box-shadow 100ms ease-in;
+ text-decoration: none;
+ color: inherit;
+ overflow: visible;
+ gap: 2px;
+ box-sizing: border-box;
+ white-space: normal;
+ word-wrap: break-word;
+ min-height: fit-content;
+ height: auto;
+ width: 100%;
+ max-width: none;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
+ font-size: 12px;
+ line-height: 1.3;
+ z-index: 10;
+ will-change: transform;
+}
+
+.notion-calendar-event-card .notion-collection-card-body {
+ padding: 2px 4px;
+}
+
+.notion-calendar-event-card .notion-collection-card-property {
+ padding: 2px 0;
+ font-size: 11px;
+ line-height: 1.2;
+}
+
+.notion-calendar-event-card .notion-collection-card-property:first-child {
+ font-size: 12px;
+ font-weight: 500;
+ padding: 1px 0;
+}
+
+.notion-calendar-event-card:hover {
+ background: rgba(55, 53, 47, 0.03);
+ border-color: rgba(55, 53, 47, 0.2);
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
+}
+
+.notion-calendar-event-card-wrapper {
+ position: absolute;
+ z-index: 10;
+ overflow: visible;
+ width: 100%;
+ height: auto;
+}
+
+.notion-calendar-event-card-wrapper[data-continuation='true']
+ .notion-calendar-event-card {
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+ border-left: none;
+ padding-left: 8px;
+}
+
+.notion-calendar-event-card-wrapper[data-continues-next-week='true']
+ .notion-calendar-event-card {
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+ padding-right: 8px;
+}
+
+.notion-calendar-event-clip-left {
+ position: absolute;
+ left: -2px;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 0;
+ height: 0;
+ border-top: 6px solid transparent;
+ border-bottom: 6px solid transparent;
+ border-left: 6px solid rgba(55, 53, 47, 0.16);
+ z-index: 2;
+}
+
+.notion-calendar-event-clip-right {
+ position: absolute;
+ right: -2px;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 0;
+ height: 0;
+ border-top: 6px solid transparent;
+ border-bottom: 6px solid transparent;
+ border-right: 6px solid rgba(55, 53, 47, 0.16);
+ z-index: 2;
+}
+
+.notion-calendar-event-resume-right {
+ position: absolute;
+ right: -2px;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 0;
+ height: 0;
+ border-top: 6px solid transparent;
+ border-bottom: 6px solid transparent;
+ border-right: 6px solid #2383e2;
+ z-index: 2;
+}
+
+.notion-calendar-event-card-wrapper[data-continuation='true'] {
+ pointer-events: none;
+}
+
+@media (max-width: 768px) {
+ .notion-calendar-header {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 12px;
+ padding: 12px 0;
+ }
+
+ .notion-calendar-header-title {
+ font-size: 20px;
+ }
+
+ .notion-calendar-day {
+ --multiday-card-height: 40px;
+ --multiday-card-gap: 5px;
+ --multiday-start-offset: 24px;
+ min-height: calc(
+ 100px + var(--multiday-layers, 0) *
+ (var(--multiday-card-height) + var(--multiday-card-gap))
+ );
+ padding: 4px;
+ }
+
+ .notion-calendar-day-number {
+ font-size: 12px;
+ }
+
+ .notion-calendar-weekday {
+ font-size: 11px;
+ padding: 6px 4px;
+ }
+
+ .notion-calendar-event-card {
+ padding: 6px 8px;
+ font-size: 12px;
+ }
+}
+
+@media (max-width: 480px) {
+ .notion-calendar-header-title {
+ font-size: 18px;
+ }
+
+ .notion-calendar-day {
+ --multiday-card-height: 36px;
+ --multiday-card-gap: 4px;
+ --multiday-start-offset: 20px;
+ min-height: calc(
+ 80px + var(--multiday-layers, 0) *
+ (var(--multiday-card-height) + var(--multiday-card-gap))
+ );
+ padding: 3px;
+ }
+
+ .notion-calendar-day-number {
+ font-size: 11px;
+ margin-bottom: 2px;
+ }
+
+ .notion-calendar-day-today .notion-calendar-day-number {
+ width: 28px;
+ height: 28px;
+ font-size: 12px;
+ }
+
+ .notion-calendar-weekday {
+ font-size: 10px;
+ padding: 4px 2px;
+ }
+
+ .notion-calendar-event-card {
+ padding: 4px 6px;
+ gap: 4px;
+ }
+
+ .notion-calendar-event-title {
+ font-size: 11px;
+ }
+
+ .notion-calendar-event-time {
+ font-size: 10px;
+ }
+
+ .notion-calendar-nav-button {
+ width: 24px;
+ height: 24px;
+ font-size: 14px;
+ }
+
+ .notion-calendar-nav-today-button {
+ min-width: 40px;
+ height: 24px;
+ font-size: 12px;
+ padding: 0 6px;
+ }
+}
+
.notion-collection-page-properties {
width: 100%;
display: flex;
@@ -2668,9 +3128,6 @@ svg.notion-page-icon {
margin-bottom: 1em;
}
-.notion-collection-group > summary {
-}
-
.notion-collection-group > summary > div {
transform: scale(0.85);
transform-origin: 0% 50%;
@@ -3079,6 +3536,7 @@ svg.notion-page-icon {
color: var(--fg-color);
display: -webkit-box;
-webkit-line-clamp: 2;
+ line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
margin: 0;
diff --git a/packages/react-notion-x/src/third-party/collection-view-calendar.tsx b/packages/react-notion-x/src/third-party/collection-view-calendar.tsx
new file mode 100644
index 00000000..144267c3
--- /dev/null
+++ b/packages/react-notion-x/src/third-party/collection-view-calendar.tsx
@@ -0,0 +1,223 @@
+import {
+ addMonths,
+ eachDayOfInterval,
+ endOfMonth,
+ format,
+ startOfMonth,
+ startOfWeek,
+ subMonths
+} from 'date-fns'
+import { type PageBlock } from 'notion-types'
+import { getDateValue } from 'notion-utils'
+import React from 'react'
+
+import { useNotionContext } from '../context'
+import { type CollectionViewProps } from '../types'
+import { CalendarWeekRow } from './collection-week-row'
+
+const defaultBlockIds: string[] = []
+
+export function CollectionViewCalendar({
+ collection,
+ collectionView,
+ collectionData
+}: CollectionViewProps) {
+ const { recordMap } = useNotionContext()
+ const [currentMonth, setCurrentMonth] = React.useState(new Date())
+
+ const blockIds =
+ (collectionData.collection_group_results?.blockIds ??
+ collectionData.blockIds) ||
+ defaultBlockIds
+
+ const datePropertyId = React.useMemo(() => {
+ const calendarBy = (collectionView as any)?.query2?.calendar_by
+ if (calendarBy) {
+ const schema = collection.schema
+ const propertySchema = schema[calendarBy]
+ if (propertySchema && propertySchema.type === 'date') {
+ return calendarBy
+ }
+ }
+
+ const schema = collection.schema
+ for (const [propertyId, propertySchema] of Object.entries(schema)) {
+ if (propertySchema.type === 'date') {
+ return propertyId
+ }
+ }
+ return null
+ }, [collection.schema, collectionView])
+
+ const eventsByDate = React.useMemo(() => {
+ const eventsMap = new Map<
+ string,
+ Array<{ block: PageBlock; dateRange: { start: Date; end: Date } | null }>
+ >()
+
+ if (!datePropertyId) {
+ return eventsMap
+ }
+
+ for (const blockId of blockIds) {
+ const block = recordMap.block[blockId]?.value as PageBlock
+ if (!block) continue
+
+ const dateProperty =
+ block.properties?.[datePropertyId as keyof typeof block.properties]
+ if (!dateProperty) continue
+
+ const dateValue = getDateValue(dateProperty as any[])
+ if (!dateValue) continue
+
+ let startDate: Date
+ let endDate: Date | null = null
+ let dateRange: { start: Date; end: Date } | null = null
+
+ if (dateValue.type === 'date' || dateValue.type === 'datetime') {
+ startDate = new Date(dateValue.start_date)
+ endDate = null
+ } else if (
+ dateValue.type === 'daterange' ||
+ dateValue.type === 'datetimerange'
+ ) {
+ startDate = new Date(dateValue.start_date)
+ endDate = dateValue.end_date ? new Date(dateValue.end_date) : null
+ if (endDate) {
+ dateRange = { start: startDate, end: endDate }
+ }
+ } else {
+ continue
+ }
+
+ if (dateRange && endDate) {
+ const allDays = eachDayOfInterval({
+ start: startDate,
+ end: endDate
+ })
+ for (const day of allDays) {
+ const dateKey = format(day, 'yyyy-MM-dd')
+ if (!eventsMap.has(dateKey)) {
+ eventsMap.set(dateKey, [])
+ }
+ eventsMap.get(dateKey)!.push({ block, dateRange })
+ }
+ } else {
+ const dateKey = format(startDate, 'yyyy-MM-dd')
+ if (!eventsMap.has(dateKey)) {
+ eventsMap.set(dateKey, [])
+ }
+ eventsMap.get(dateKey)!.push({ block, dateRange: null })
+ }
+ }
+
+ return eventsMap
+ }, [blockIds, datePropertyId, recordMap])
+
+ const calendarDays = React.useMemo(() => {
+ const weekStartsOn = 1 as 0 | 1 | 2 | 3 | 4 | 5 | 6
+ const monthStart = startOfMonth(currentMonth)
+ const monthEnd = endOfMonth(currentMonth)
+ const calendarStart = startOfWeek(monthStart, { weekStartsOn })
+ const calendarEnd = startOfWeek(monthEnd, { weekStartsOn })
+ const endDate = new Date(calendarEnd)
+ endDate.setDate(endDate.getDate() + 6)
+
+ return eachDayOfInterval({
+ start: calendarStart,
+ end: endDate
+ })
+ }, [currentMonth])
+
+ const goToPreviousMonth = () => {
+ setCurrentMonth(subMonths(currentMonth, 1))
+ }
+
+ const goToNextMonth = () => {
+ setCurrentMonth(addMonths(currentMonth, 1))
+ }
+
+ const goToToday = () => {
+ setCurrentMonth(new Date())
+ }
+
+ const today = new Date()
+
+ const weeks = React.useMemo(() => {
+ const weekArray: Date[][] = []
+ for (let i = 0; i < calendarDays.length; i += 7) {
+ weekArray.push(calendarDays.slice(i, i + 7))
+ }
+ return weekArray
+ }, [calendarDays])
+
+ return (
+
+
+
+
+ {format(currentMonth, 'MMMM yyyy')}
+
+
+
+
+
+
+
+
+
+
+
+ {Array.from({ length: 7 }, (_, i) => {
+ const dayIndex = (i + 1) % 7
+ const date = new Date(2024, 0, 7 + dayIndex)
+ const dayName = format(date, 'EEEEEE')
+ return (
+
+ {dayName}
+
+ )
+ })}
+
+
+ {weeks.map((weekDays, weekIndex) => (
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/packages/react-notion-x/src/third-party/collection-view.tsx b/packages/react-notion-x/src/third-party/collection-view.tsx
index 378550dc..44173a70 100644
--- a/packages/react-notion-x/src/third-party/collection-view.tsx
+++ b/packages/react-notion-x/src/third-party/collection-view.tsx
@@ -2,6 +2,7 @@ import React from 'react'
import { type CollectionViewProps } from '../types'
import { CollectionViewBoard } from './collection-view-board'
+import { CollectionViewCalendar } from './collection-view-calendar'
import { CollectionViewGallery } from './collection-view-gallery'
import { CollectionViewList } from './collection-view-list'
import { CollectionViewTable } from './collection-view-table'
@@ -22,6 +23,9 @@ export function CollectionViewImpl(props: CollectionViewProps) {
case 'board':
return
+ case 'calendar':
+ return
+
default:
console.warn('unsupported collection view', collectionView)
return null
diff --git a/packages/react-notion-x/src/third-party/collection-week-row.tsx b/packages/react-notion-x/src/third-party/collection-week-row.tsx
new file mode 100644
index 00000000..5b1b73e4
--- /dev/null
+++ b/packages/react-notion-x/src/third-party/collection-week-row.tsx
@@ -0,0 +1,703 @@
+import { format, getDay, isSameDay, isSameMonth } from 'date-fns'
+import { type PageBlock } from 'notion-types'
+import React from 'react'
+
+import { cs } from '../utils'
+import { CollectionCard } from './collection-card'
+
+const CARD_GAP = 8
+const CARD_HORIZONTAL_PADDING = 4
+const DAY_BORDER_WIDTH = 1
+
+export function CalendarWeekRow({
+ weekIndex,
+ weekDays,
+ calendarDays,
+ eventsByDate,
+ collection,
+ collectionView,
+ datePropertyId,
+ currentMonth,
+ today
+}: {
+ weekIndex: number
+ weekDays: Date[]
+ calendarDays: Date[]
+ eventsByDate: Map<
+ string,
+ Array<{ block: PageBlock; dateRange: { start: Date; end: Date } | null }>
+ >
+ collection: any
+ collectionView: any
+ datePropertyId: string | null
+ currentMonth: Date
+ today: Date
+}) {
+ const weekRef = React.useRef(null)
+ const cardRefs = React.useRef