EmpireUI
Get Pro
← Blog7 min read#react#calendar#tailwind-css

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.

Open planner notebook with calendar grid and colorful sticky notes on a desk

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

Can I use this calendar event component with react-big-calendar or FullCalendar?

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.

How do I handle all-day events separately from timed events?

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 overlap detection algorithm you showed doesn't handle all edge cases — what's missing?

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.

How do I sync the dragged event position back to a server without hammering the API?

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.

What's the minimum event height before the title becomes unreadable?

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.

Does this work in React Native?

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.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Drag-and-Drop Sortable Lists in React: No Library RequiredForm Builder in React: Drag-and-Drop Field ConfigurationGlassmorphism Calendar Component: Date Picker UIGlassmorphism Kanban Board: Project Management UI