npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@zelkim/zui

v0.1.2

Published

Composable React calendar components built on shadcn/ui and Tailwind CSS

Readme

@zelkim/zui

Composable, headless-ish React calendar components built on Tailwind CSS and shadcn/ui primitives. Two component families ship today:

  • CalendarView — a traditional month grid
  • CalendarTimeGrid — a time-aware day/week grid with drag-and-drop, overlap resolution, hover preview, and snapping

Every component is a building block. Compose them to match your UI, override styles with className, and wire up state however you like.


Table of Contents


Install

npm install @zelkim/zui

Peer dependencies (install manually): react >= 18, react-dom >= 18, tailwindcss >= 3

Dependencies (auto-installed): @radix-ui/react-slot, class-variance-authority

Bundled (inlined in dist, no install needed): @dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities, date-fns, lucide-react, clsx, tailwind-merge


Tailwind Configuration

Add the @zelkim/zui distribution files to your Tailwind content array so component classes are included in the output:

// tailwind.config.js
export default {
  content: [
    "./src/**/*.{js,ts,jsx,tsx}",
    "./node_modules/@zelkim/zui/dist/**/*.{js,cjs}",
  ],
  // ...
};

CalendarView (Month Grid)

A composable month calendar. You control the month/year; the library computes the 6×7 grid (with overflow days from adjacent months), handles navigation, and lets you inject custom content into any day cell.

CalendarView Component Tree

CalendarView                  ← context provider (month, year, day, callbacks)
├── CalendarViewHeader        ← toolbar row (flex container)
│   ├── CalendarViewHeaderButton type="previous"  ← navigates to previous month
│   ├── CalendarViewHeaderButton type="default"   ← displays "March 2026"
│   └── CalendarViewHeaderButton type="next"      ← navigates to next month
└── CalendarViewGrid          ← 7-column CSS grid of day cells
    ├── CalendarViewDay day={15}  ← override content for day 15
    └── CalendarViewDay day={22}  ← override content for day 22

CalendarView

The root provider. All child components read from its context.

| Prop | Type | Default | Description | |------|------|---------|-------------| | month | number | required | 0-indexed month (0 = January, 11 = December) | | year | number | current year | Full year (e.g. 2026) | | day | number | — | Focused day (1-based). Shows ring/highlight styling. | | onDayClick | (date: Date) => void | — | Fires when a non-overflow day cell is clicked | | onMonthChange | (month: number, year: number) => void | — | Fires when prev/next navigation changes the month | | onHeaderClick | () => void | — | Fires when the header label button is clicked | | className | string | — | Additional CSS classes on the root <div> |

Under the hood: Creates a CalendarViewContext with month, year, day, all callbacks, and an internal setMonthYear function. Child components consume this context via useCalendarView(). Month/year state is semi-controlled: external props sync to internal state, but prev/next navigation also updates it internally.

CalendarViewHeader

A flex container (flex items-center justify-between) for navigation buttons. Pure layout — no logic.

| Prop | Type | Description | |------|------|-------------| | className | string | Additional CSS classes |

CalendarViewHeaderButton

A navigation or label button. Behavior depends on type:

| Prop | Type | Default | Description | |------|------|---------|-------------| | type | "previous" \| "next" \| "default" | required | Button role | | icon | React.ReactNode | <ChevronLeft/> / <ChevronRight/> | Custom icon for prev/next | | onClick | () => void | — | Additional click handler (fires after navigation) | | className | string | — | Additional CSS classes | | children | React.ReactNode | auto-generated label | Custom label for type="default" |

Under the hood:

  • type="previous": decrements month (wraps year). Calls setMonthYear() from context.
  • type="next": increments month (wraps year). Calls setMonthYear() from context.
  • type="default": displays "MMMM yyyy" (e.g. "March 2026") via date-fns format. Calls onHeaderClick from context on click. You can override the label with children.

CalendarViewGrid

Renders the weekday header row (Sun–Sat) and the day cells grid.

