Flab UIv0.0.2
MobileA2UI

Calendar

An enterprise week view. Pass a week of days and a flat list of events; it draws the header, an optional all-day band, the hourly grid and a now rule, and packs overlapping events into side-by-side columns so a busy day still reads. Interaction layers on from a client boundary: renderSlotPopover opens an anchored popover to add an event on an empty slot, renderEventPopover opens one on an existing event, and onEventDrop enables drag-to-reschedule — hold Ctrl (Cmd on macOS) while dragging to copy. Try it below: drag an event to move it, or hold Ctrl/Cmd to drop a copy. The live version is on the Calendar showcase.

UTC+7
Mon18
Tue194
Wed20
Thu21
Fri22
All day
On-call
Team offsite
1 AM
2 AM
3 AM
4 AM
5 AM
6 AM
7 AM
8 AM
9 AM
10 AM
11 AM
12 PM
1 PM
2 PM
3 PM
4 PM
5 PM
6 PM
7 PM
8 PM
9 PM
10 PM
11 PM
12 PM
const days = [  { weekday: 'Mon', date: 18 },  { weekday: 'Tue', date: 19, today: true, count: 4 },  { weekday: 'Wed', date: 20 },  { weekday: 'Thu', date: 21 },  { weekday: 'Fri', date: 22 },];
const events = [  // All-day band.  { id: 'offsite', title: 'Team offsite', day: 4, allDay: true, tone: 'info' },  { id: 'oncall', title: 'On-call', day: 1, allDay: true, tone: 'warning' },
  // Mon.  { id: 'm1', title: 'Standup', day: 0, start: '09:00', end: '09:15', tone: 'alt' },  { id: 'm2', title: 'Design review', day: 0, start: '09:30', end: '11:00', tone: 'info', meta: 'Figma' },  { id: 'm3', title: '1:1 Priya', day: 0, start: '11:30', end: '12:00', tone: 'neutral' },  { id: 'm4', title: 'Lunch', day: 0, start: '12:30', end: '13:30', tone: 'success' },  { id: 'm5', title: 'Focus: API', day: 0, start: '14:00', end: '16:00', tone: 'warning' },
  // Tue (today).  { id: 't1', title: 'Standup', day: 1, start: '09:00', end: '09:15', tone: 'alt' },  { id: 't2', title: 'Sprint planning', day: 1, start: '09:30', end: '11:00', tone: 'info', meta: 'Room 4' },  { id: 't3', title: 'Interview', day: 1, start: '11:15', end: '12:00', tone: 'warning' },  { id: 't4', title: 'Lunch', day: 1, start: '12:00', end: '13:00', tone: 'success' },  { id: 't5', title: 'Product sync', day: 1, start: '13:30', end: '14:30', tone: 'alt' },  { id: 't6', title: 'Design crit', day: 1, start: '14:00', end: '15:30', tone: 'warning' },  { id: 't7', title: 'Release prep', day: 1, start: '16:00', end: '17:30', tone: 'info' },
  // Wed.  { id: 'w1', title: 'Standup', day: 2, start: '09:00', end: '09:15', tone: 'alt' },  { id: 'w2', title: 'Deep work', day: 2, start: '10:00', end: '12:00', tone: 'info' },  { id: 'w3', title: 'Lunch with Emma', day: 2, start: '12:00', end: '13:00', tone: 'success', meta: 'Café Nero' },  { id: 'w4', title: 'Team meeting', day: 2, start: '14:00', end: '15:30', tone: 'alt', meta: 'Room 4' },  { id: 'w5', title: 'Review', day: 2, start: '14:30', end: '16:00', tone: 'warning' },
  // Thu.  { id: 'th1', title: 'Standup', day: 3, start: '09:00', end: '09:15', tone: 'alt' },  { id: 'th2', title: 'Roadmap', day: 3, start: '10:00', end: '11:30', tone: 'info' },  { id: 'th3', title: 'Lunch', day: 3, start: '12:00', end: '13:00', tone: 'success' },  { id: 'th4', title: 'Customer call', day: 3, start: '15:00', end: '16:00', tone: 'neutral', meta: 'Zoom' },  { id: 'th5', title: 'Release', day: 3, start: '16:00', end: '18:00', tone: 'success' },
  // Fri.  { id: 'f1', title: 'Standup', day: 4, start: '09:00', end: '09:15', tone: 'alt' },  { id: 'f2', title: 'Retro', day: 4, start: '10:00', end: '11:00', tone: 'alt' },  { id: 'f3', title: 'Lunch', day: 4, start: '12:00', end: '13:00', tone: 'success' },  { id: 'f4', title: 'Sprint demo', day: 4, start: '14:00', end: '15:00', tone: 'info' },];
// Hold the events in state so a drag can reschedule them. Dropping normally// moves the event; holding Ctrl (Cmd on macOS) while dragging drops a copy.const [items, setItems] = useState(events);
// Give it a bounded height so the 24-hour grid scrolls internally.<div className="h-[32rem] overflow-hidden rounded-xl border border-hairline">  <CalendarWeek    className="h-full"    days={days}    events={items}    startHour={0}    endHour={24}    initialScrollHour={8}    timezone="UTC+7"    now={{ day: 1, minutes: 14 * 60 + 40 }}    onEventDrop={(event, target, { copy }) => {      if (copy) {        setItems((prev) => [          ...prev,          { ...event, id: `${event.id}-copy-${Date.now()}`, day: target.day, start: target.start, end: target.end },        ]);      } else {        setItems((prev) =>          prev.map((e) =>            e.id === event.id ? { ...e, day: target.day, start: target.start, end: target.end } : e,          ),        );      }    }}  /></div>

Props

PropTypeDefaultNotes
daysCalendarDay[]—Week columns: weekday, date, today, count.
eventsCalendarEvent[]—Flat list; each event names its day index, start/end and tone.
startHournumber7First visible hour (0–23).
endHournumber21Grid end bound (1–24).
hourHeightnumber56Pixel height of one hour row.
timezoneReactNode—Corner label above the time gutter.
now{ day; minutes }—Draws the current-time rule at this position.
hour24booleanfalseUse 24-hour time labels.
snapMinutesnumber15Drag snap increment.
newEventMinutesnumber60Default length for an event added from a slot.
renderEventPopover(event, api) => ReactNode—Content for a popover anchored to a clicked event.
renderSlotPopover(slot, api) => ReactNode—Content for a popover anchored to a clicked empty slot.
onEventDrop(event, target, { copy }) => void—Enables drag-to-reschedule; copy is true when Ctrl (Cmd on macOS) is held.
onEventClick(event) => void—Lower-level click callback when no event popover is set.
onSlotClick(day, minutes) => void—Lower-level slot callback when no slot popover is set.
CalendarEvent.toneCalendarTone'info'neutral · success · warning · danger · info · alt.
CalendarEvent.allDayboolean—Renders in the all-day band instead of the time grid.