Roadmap
React roadmap components with timeline visualization. Build product roadmaps, project timelines, and feature releases with Gantt charts, TypeScript, and shadcn/ui.
Loading component...
'use client';import { faker } from '@faker-js/faker';import { CalendarBody, CalendarDate, CalendarDatePagination, CalendarDatePicker, CalendarHeader, CalendarItem, CalendarMonthPicker, CalendarProvider, CalendarYearPicker,} from '@/components/ui/shadcn-io/calendar';import { GanttCreateMarkerTrigger, GanttFeatureItem, GanttFeatureList, GanttFeatureListGroup, GanttHeader, GanttMarker, GanttProvider, GanttSidebar, GanttSidebarGroup, GanttSidebarItem, GanttTimeline, GanttToday,} from '@/components/ui/shadcn-io/gantt';import { KanbanBoard, KanbanCard, KanbanCards, KanbanHeader, KanbanProvider,} from '@/components/ui/shadcn-io/kanban';import { type DragEndEvent, ListGroup, ListHeader, ListItem, ListItems, ListProvider,} from '@/components/ui/shadcn-io/list';import type { ColumnDef } from '@/components/ui/shadcn-io/table';import { TableBody, TableCell, TableColumnHeader, TableHead, TableHeader, TableHeaderGroup, TableProvider, TableRow,} from '@/components/ui/shadcn-io/table';import groupBy from 'lodash.groupby';import { CalendarIcon, ChevronRightIcon, EyeIcon, GanttChartSquareIcon, KanbanSquareIcon, LinkIcon, ListIcon, TableIcon, TrashIcon,} from 'lucide-react';import { useState } from 'react';import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger,} from '@/components/ui/context-menu';import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);const statuses = [ { id: faker.string.uuid(), name: 'Planned', color: '#6B7280' }, { id: faker.string.uuid(), name: 'In Progress', color: '#F59E0B' }, { id: faker.string.uuid(), name: 'Done', color: '#10B981' },];const users = Array.from({ length: 4 }) .fill(null) .map(() => ({ id: faker.string.uuid(), name: faker.person.fullName(), image: faker.image.avatar(), }));const exampleGroups = Array.from({ length: 6 }) .fill(null) .map(() => ({ id: faker.string.uuid(), name: capitalize(faker.company.buzzPhrase()), }));const exampleProducts = Array.from({ length: 4 }) .fill(null) .map(() => ({ id: faker.string.uuid(), name: capitalize(faker.company.buzzPhrase()), }));const exampleInitiatives = Array.from({ length: 2 }) .fill(null) .map(() => ({ id: faker.string.uuid(), name: capitalize(faker.company.buzzPhrase()), }));const exampleReleases = Array.from({ length: 3 }) .fill(null) .map(() => ({ id: faker.string.uuid(), name: capitalize(faker.company.buzzPhrase()), }));const exampleFeatures = Array.from({ length: 20 }) .fill(null) .map(() => ({ id: faker.string.uuid(), name: capitalize(faker.company.buzzPhrase()), startAt: faker.date.past({ years: 0.5, refDate: new Date() }), endAt: faker.date.future({ years: 0.5, refDate: new Date() }), status: faker.helpers.arrayElement(statuses), owner: faker.helpers.arrayElement(users), group: faker.helpers.arrayElement(exampleGroups), product: faker.helpers.arrayElement(exampleProducts), initiative: faker.helpers.arrayElement(exampleInitiatives), release: faker.helpers.arrayElement(exampleReleases), }));const exampleMarkers = Array.from({ length: 6 }) .fill(null) .map(() => ({ id: faker.string.uuid(), date: faker.date.past({ years: 0.5, refDate: new Date() }), label: capitalize(faker.company.buzzPhrase()), className: faker.helpers.arrayElement([ 'bg-blue-100 text-blue-900', 'bg-green-100 text-green-900', 'bg-purple-100 text-purple-900', 'bg-red-100 text-red-900', 'bg-orange-100 text-orange-900', 'bg-teal-100 text-teal-900', ]), }));const GanttView = () => { const [features, setFeatures] = useState(exampleFeatures); const groupedFeatures = groupBy(features, 'group.name'); const sortedGroupedFeatures = Object.fromEntries( Object.entries(groupedFeatures).sort(([nameA], [nameB]) => nameA.localeCompare(nameB) ) ); const handleViewFeature = (id: string) => console.log(`Feature selected: ${id}`); const handleCopyLink = (id: string) => console.log(`Copy link: ${id}`); const handleRemoveFeature = (id: string) => setFeatures((prev) => prev.filter((feature) => feature.id !== id)); const handleRemoveMarker = (id: string) => console.log(`Remove marker: ${id}`); const handleCreateMarker = (date: Date) => console.log(`Create marker: ${date.toISOString()}`); const handleMoveFeature = (id: string, startAt: Date, endAt: Date | null) => { if (!endAt) { return; } setFeatures((prev) => prev.map((feature) => feature.id === id ? { ...feature, startAt, endAt } : feature ) ); console.log(`Move feature: ${id} from ${startAt} to ${endAt}`); }; const handleAddFeature = (date: Date) => console.log(`Add feature: ${date.toISOString()}`); return ( <GanttProvider className="rounded-none" onAddItem={handleAddFeature} range="monthly" zoom={100} > <GanttSidebar> {Object.entries(sortedGroupedFeatures).map(([group, features]) => ( <GanttSidebarGroup key={group} name={group}> {features.map((feature) => ( <GanttSidebarItem feature={feature} key={feature.id} onSelectItem={handleViewFeature} /> ))} </GanttSidebarGroup> ))} </GanttSidebar> <GanttTimeline> <GanttHeader /> <GanttFeatureList> {Object.entries(sortedGroupedFeatures).map(([group, features]) => ( <GanttFeatureListGroup key={group}> {features.map((feature) => ( <div className="flex" key={feature.id}> <ContextMenu> <ContextMenuTrigger asChild> <button onClick={() => handleViewFeature(feature.id)} type="button" > <GanttFeatureItem onMove={handleMoveFeature} {...feature} > <p className="flex-1 truncate text-xs"> {feature.name} </p> {feature.owner && ( <Avatar className="h-4 w-4"> <AvatarImage src={feature.owner.image} /> <AvatarFallback> {feature.owner.name?.slice(0, 2)} </AvatarFallback> </Avatar> )} </GanttFeatureItem> </button> </ContextMenuTrigger> <ContextMenuContent> <ContextMenuItem className="flex items-center gap-2" onClick={() => handleViewFeature(feature.id)} > <EyeIcon className="text-muted-foreground" size={16} /> View feature </ContextMenuItem> <ContextMenuItem className="flex items-center gap-2" onClick={() => handleCopyLink(feature.id)} > <LinkIcon className="text-muted-foreground" size={16} /> Copy link </ContextMenuItem> <ContextMenuItem className="flex items-center gap-2 text-destructive" onClick={() => handleRemoveFeature(feature.id)} > <TrashIcon size={16} /> Remove from roadmap </ContextMenuItem> </ContextMenuContent> </ContextMenu> </div> ))} </GanttFeatureListGroup> ))} </GanttFeatureList> {exampleMarkers.map((marker) => ( <GanttMarker key={marker.id} {...marker} onRemove={handleRemoveMarker} /> ))} <GanttToday /> <GanttCreateMarkerTrigger onCreateMarker={handleCreateMarker} /> </GanttTimeline> </GanttProvider> );};const earliestYear = exampleFeatures .map((feature) => feature.startAt.getFullYear()) .sort() .at(0) ?? new Date().getFullYear();const latestYear = exampleFeatures .map((feature) => feature.endAt.getFullYear()) .sort() .at(-1) ?? new Date().getFullYear();const CalendarView = () => ( <CalendarProvider> <CalendarDate> <CalendarDatePicker> <CalendarMonthPicker /> <CalendarYearPicker end={latestYear} start={earliestYear} /> </CalendarDatePicker> <CalendarDatePagination /> </CalendarDate> <CalendarHeader /> <CalendarBody features={exampleFeatures}> {({ feature }) => <CalendarItem feature={feature} key={feature.id} />} </CalendarBody> </CalendarProvider>);const ListView = () => { const [features, setFeatures] = useState(exampleFeatures); const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; if (!over) { return; } const status = statuses.find((status) => status.name === over.id); if (!status) { return; } setFeatures( features.map((feature) => { if (feature.id === active.id) { return { ...feature, status }; } return feature; }) ); }; return ( <ListProvider className="overflow-auto" onDragEnd={handleDragEnd}> {statuses.map((status) => ( <ListGroup id={status.name} key={status.name}> <ListHeader color={status.color} name={status.name} /> <ListItems> {features .filter((feature) => feature.status.name === status.name) .map((feature, index) => ( <ListItem id={feature.id} index={index} key={feature.id} name={feature.name} parent={feature.status.name} > <div className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: feature.status.color }} /> <p className="m-0 flex-1 font-medium text-sm"> {feature.name} </p> {feature.owner && ( <Avatar className="h-4 w-4 shrink-0"> <AvatarImage src={feature.owner.image} /> <AvatarFallback> {feature.owner.name?.slice(0, 2)} </AvatarFallback> </Avatar> )} </ListItem> ))} </ListItems> </ListGroup> ))} </ListProvider> );};const dateFormatter = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric',});const shortDateFormatter = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric',});const KanbanView = () => { const [features, setFeatures] = useState( exampleFeatures.map((feature) => ({ ...feature, column: feature.status.id, })) ); const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; if (!over) { return; } const status = statuses.find(({ id }) => id === over.id); if (!status) { return; } setFeatures( features.map((feature) => { if (feature.id === active.id) { return { ...feature, status }; } return feature; }) ); }; return ( <KanbanProvider className="p-4" columns={statuses} data={features} onDragEnd={handleDragEnd} > {(column) => ( <KanbanBoard id={column.id} key={column.id}> <KanbanHeader>{column.name}</KanbanHeader> <KanbanCards id={column.id}> {(feature: (typeof features)[number]) => ( <KanbanCard column={column.id} id={feature.id} key={feature.id} name={feature.name} > <div className="flex items-start justify-between gap-2"> <div className="flex flex-col gap-1"> <p className="m-0 flex-1 font-medium text-sm"> {feature.name} </p> <p className="m-0 text-muted-foreground text-xs"> {feature.initiative.name} </p> </div> {feature.owner && ( <Avatar className="h-4 w-4 shrink-0"> <AvatarImage src={feature.owner.image} /> <AvatarFallback> {feature.owner.name?.slice(0, 2)} </AvatarFallback> </Avatar> )} </div> <p className="m-0 text-muted-foreground text-xs"> {shortDateFormatter.format(feature.startAt)} -{' '} {dateFormatter.format(feature.endAt)} </p> </KanbanCard> )} </KanbanCards> </KanbanBoard> )} </KanbanProvider> );};const TableView = () => { const columns: ColumnDef<(typeof exampleFeatures)[number]>[] = [ { accessorKey: 'name', header: ({ column }) => ( <TableColumnHeader column={column} title="Name" /> ), cell: ({ row }) => ( <div className="flex items-center gap-2"> <div className="relative"> <Avatar className="size-6"> <AvatarImage src={row.original.owner.image} /> <AvatarFallback> {row.original.owner.name?.slice(0, 2)} </AvatarFallback> </Avatar> <div className="absolute right-0 bottom-0 h-2 w-2 rounded-full ring-2 ring-background" style={{ backgroundColor: row.original.status.color, }} /> </div> <div> <span className="font-medium">{row.original.name}</span> <div className="flex items-center gap-1 text-muted-foreground text-xs"> <span>{row.original.product.name}</span> <ChevronRightIcon size={12} /> <span>{row.original.group.name}</span> </div> </div> </div> ), }, { accessorKey: 'startAt', header: ({ column }) => ( <TableColumnHeader column={column} title="Start At" /> ), cell: ({ row }) => new Intl.DateTimeFormat('en-US', { dateStyle: 'medium', }).format(row.original.startAt), }, { accessorKey: 'endAt', header: ({ column }) => ( <TableColumnHeader column={column} title="End At" /> ), cell: ({ row }) => new Intl.DateTimeFormat('en-US', { dateStyle: 'medium', }).format(row.original.endAt), }, { id: 'release', accessorFn: (row) => row.release.id, header: ({ column }) => ( <TableColumnHeader column={column} title="Release" /> ), cell: ({ row }) => row.original.release.name, }, ]; return ( <div className="size-full overflow-auto"> <TableProvider columns={columns} data={exampleFeatures}> <TableHeader> {({ headerGroup }) => ( <TableHeaderGroup headerGroup={headerGroup} key={headerGroup.id}> {({ header }) => <TableHead header={header} key={header.id} />} </TableHeaderGroup> )} </TableHeader> <TableBody> {({ row }) => ( <TableRow key={row.id} row={row}> {({ cell }) => <TableCell cell={cell} key={cell.id} />} </TableRow> )} </TableBody> </TableProvider> </div> );};const Example = () => { const views = [ { id: 'gantt', label: 'Gantt', icon: GanttChartSquareIcon, component: GanttView, }, { id: 'calendar', label: 'Calendar', icon: CalendarIcon, component: CalendarView, }, { id: 'list', label: 'List', icon: ListIcon, component: ListView, }, { id: 'kanban', label: 'Kanban', icon: KanbanSquareIcon, component: KanbanView, }, { id: 'table', label: 'Table', icon: TableIcon, component: TableView, }, ]; return ( <Tabs className="not-prose size-full gap-0 divide-y" defaultValue="gantt"> <div className="flex items-center justify-between gap-4 p-4"> <p className="font-medium">Roadmap</p> <TabsList> {views.map((view) => ( <TabsTrigger key={view.id} value={view.id}> <view.icon size={16} /> <span className="sr-only">{view.label}</span> </TabsTrigger> ))} </TabsList> </div> {views.map((view) => ( <TabsContent className="overflow-hidden" key={view.id} value={view.id}> <view.component /> </TabsContent> ))} </Tabs> );};export default Example;
'use client';import { getDay, getDaysInMonth, isSameDay } from 'date-fns';import { atom, useAtom } from 'jotai';import { Check, ChevronLeftIcon, ChevronRightIcon, ChevronsUpDown,} from 'lucide-react';import { createContext, memo, type ReactNode, useCallback, useContext, useMemo, useState,} from 'react';import { Button } from '@/components/ui/button';import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList,} from '@/components/ui/command';import { Popover, PopoverContent, PopoverTrigger,} from '@/components/ui/popover';import { cn } from '@/lib/utils';export type CalendarState = { month: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11; year: number;};const monthAtom = atom<CalendarState['month']>( new Date().getMonth() as CalendarState['month']);const yearAtom = atom<CalendarState['year']>(new Date().getFullYear());export const useCalendarMonth = () => useAtom(monthAtom);export const useCalendarYear = () => useAtom(yearAtom);type CalendarContextProps = { locale: Intl.LocalesArgument; startDay: number;};const CalendarContext = createContext<CalendarContextProps>({ locale: 'en-US', startDay: 0,});export type Status = { id: string; name: string; color: string;};export type Feature = { id: string; name: string; startAt: Date; endAt: Date; status: Status;};type ComboboxProps = { value: string; setValue: (value: string) => void; data: { value: string; label: string; }[]; labels: { button: string; empty: string; search: string; }; className?: string;};export const monthsForLocale = ( localeName: Intl.LocalesArgument, monthFormat: Intl.DateTimeFormatOptions['month'] = 'long') => { const format = new Intl.DateTimeFormat(localeName, { month: monthFormat }) .format; return [...new Array(12).keys()].map((m) => format(new Date(Date.UTC(2021, m, 2))) );};export const daysForLocale = ( locale: Intl.LocalesArgument, startDay: number) => { const weekdays: string[] = []; const baseDate = new Date(2024, 0, startDay); for (let i = 0; i < 7; i++) { weekdays.push( new Intl.DateTimeFormat(locale, { weekday: 'short' }).format(baseDate) ); baseDate.setDate(baseDate.getDate() + 1); } return weekdays;};const Combobox = ({ value, setValue, data, labels, className,}: ComboboxProps) => { const [open, setOpen] = useState(false); return ( <Popover onOpenChange={setOpen} open={open}> <PopoverTrigger asChild> <Button aria-expanded={open} className={cn('w-40 justify-between capitalize', className)} variant="outline" > {value ? data.find((item) => item.value === value)?.label : labels.button} <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> </Button> </PopoverTrigger> <PopoverContent className="w-40 p-0"> <Command filter={(value, search) => { const label = data.find((item) => item.value === value)?.label; return label?.toLowerCase().includes(search.toLowerCase()) ? 1 : 0; }} > <CommandInput placeholder={labels.search} /> <CommandList> <CommandEmpty>{labels.empty}</CommandEmpty> <CommandGroup> {data.map((item) => ( <CommandItem className="capitalize" key={item.value} onSelect={(currentValue) => { setValue(currentValue === value ? '' : currentValue); setOpen(false); }} value={item.value} > <Check className={cn( 'mr-2 h-4 w-4', value === item.value ? 'opacity-100' : 'opacity-0' )} /> {item.label} </CommandItem> ))} </CommandGroup> </CommandList> </Command> </PopoverContent> </Popover> );};type OutOfBoundsDayProps = { day: number;};const OutOfBoundsDay = ({ day }: OutOfBoundsDayProps) => ( <div className="relative h-full w-full bg-secondary p-1 text-muted-foreground text-xs"> {day} </div>);export type CalendarBodyProps = { features: Feature[]; children: (props: { feature: Feature }) => ReactNode;};export const CalendarBody = ({ features, children }: CalendarBodyProps) => { const [month] = useCalendarMonth(); const [year] = useCalendarYear(); const { startDay } = useContext(CalendarContext); // Memoize expensive date calculations const currentMonthDate = useMemo( () => new Date(year, month, 1), [year, month] ); const daysInMonth = useMemo( () => getDaysInMonth(currentMonthDate), [currentMonthDate] ); const firstDay = useMemo( () => (getDay(currentMonthDate) - startDay + 7) % 7, [currentMonthDate, startDay] ); // Memoize previous month calculations const prevMonthData = useMemo(() => { const prevMonth = month === 0 ? 11 : month - 1; const prevMonthYear = month === 0 ? year - 1 : year; const prevMonthDays = getDaysInMonth(new Date(prevMonthYear, prevMonth, 1)); const prevMonthDaysArray = Array.from( { length: prevMonthDays }, (_, i) => i + 1 ); return { prevMonthDays, prevMonthDaysArray }; }, [month, year]); // Memoize next month calculations const nextMonthData = useMemo(() => { const nextMonth = month === 11 ? 0 : month + 1; const nextMonthYear = month === 11 ? year + 1 : year; const nextMonthDays = getDaysInMonth(new Date(nextMonthYear, nextMonth, 1)); const nextMonthDaysArray = Array.from( { length: nextMonthDays }, (_, i) => i + 1 ); return { nextMonthDaysArray }; }, [month, year]); // Memoize features filtering by day to avoid recalculating on every render const featuresByDay = useMemo(() => { const result: { [day: number]: Feature[] } = {}; for (let day = 1; day <= daysInMonth; day++) { result[day] = features.filter((feature) => { return isSameDay(new Date(feature.endAt), new Date(year, month, day)); }); } return result; }, [features, daysInMonth, year, month]); const days: ReactNode[] = []; for (let i = 0; i < firstDay; i++) { const day = prevMonthData.prevMonthDaysArray[ prevMonthData.prevMonthDays - firstDay + i ]; if (day) { days.push(<OutOfBoundsDay day={day} key={`prev-${i}`} />); } } for (let day = 1; day <= daysInMonth; day++) { const featuresForDay = featuresByDay[day] || []; days.push( <div className="relative flex h-full w-full flex-col gap-1 p-1 text-muted-foreground text-xs" key={day} > {day} <div> {featuresForDay.slice(0, 3).map((feature) => children({ feature }))} </div> {featuresForDay.length > 3 && ( <span className="block text-muted-foreground text-xs"> +{featuresForDay.length - 3} more </span> )} </div> ); } const remainingDays = 7 - ((firstDay + daysInMonth) % 7); if (remainingDays < 7) { for (let i = 0; i < remainingDays; i++) { const day = nextMonthData.nextMonthDaysArray[i]; if (day) { days.push(<OutOfBoundsDay day={day} key={`next-${i}`} />); } } } return ( <div className="grid flex-grow grid-cols-7"> {days.map((day, index) => ( <div className={cn( 'relative aspect-square overflow-hidden border-t border-r', index % 7 === 6 && 'border-r-0' )} key={index} > {day} </div> ))} </div> );};export type CalendarDatePickerProps = { className?: string; children: ReactNode;};export const CalendarDatePicker = ({ className, children,}: CalendarDatePickerProps) => ( <div className={cn('flex items-center gap-1', className)}>{children}</div>);export type CalendarMonthPickerProps = { className?: string;};export const CalendarMonthPicker = ({ className,}: CalendarMonthPickerProps) => { const [month, setMonth] = useCalendarMonth(); const { locale } = useContext(CalendarContext); // Memoize month data to avoid recalculating date formatting const monthData = useMemo(() => { return monthsForLocale(locale).map((month, index) => ({ value: index.toString(), label: month, })); }, [locale]); return ( <Combobox className={className} data={monthData} labels={{ button: 'Select month', empty: 'No month found', search: 'Search month', }} setValue={(value) => setMonth(Number.parseInt(value) as CalendarState['month']) } value={month.toString()} /> );};export type CalendarYearPickerProps = { className?: string; start: number; end: number;};export const CalendarYearPicker = ({ className, start, end,}: CalendarYearPickerProps) => { const [year, setYear] = useCalendarYear(); return ( <Combobox className={className} data={Array.from({ length: end - start + 1 }, (_, i) => ({ value: (start + i).toString(), label: (start + i).toString(), }))} labels={{ button: 'Select year', empty: 'No year found', search: 'Search year', }} setValue={(value) => setYear(Number.parseInt(value))} value={year.toString()} /> );};export type CalendarDatePaginationProps = { className?: string;};export const CalendarDatePagination = ({ className,}: CalendarDatePaginationProps) => { const [month, setMonth] = useCalendarMonth(); const [year, setYear] = useCalendarYear(); const handlePreviousMonth = useCallback(() => { if (month === 0) { setMonth(11); setYear(year - 1); } else { setMonth((month - 1) as CalendarState['month']); } }, [month, year, setMonth, setYear]); const handleNextMonth = useCallback(() => { if (month === 11) { setMonth(0); setYear(year + 1); } else { setMonth((month + 1) as CalendarState['month']); } }, [month, year, setMonth, setYear]); return ( <div className={cn('flex items-center gap-2', className)}> <Button onClick={handlePreviousMonth} size="icon" variant="ghost"> <ChevronLeftIcon size={16} /> </Button> <Button onClick={handleNextMonth} size="icon" variant="ghost"> <ChevronRightIcon size={16} /> </Button> </div> );};export type CalendarDateProps = { children: ReactNode;};export const CalendarDate = ({ children }: CalendarDateProps) => ( <div className="flex items-center justify-between p-3">{children}</div>);export type CalendarHeaderProps = { className?: string;};export const CalendarHeader = ({ className }: CalendarHeaderProps) => { const { locale, startDay } = useContext(CalendarContext); // Memoize days data to avoid recalculating date formatting const daysData = useMemo(() => { return daysForLocale(locale, startDay); }, [locale, startDay]); return ( <div className={cn('grid flex-grow grid-cols-7', className)}> {daysData.map((day) => ( <div className="p-3 text-right text-muted-foreground text-xs" key={day}> {day} </div> ))} </div> );};export type CalendarItemProps = { feature: Feature; className?: string;};export const CalendarItem = memo( ({ feature, className }: CalendarItemProps) => ( <div className={cn('flex items-center gap-2', className)}> <div className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: feature.status.color, }} /> <span className="truncate">{feature.name}</span> </div> ));CalendarItem.displayName = 'CalendarItem';export type CalendarProviderProps = { locale?: Intl.LocalesArgument; startDay?: number; children: ReactNode; className?: string;};export const CalendarProvider = ({ locale = 'en-US', startDay = 0, children, className,}: CalendarProviderProps) => ( <CalendarContext.Provider value={{ locale, startDay }}> <div className={cn('relative flex flex-col', className)}>{children}</div> </CalendarContext.Provider>);
Collaborative Canvas
React collaborative canvas with real-time multiplayer editing. Build Figma-style interfaces with cursors, avatars, and live collaboration using TypeScript and shadcn/ui.
Form
React form components with advanced inputs and validation. Build complete contact forms, surveys, and data collection with TypeScript, file uploads, and shadcn/ui.