| Prop | Type | Description | |------|------|-------------| | className | string | Additional CSS classes | | children | React.ReactNode | CalendarViewDay components for content overrides |

Under the hood:

  1. Calls buildMonthGrid(month, year) to generate 42 GridCell objects (6 rows × 7 days). Each cell has { date, day, isOverflow }.
  2. Scans children for CalendarViewDay elements (matched by displayName). Builds a Map<number, ReactElement> keyed by day number.
  3. Renders a grid grid-cols-7 of day cells. Overflow days get muted styling. The focused day (from context) gets a ring highlight. Day cells with a matching CalendarViewDay override render that override's children inside the cell.
  4. Only renders as many rows as needed (hides trailing empty rows).

CalendarViewDay

A slot component — it doesn't render in isolation. Place it inside CalendarViewGrid to inject custom content into a specific day cell.

| Prop | Type | Description | |------|------|-------------| | day | number | 1-based day of the month to override | | focus | boolean | Reserved for future use | | className | string | Additional CSS classes | | children | React.ReactNode | Content to render in the day cell |

Under the hood: CalendarViewGrid scans for children with displayName === "CalendarViewDay", reads their day prop, and injects their content into the matching cell. The component itself just renders a <div> wrapper.

CalendarView Full Example

import { useState } from "react";
import {
  CalendarView,
  CalendarViewHeader,
  CalendarViewHeaderButton,
  CalendarViewGrid,
  CalendarViewDay,
} from "@zelkim/zui";

function MonthCalendar() {
  const today = new Date();
  const [focusedDay, setFocusedDay] = useState<number | undefined>(today.getDate());

  return (
    <CalendarView
      month={today.getMonth()}
      year={today.getFullYear()}
      day={focusedDay}
      onDayClick={(date) => setFocusedDay(date.getDate())}
      onMonthChange={() => setFocusedDay(undefined)}
    >
      <CalendarViewHeader>
        <CalendarViewHeaderButton type="previous" />
        <CalendarViewHeaderButton type="default" />
        <CalendarViewHeaderButton type="next" />
      </CalendarViewHeader>

      <CalendarViewGrid>
        {/* Inject events into specific days */}
        <CalendarViewDay day={15}>
          <div className="text-[10px] bg-blue-100 text-blue-800 rounded px-1 truncate">
            Team Standup
          </div>
        </CalendarViewDay>

        <CalendarViewDay day={22}>
          <div className="space-y-0.5">
            <div className="text-[10px] bg-green-100 text-green-800 rounded px-1 truncate">
              Lunch
            </div>
            <div className="text-[10px] bg-purple-100 text-purple-800 rounded px-1 truncate">
              Review
            </div>
          </div>
        </CalendarViewDay>
      </CalendarViewGrid>
    </CalendarView>
  );
}

CalendarTimeGrid (Week/Day Grid)

A time-aware grid where each column represents a day and rows represent hours (0–24). Supports:

  • 12h / 24h time format
  • Current-time marker
  • Drag-and-drop with 15-minute visual snapping
  • Cross-day drag-and-drop
  • Hover preview on empty slots
  • Click-to-create on empty slots with snapping
  • Automatic overlap resolution for conflicting events
  • Variable-width layout (events expand when adjacent columns are empty)

CalendarTimeGrid Component Tree

CalendarTimeGrid                          ← context provider (format, markerTime)
├── CalendarTimeGridHeader                ← toolbar row
│   ├── CalendarTimeGridHeaderButton type="previous"
│   ├── CalendarTimeGridHeaderButton type="default"
│   └── CalendarTimeGridHeaderButton type="next"
└── CalendarTimeGridBody                  ← scroll container + CSS grid + DnD context
    ├── CalendarTimeGridTimeLabels        ← hour labels column (0:00–23:00)
    └── CalendarTimeGridDay               ← logical grouping for one day
        ├── CalendarTimeGridDayHeader     ← sticky column header
        │   ├── CalendarTimeGridDayHeaderWeekDay  ← "MON", "TUE", etc.
        │   └── CalendarTimeGridDayHeaderDate     ← day number (e.g. "5")
        └── CalendarTimeGridTable         ← 24-hour time column (relative container)
            ├── CalendarTimeGridTableItem  ← absolutely-positioned event block
            └── CalendarTimeGridTableItem

