Calendar Event Component: Google Calendar Clone in React
Build a Google Calendar-style event component in React with Tailwind. Drag-to-resize, color coding, overlap detection — no third-party calendar lib needed.
Why Build a Calendar Event Component Instead of Using a Library
Honestly, most calendar libraries are a nightmare to style. You spend three hours fighting their CSS specificity, override half their internals, and end up with a component that still doesn't match your design system. At that point you've spent more time wrestling the library than it would have taken to write something yourself.
Google Calendar's event blocks look deceptively simple — a colored pill with a title and time range. But they actually carry a lot of logic: they position themselves along a vertical time axis, they squish horizontally when events overlap, and they resize when you drag their bottom edge. None of that comes free.
This article walks through building that exact component from scratch in React with Tailwind v4.0.2. We're keeping zero runtime dependencies beyond React itself. If you've already worked through the animated tabs component or the cards stack, some of the motion patterns here will feel familiar.
Data Model: Representing a Calendar Event in TypeScript
Before any UI, let's nail the shape of data. A calendar event needs a start time, an end time, a title, an optional description, and a color. You also need a unique id so React's reconciler doesn't lose track during drag operations.
Keep times as plain integers — minutes since midnight. So 9:30 AM is 570, 2:15 PM is 855. This makes the math for vertical positioning trivial: top = (startMinutes / 1440) * 100 gives you a CSS percentage. No Date parsing overhead in the render path.
export type CalendarEvent = {
id: string;
title: string;
description?: string;
startMinutes: number; // minutes since midnight, e.g. 540 = 9:00 AM
endMinutes: number;
color: EventColor;
};
export type EventColor =
| 'blue'
| 'green'
| 'red'
| 'purple'
| 'yellow'
| 'pink';
const COLOR_MAP: Record<EventColor, string> = {
blue: 'bg-blue-500/90 border-blue-600 text-white',
green: 'bg-emerald-500/90 border-emerald-600 text-white',
red: 'bg-red-500/90 border-red-600 text-white',
purple: 'bg-violet-500/90 border-violet-600 text-white',
yellow: 'bg-amber-400/90 border-amber-500 text-gray-900',
pink: 'bg-pink-500/90 border-pink-600 text-white',
};The /90 opacity suffix on each background class gives you that semi-transparent look Google Calendar uses — especially nice when events overlap. We'll come back to the opacity trick in the overlap section.
Positioning Events on the Time Grid with Pure CSS
The day column is a relative container. Each event is absolute inside it. The height of the container represents 24 hours (or whatever range you display — typically 7 AM to 10 PM, which is 900 minutes). Events get top and height as inline styles calculated from their minute values.
const DISPLAY_START = 7 * 60; // 7:00 AM in minutes
const DISPLAY_END = 22 * 60; // 10:00 PM in minutes
const DISPLAY_RANGE = DISPLAY_END - DISPLAY_START; // 900 minutes
function minutesToPercent(minutes: number): number {
return ((minutes - DISPLAY_START) / DISPLAY_RANGE) * 100;
}
function EventBlock({ event }: { event: CalendarEvent }) {
const top = minutesToPercent(event.startMinutes);
const height = minutesToPercent(event.endMinutes) - top;
return (
<div
className={`absolute left-1 right-1 rounded-md border-l-4 px-2 py-1 text-xs
overflow-hidden cursor-pointer select-none
${COLOR_MAP[event.color]}`}
style={{ top: `${top}%`, height: `${height}%` }}
>
<p className="font-semibold truncate">{event.title}</p>
<p className="opacity-80">{formatTime(event.startMinutes)}</p>
</div>
);
}Notice the left-1 right-1 — that's an 8px gap on each side so events don't butt right against the column edges. This is the base case, single non-overlapping event. Overlap handling is a separate pass that narrows left and right further.
The border-l-4 is the colored left accent stripe. It's the same pattern Google Calendar uses for all-day events. Visually it helps at small heights where the background color alone would be hard to read.
Detecting and Resolving Event Overlaps
Overlap detection is where most DIY calendar components fall apart. The algorithm you want is a sweep-line: sort events by start time, then group them into "columns" such that no two events in the same column overlap. Each column gets an equal share of the horizontal width.
type PositionedEvent = CalendarEvent & {
column: number;
totalColumns: number;
};
function positionEvents(events: CalendarEvent[]): PositionedEvent[] {
const sorted = [...events].sort((a, b) => a.startMinutes - b.startMinutes);
const positioned: PositionedEvent[] = [];
const active: PositionedEvent[] = [];
for (const event of sorted) {
// Remove events that have already ended
const stillActive = active.filter(e => e.endMinutes > event.startMinutes);
// Find the first available column slot
const usedColumns = new Set(stillActive.map(e => e.column));
let col = 0;
while (usedColumns.has(col)) col++;
const pos: PositionedEvent = { ...event, column: col, totalColumns: 1 };
stillActive.push(pos);
positioned.push(pos);
// Update totalColumns for all active events in this group
const maxCol = Math.max(...stillActive.map(e => e.column)) + 1;
stillActive.forEach(e => { e.totalColumns = maxCol; });
active.length = 0;
active.push(...stillActive);
}
return positioned;
}Once you have column and totalColumns, the width math is straightforward. Each event gets 1 / totalColumns of the column width, offset by column / totalColumns. Apply those as percentage-based left and width inline styles, keeping a 4px gutter between adjacent events.
Does this handle every edge case perfectly? No. Triple overlaps where the third event only overlaps one of the first two still need a second pass. But for 95% of real calendars this algorithm is more than good enough.
Drag-to-Move and Resize with Pointer Events
Skip touch events and mouse events — use the Pointer Events API. One set of handlers covers both. The resize handle is a 12px strip at the bottom of each event block. When the pointer goes down on it, you enter resize mode; anywhere else on the block, you enter move mode.
The key insight for smooth dragging: don't update React state on every pointermove. That's 60+ re-renders per second and it'll jank even on fast machines. Instead, mutate a ref on each move and only call setState on pointerup. Use CSS transform: translateY() on the dragged element for the visual feedback.
One gotcha: call e.currentTarget.setPointerCapture(e.pointerId) in your pointerdown handler. Without this, if the pointer moves faster than your component updates, the pointer leaves the element and your drag silently breaks. This is the single most common bug in DIY drag implementations.
For the resize specifically: clamp the new endMinutes to a minimum duration of 30 minutes. Nobody wants a zero-height event. Also snap to the nearest 15-minute mark — Math.round(rawMinutes / 15) * 15 — so the UX matches what users expect from Google Calendar.
Dark Mode and the rgba Trick for Glassmorphism Events
If your app supports dark mode (and it should — check out the theme toggle component for the implementation), calendar events need special treatment. Pure bg-blue-500 looks fine in light mode but too saturated in dark mode, especially on a dark gray calendar background.
The fix is rgba(255,255,255,0.15) as a semi-transparent white overlay in dark mode. In Tailwind: add dark:bg-white/10 to each event's class list alongside the base color. This desaturates the event slightly in dark contexts and gives it that frosted look — similar to what we described in the glassmorphism guide.
For the text, always test contrast at the smallest font size you'll render. Event titles at text-xs (12px) on a bg-blue-500/90 background hit WCAG AA at around 4.6:1. That's fine. But yellow events with dark text need explicit testing — bg-amber-400/90 with text-gray-900 sits at 8.1:1, which passes AAA.
One more detail: the backdrop-blur-sm class on overlapping events creates a subtle depth separation when two events sit on top of each other. It costs a GPU compositing layer so use it sparingly — only on the topmost event in an overlap group.
Integrating the Calendar Event Component with Empire UI Styles
Empire UI ships 40 visual styles — Glassmorphism, Neon, Claymorphism, Brutalist, and more. The calendar event component plugs into this system cleanly because all the color and surface decisions go through Tailwind classes, not hardcoded hex values.
For the Neon style, swap the border-l-4 for a ring-1 ring-offset-0 with a neon color and add drop-shadow-[0_0_6px_rgba(99,102,241,0.8)] to the event block. For Glassmorphism, replace the solid background with bg-white/10 backdrop-blur-md border border-white/20. Neither change touches any logic — only classNames.
If you're building a full scheduling UI, pair this with an animated button for the "New Event" trigger. The two components share the same color token system so they'll look consistent without any extra work.
The Empire UI source ships the calendar event component as a single file drop-in. No context providers, no global store, no install ceremony. Copy the file, import it, done.
Performance: Rendering 200+ Events Without Dropping Frames
If you're building a scheduling tool where a single day view might have 50-200 events (think a call center schedule or a room-booking system), vanilla React rendering will start to hurt. A few targeted optimizations get you to 60fps without rewriting everything.
First: wrap EventBlock in React.memo. The component only needs to re-render when its event data or position changes. If you're managing events in a list that doesn't mutate existing entries (i.e., you follow immutable update patterns), React.memo alone can cut renders by 80%.
Second: the positionEvents overlap algorithm runs on every render of the parent column. Memoize it with useMemo — the input is the events array, so memoization is trivial. Third: if you're using Framer Motion for enter/exit animations, batch the AnimatePresence at the column level rather than wrapping each individual EventBlock. Fewer concurrent animation nodes means fewer style recalculations.
What about virtualization? For a single day column with a fixed pixel height, you don't need it. Virtualization makes sense when you have an unknown or very large list rendered in a scroll container. A 900px-tall day column with 200 absolutely positioned events is fast. All 200 are in the DOM, but absolute positioning means the browser skips layout for offscreen elements during scroll.
FAQ
Not directly — those libraries render their own event elements internally. You'd need to use their custom event renderer APIs (react-big-calendar has an eventPropGetter and a components.event prop). You can pass your styling logic through those hooks, but you won't get the full drag-to-resize behavior since those libraries control pointer events themselves.
Keep them in a separate data array and render them in a fixed-height banner above the time grid, not inside the absolute-positioned column. All-day events use horizontal space (left/right percentages based on day position in the week) rather than vertical space. The component in this article only covers timed events.
The main gap is events that partially overlap a group but not all events in it. Say event A runs 9-11, event B runs 10-12, and event C runs 11-1. C overlaps B but not A, yet the naive algorithm puts all three in a 3-column layout. A more correct approach tracks overlap groups as connected components in a graph. For most UIs the simpler version is fine — users rarely have 5+ overlapping events in a real calendar.
Only fire the API call on pointerup, not during the drag. Debouncing isn't enough — you want to wait for the final position. Use optimistic updates: update local state immediately on pointerup, fire the mutation in the background, and roll back if the mutation fails. React Query's useMutation with onMutate/onError/onSettled handles this pattern cleanly.
At 30 minutes on a 900-minute grid inside a 900px column, each event is 30px tall. That's enough for one line of text-xs (12px) with 4px padding top and bottom. Below 20px, hide the time label and show only the title. Below 12px (15-minute events), just show a colored bar with no text — truncate with a tooltip on hover.
Not as-is. The component uses Tailwind classes and DOM pointer events. For React Native you'd replace Tailwind with StyleSheet objects and pointer events with PanResponder or react-native-gesture-handler. The positioning math (minutesToPercent, positionEvents) is pure TypeScript and ports directly.