CalendarTimeGrid

The root provider. Sets the time format and optional current-time marker.

| Prop | Type | Default | Description | |------|------|---------|-------------| | format | "12" \| "24" | required | Time display format (AM/PM or 24-hour) | | markerTime | string | — | ISO 8601 string for the current-time marker | | className | string | — | Additional CSS classes |

Under the hood: Creates a CalendarTimeGridContext with { format, markerTime }. All descendant components access this via useCalendarTimeGrid().

CalendarTimeGridHeader / CalendarTimeGridHeaderButton

Identical API to the CalendarView header components. The header is a flex container; buttons come in three types: "previous", "next", and "default".

CalendarTimeGridHeaderButton props:

| Prop | Type | Default | Description | |------|------|---------|-------------| | type | "previous" \| "next" \| "default" | required | Button role | | onClick | () => void | — | Click handler | | icon | React.ReactNode | Chevron icons | Custom icon | | className | string | — | Additional CSS classes | | children | React.ReactNode | — | Label content for type="default" |

Unlike CalendarViewHeaderButton, these don't auto-navigate — you supply the onClick handler.

CalendarTimeGridBody

The scroll container and layout engine. This is where the magic happens.

| Prop | Type | Default | Description | |------|------|---------|-------------| | draggable | boolean | false | Enable drag-and-drop for all items | | onItemDrop | (data: { id: string; newStart: Date; newEnd: Date }) => void | — | Fires after a successful drop | | initialScrollTime | string | — | Scroll to this time on mount. Accepts "HH:MM" / "HH:MM:SS" or an ISO 8601 string. Uses instant (no-animation) scroll. | | className | string | — | CSS classes for the scroll container |

CalendarTimeGridBody is a forwardRef component. Pass a React.RefObject<CalendarTimeGridBodyHandle> to get imperative scroll control:

import { useRef } from "react";
import { CalendarTimeGridBody, type CalendarTimeGridBodyHandle } from "@zelkim/zui";

const bodyRef = useRef<CalendarTimeGridBodyHandle>(null);

// Scroll smoothly to 9 AM
bodyRef.current?.scrollToTime("09:00");

// Scroll instantly to a specific ISO time
bodyRef.current?.scrollToTime("2026-04-11T14:30:00", "instant");

<CalendarTimeGridBody ref={bodyRef} initialScrollTime="08:00" ...>

CalendarTimeGridBodyHandle methods:

| Method | Signature | Description | |--------|-----------|-------------| | scrollToTime | (time: string, behavior?: ScrollBehavior) => void | Scroll the container to the given time. behavior defaults to "smooth". |

Under the hood:

  1. Child scanning: Iterates React.Children to separate:
    • Time labels (children without a day prop) → placed in column 1
    • Day columns (children with a day prop) → each one's children are split into headers (matched by displayName === "CalendarTimeGridDayHeader") and tables
  2. Grid layout: Renders a single CSS grid: auto repeat(N, 1fr) where N = number of days. Row 1 = sticky headers. Row 2 = time labels + day tables. This ensures column widths stay perfectly aligned between headers and tables.
  3. Column registration: Provides a CalendarTimeGridBodyContext with registerColumn/unregisterColumn/getColumns. Each CalendarTimeGridTable registers its DOM ref and Date, enabling cross-column drag resolution.
  4. DnD context: When draggable is true, wraps the inner content with @dnd-kit/core's DndContext. On drag end:
    • Computes vertical delta → converts to minutes → snaps to 15-minute intervals
    • Computes horizontal target column by checking which registered column's bounding rect contains the drag's center X position (with nearest-column fallback)
    • Calls onItemDrop with the resolved { id, newStart, newEnd }
  5. Scroll control: Holds a ref to the scroll container div and exposes it via useImperativeHandle. initialScrollTime triggers an instant scroll in a useEffect on mount. scrollToTime calls scrollRef.current.scrollTo() with behavior forwarded — uses timeStringToScrollPx() for pixel conversion.

CalendarTimeGridTimeLabels

Renders the hour labels (e.g. "9:00 AM" or "09:00") in a column to the left of the day tables.

| Prop | Type | Description | |------|------|-------------| | className | string | Additional CSS classes (use w-16 for a nice width) |

Under the hood: Reads format from context. Renders 24 absolutely-positioned labels, each at hour * HOUR_HEIGHT_PX - 5 pixels from the top. Uses formatHourLabel() to format based on 12h/24h.

Constants: HOUR_HEIGHT_PX = 60, so the full grid is 1440px tall (24 × 60).

CalendarTimeGridDay

A logical grouping for one day. Provides context with the day's ISO string and parsed Date.

| Prop | Type | Description | |------|------|-------------| | day | string | ISO 8601 date string (e.g. "2026-04-08T00:00:00") | | className | string | Additional CSS classes |

Under the hood: Creates a CalendarTimeGridDayContext with { dayIso, date } using parseISO() from date-fns. Important: CalendarTimeGridBody actually re-provides this context when it restructures children into its grid layout, so the context is correctly scoped even though the DOM tree differs from the JSX tree.

CalendarTimeGridDayHeader

The clickable header cell for a day column. Renders inside the sticky header row.

| Prop | Type | Description | |------|------|-------------| | onClick | (date: Date) => void | Fires with the day's Date | | className | string | Additional CSS classes |

Under the hood: Has displayName = "CalendarTimeGridDayHeader" — this is how CalendarTimeGridBody identifies headers vs. tables when scanning children.

CalendarTimeGridDayHeaderWeekDay / CalendarTimeGridDayHeaderDate

Small display components that read from CalendarTimeGridDayContext:

  • WeekDay — renders the abbreviated weekday name (e.g. "MON") via date-fns format(date, "EEE")
  • Date — renders the day-of-month number (e.g. "8") via date.getDate()

Both accept a className prop.

CalendarTimeGridTable

The core time column. A 1440px-tall relatively-positioned container with 24 hourly gridlines.

| Prop | Type | Default | Description | |------|------|---------|-------------| | showMarker | boolean | false | Show the current-time marker (red line + dot) | | onTimeSlotClick | (datetime: Date) => void | — | Fires on click on empty area | | snap | number | — | Snap interval in minutes for click position and hover preview | | hoverPreview | boolean | false | Show a ghost block on mouse hover over empty areas | | hoverPreviewDuration | number | 30 | Duration of the hover preview block in minutes | | hoverPreviewClassName | string | "bg-primary/20 border-primary/40" | CSS classes for the preview | | resolveOverlaps | boolean | false | Auto-compute side-by-side layout for overlapping items | | className | string | — | Additional CSS classes |

Under the hood:

  1. Column registration: On mount, registers with CalendarTimeGridBodyContext so the body knows this column's DOM element and date (for cross-day drag resolution).
  2. Hour gridlines: 24 absolutely-positioned divs at hour * 60px.
  3. Time marker: When showMarker is true and markerTime exists in context, computes timeToTop(markerTime) and renders a red line + dot at that position.
  4. Hover preview: When hoverPreview is true, tracks mousemove via refs (zero re-renders). Converts Y position → minutes → snaps to snap interval → converts back to pixels. Hides when hovering over an existing item ([data-time-grid-item]).
  5. Click handling: Converts click Y position → minutes → optionally snaps → creates a Date. Only fires on empty areas (ignores clicks on items).
  6. Overlap resolution: When resolveOverlaps is true, wraps children with an internal OverlapResolver component that introspects each child's start/end props, runs computeOverlapLayout(), and clones items with _layoutLeft/_layoutWidth props injected.

CalendarTimeGridTableItem

An absolutely-positioned event block inside a CalendarTimeGridTable.

| Prop | Type | Default | Description | |------|------|---------|-------------| | id | string | auto-generated | Unique ID for drag-and-drop | | start | string | required | ISO 8601 start time | | end | string | required | ISO 8601 end time | | color | string | "bg-primary/20" | Tailwind color classes (background + border + text) | | onClick | () => void | — | Click handler | | disabled | boolean | false | Disable interactions | | className | string | — | Additional CSS classes |

Under the hood:

  1. Positioning: top = timeToFraction(start) * 60 pixels. height = (endFraction - startFraction) * 60 pixels, minimum 16px.
  2. Overlap layout: When _layoutLeft and _layoutWidth are injected (by the parent's OverlapResolver), uses calc() percentage positioning instead of the default left-1 right-1 (4px inset). Example: an item in column 1 of 3 gets left: calc(33.33% + 2px); width: calc(33.33% - 4px).
  3. Drag-and-drop: Always calls useDraggable() from @dnd-kit/core. During drag, the transform is snapped to 15-minute pixel increments vertically (DRAG_SNAP_PX = 15px) and allows free horizontal movement (for cross-day dragging). The visual snap formula: Math.round(transform.y / 15px) * 15px.
  4. Click isolation: e.stopPropagation() prevents the click from bubbling to the table's onTimeSlotClick.

CalendarTimeGrid Full Example

import { useState, useCallback, useRef } from "react";
import {
  CalendarTimeGrid,
  CalendarTimeGridBody,
  type CalendarTimeGridBodyHandle,
  CalendarTimeGridTimeLabels,
  CalendarTimeGridHeader,
  CalendarTimeGridHeaderButton,
  CalendarTimeGridDay,
  CalendarTimeGridDayHeader,
  CalendarTimeGridDayHeaderWeekDay,
  CalendarTimeGridDayHeaderDate,
  CalendarTimeGridTable,
  CalendarTimeGridTableItem,
} from "@zelkim/zui";

interface Event {
  id: string;
  start: string; // local ISO, e.g. "2026-04-06T09:00:00"
  end: string;
  title: string;
  color: string;
}

function WeekCalendar() {
  const [events, setEvents] = useState<Event[]>([
    {
      id: "1",
      start: "2026-04-06T09:00:00",
      end: "2026-04-06T10:30:00",
      title: "Team Standup",
      color: "bg-blue-100 border-blue-300 text-blue-900",
    },
    {
      id: "2",
      start: "2026-04-06T09:30:00",
      end: "2026-04-06T10:00:00",
      title: "Quick Sync",
      color: "bg-pink-100 border-pink-300 text-pink-900",
    },
    {
      id: "3",
      start: "2026-04-07T14:00:00",
      end: "2026-04-07T15:00:00",
      title: "Design Review",
      color: "bg-purple-100 border-purple-300 text-purple-900",
    },
  ]);
  const counterRef = useRef(4);
  const bodyRef = useRef<CalendarTimeGridBodyHandle>(null);

  const weekDays = [
    "2026-04-06T00:00:00",
    "2026-04-07T00:00:00",
    "2026-04-08T00:00:00",
    "2026-04-09T00:00:00",
    "2026-04-10T00:00:00",
  ];

  // Create event on empty slot click
  const handleSlotClick = useCallback((datetime: Date) => {
    const id = String(counterRef.current++);
    const start = datetime;
    const end = new Date(datetime);
    end.setMinutes(end.getMinutes() + 30);

    const pad = (n: number) => String(n).padStart(2, "0");
    const toISO = (d: Date) =>
      `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;

    setEvents((prev) => [
      ...prev,
      { id, start: toISO(start), end: toISO(end), title: `Event ${id}`, color: "bg-green-100 border-green-300 text-green-900" },
    ]);
  }, []);

  // Move event on drop
  const handleDrop = useCallback((data: { id: string; newStart: Date; newEnd: Date }) => {
    const pad = (n: number) => String(n).padStart(2, "0");
    const toISO = (d: Date) =>
      `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;

    setEvents((prev) =>
      prev.map((ev) =>
        ev.id === data.id
          ? { ...ev, start: toISO(data.newStart), end: toISO(data.newEnd) }
          : ev
      )
    );
  }, []);

  const eventsForDay = (dayIso: string) => {
    const dayStr = dayIso.split("T")[0];
    return events.filter((ev) => ev.start.startsWith(dayStr));
  };

  return (
    <CalendarTimeGrid format="12" markerTime={new Date().toISOString()}>
      <CalendarTimeGridHeader>
        <CalendarTimeGridHeaderButton type="previous" onClick={() => {}} />
        <CalendarTimeGridHeaderButton type="default">
          April 6 – 10, 2026
        </CalendarTimeGridHeaderButton>
        <CalendarTimeGridHeaderButton type="next" onClick={() => {}} />
      </CalendarTimeGridHeader>

      <CalendarTimeGridBody
        ref={bodyRef}
        className="max-h-[600px]"
        draggable
        initialScrollTime="08:00"
        onItemDrop={handleDrop}
      >
        <CalendarTimeGridTimeLabels className="w-16" />

        {weekDays.map((dayIso) => (
          <CalendarTimeGridDay key={dayIso} day={dayIso}>
            <CalendarTimeGridDayHeader>
              <CalendarTimeGridDayHeaderWeekDay />
              <CalendarTimeGridDayHeaderDate />
            </CalendarTimeGridDayHeader>

            <CalendarTimeGridTable
              showMarker={dayIso.startsWith("2026-04-08")}
              snap={15}
              hoverPreview
              hoverPreviewDuration={30}
              resolveOverlaps
              onTimeSlotClick={handleSlotClick}
            >
              {eventsForDay(dayIso).map((ev) => (
                <CalendarTimeGridTableItem
                  key={ev.id}
                  id={ev.id}
                  start={ev.start}
                  end={ev.end}
                  color={ev.color}
                >
                  <div className="font-medium">{ev.title}</div>
                </CalendarTimeGridTableItem>
              ))}
            </CalendarTimeGridTable>
          </CalendarTimeGridDay>
        ))}
      </CalendarTimeGridBody>
    </CalendarTimeGrid>
  );
}

Overlap Resolution

When multiple events share the same time slots, @zelkim/zui can automatically display them side-by-side with intelligently computed widths.

How It Works

The layout algorithm runs in 4 phases:

  1. Sort by priority — longer events sort first (duration descending). Ties broken by earlier start time (ascending). This means longer events get the leftmost column.

  2. Build overlap clusters — uses union-find to group events into connected components. Two events overlap when startA < endB && startB < endA (strict inequality — abutting events like 9:00–10:00 and 10:00–11:00 do not conflict).

  3. Assign columns — within each cluster, events are processed in priority order and greedily assigned to the leftmost column where they don't conflict with any already-placed event.

  4. Compute variable-width spans — each event expands rightward into empty adjacent columns. For example, if an event is in column 0 of a 3-column cluster, and columns 1 and 2 are empty at that time slice, it expands to span all 3 columns at full width.

Edge cases handled:

  • Cascading overlaps (A↔B, B↔C, but not A↔C): all three are in one cluster, but A and C can share a column
  • Containment (short event fully inside a long one): the long event gets priority (more left)
  • Single events: full width, no layout changes
  • 4+ concurrent events: columns created as needed, each event sized proportionally

Using resolveOverlaps

The simplest approach — add resolveOverlaps to CalendarTimeGridTable:

<CalendarTimeGridTable resolveOverlaps>
  <CalendarTimeGridTableItem start="2026-04-06T09:00:00" end="2026-04-06T10:30:00" id="a" ...>
    Long Meeting
  </CalendarTimeGridTableItem>
  <CalendarTimeGridTableItem start="2026-04-06T09:30:00" end="2026-04-06T10:00:00" id="b" ...>
    Quick Sync
  </CalendarTimeGridTableItem>
</CalendarTimeGridTable>

The table introspects each item's start/end/id props, computes the layout, and injects _layoutLeft / _layoutWidth automatically via React.cloneElement. Zero consumer effort.

Using computeOverlapLayout Directly

For advanced use cases (e.g., server-rendered layout, non-React consumers, or custom positioning logic), the pure function is exported:

import { computeOverlapLayout, type LayoutItem, type LayoutResult } from "@zelkim/zui";

const items: LayoutItem[] = [
  { id: "a", startMinutes: 540, endMinutes: 630 },  // 9:00–10:30
  { id: "b", startMinutes: 570, endMinutes: 600 },  // 9:30–10:00
  { id: "c", startMinutes: 600, endMinutes: 690 },  // 10:00–11:30
];

const layout: Map<string, LayoutResult> = computeOverlapLayout(items);

// layout.get("a") → { column: 0, totalColumns: 2, span: 1, leftFraction: 0, widthFraction: 0.5 }
// layout.get("b") → { column: 1, totalColumns: 2, span: 1, leftFraction: 0.5, widthFraction: 0.5 }
// layout.get("c") → { column: 1, totalColumns: 2, span: 1, leftFraction: 0.5, widthFraction: 0.5 }

LayoutItem:

| Field | Type | Description | |-------|------|-------------| | id | string | Unique identifier | | startMinutes | number | Start time in minutes from midnight (0–1440) | | endMinutes | number | End time in minutes from midnight (0–1440) |

LayoutResult:

| Field | Type | Description | |-------|------|-------------| | column | number | 0-based column index | | totalColumns | number | Total columns in this overlap cluster | | span | number | Number of columns this item spans (variable width) | | leftFraction | number | Fractional left offset (0–1) | | widthFraction | number | Fractional width (0–1) |


Drag and Drop

Drag-and-drop is powered by @dnd-kit/core and works across all day columns.

Enable it: Set draggable on CalendarTimeGridBody and provide an onItemDrop handler.

<CalendarTimeGridBody
  draggable
  onItemDrop={({ id, newStart, newEnd }) => {
    // Update your event state with the new times
    setEvents((prev) =>
      prev.map((ev) =>
        ev.id === id ? { ...ev, start: toISO(newStart), end: toISO(newEnd) } : ev
      )
    );
  }}
>

How it works:

  1. Each CalendarTimeGridTableItem registers as a draggable via useDraggable().
  2. A PointerSensor with a 5px activation distance prevents accidental drags on click.
  3. During drag, the item's transform snaps visually to 15-minute increments (15px vertical steps) and follows the cursor horizontally.
  4. On drop, CalendarTimeGridBody's handleDragEnd:
    • Converts vertical pixel delta → minutes → snaps to 15-minute grid
    • Resolves the target day column by checking which registered column's bounding rect contains the dragged item's translated center X
    • Falls back to nearest column by center distance
    • Clamps time to 0–1440 minutes (midnight to midnight)
    • Calls onItemDrop with { id, newStart, newEnd }
  5. The event's duration is preserved during drag — only the start time changes.

Important: onItemDrop gives you Date objects. You're responsible for updating your state. The library doesn't manage event state — it just computes and reports the new position.


Hover Preview & Snapping

Show a ghost preview block when hovering over empty time slots, and snap both hover and click positions to configurable intervals.

<CalendarTimeGridTable
  hoverPreview
  hoverPreviewDuration={30}  // 30-minute ghost block
  snap={15}                  // snap to 15-minute intervals
  hoverPreviewClassName="bg-blue-100/30 border-blue-200"  // custom styling
  onTimeSlotClick={(datetime) => createEvent(datetime)}
>

| Prop | Effect | |------|--------| | hoverPreview | Enables the ghost block | | hoverPreviewDuration | Height of the ghost in minutes (default: 30 → 30px tall) | | snap | Snaps hover position AND click position to N-minute intervals | | hoverPreviewClassName | Tailwind classes for the ghost (default: bg-primary/20 border-primary/40) |

Performance: The hover preview uses direct DOM manipulation via refs — no React re-renders on every mouse move. The preview div is hidden/shown and repositioned via style.display and style.top.

The preview automatically hides when hovering over existing event items (detected via [data-time-grid-item] selector).


Customization

Every component accepts a className prop that merges with defaults via tailwind-merge + clsx:

<CalendarViewGrid className="bg-gray-50 rounded-lg" />
<CalendarTimeGridTable className="bg-white" />
<CalendarTimeGridTableItem className="shadow-md" />

Navigation buttons accept icon to replace default chevrons:

<CalendarViewHeaderButton type="previous" icon={<MyLeftArrow />} />
<CalendarTimeGridHeaderButton type="next" icon={<MyRightArrow />} />

Event items accept color as a string of Tailwind classes:

<CalendarTimeGridTableItem color="bg-red-100 border-red-300 text-red-900" />

Architecture Notes

Context System

@zelkim/zui uses a layered context architecture:

| Context | Provider | Consumers | Data | |---------|----------|-----------|------| | CalendarViewContext | CalendarView | Header, Grid, Day | month, year, day, callbacks | | CalendarTimeGridContext | CalendarTimeGrid | TimeLabels, Table | format, markerTime | | CalendarTimeGridDayContext | CalendarTimeGridBody (re-provided) | DayHeader, Table, TableItem | dayIso, date | | CalendarTimeGridBodyContext | CalendarTimeGridBody | Table | registerColumn, getColumns |

CSS Grid Layout

CalendarTimeGridBody uses a single CSS grid for the entire scroll area:

[auto] [1fr] [1fr] [1fr] [1fr] [1fr]
┌──────┬──────┬──────┬──────┬──────┬──────┐
│      │ MON  │ TUE  │ WED  │ THU  │ FRI  │  ← Row 1: sticky headers
│      │  6   │  7   │  8   │  9   │ 10   │
├──────┼──────┼──────┼──────┼──────┼──────┤
│ Time │      │      │      │      │      │  ← Row 2: labels + tables
│Labels│ Day  │ Day  │ Day  │ Day  │ Day  │
│      │Tables│Tables│Tables│Tables│Tables│
└──────┴──────┴──────┴──────┴──────┴──────┘

This ensures column widths are perfectly shared between header cells and table cells — no width mismatch possible.

Time Positioning Constants

| Constant | Value | Purpose | |----------|-------|---------| | HOUR_HEIGHT_PX | 60 | Pixels per hour row | | TOTAL_GRID_HEIGHT_PX | 1440 | Total grid height (24 × 60) | | DRAG_SNAP_PX | 15 | Pixels per 15-minute snap interval |

Time Utility Functions

| Function | Signature | Description | |----------|-----------|-------------| | timeToFraction | (iso: string) => number | ISO → fractional hour (0–24) | | timeToTop | (iso: string) => number | ISO → pixel top position | | timeRangeToHeight | (start: string, end: string) => number | Two ISOs → pixel height | | yPositionToDate | (y: number, refDate: Date) => Date | Pixel Y → Date | | formatHourLabel | (hour: number, format: "12"\|"24") => string | Hour → display string | | timeStringToScrollPx | (time: string) => number | "HH:MM" or ISO → scroll pixel offset |


Running the Demo

git clone <repo-url>
cd zcal

# Build the library
npm install
npm run build

# Run the demo
cd examples/demo
npm install
npm run dev

The demo includes both CalendarView and CalendarTimeGrid tabs with full interactivity: click-to-create, drag-to-move, overlap resolution, hover previews, and week navigation.


License

MIT