A vertical day timeline with exact hour gridlines, overlap-aware event blocks that tile into computed columns, and a ticking now-line that pauses offscreen and in hidden tabs.
npx shadcn@latest add @paragon/day-scheduleAlso installs: calendar
"use client";
import * as React from "react";
import { motion, useInView, useReducedMotion } from "motion/react";
import { sameDay, startOfDay } from "@/registry/paragon/ui/calendar";
import { cn } from "@/lib/utils";
export type ScheduleTone = "default" | "primary" | "success" | "warning" | "destructive";
export interface ScheduleEvent {
id: string;
start: Date;
end: Date;
title: string;
location?: string;
tone?: ScheduleTone;
}
const TONE_BLOCK: Record<ScheduleTone, string> = {
default:
"border-muted-foreground/50 bg-secondary/80 hover:bg-secondary text-foreground",
primary: "border-primary bg-primary/8 hover:bg-primary/12 text-foreground",
success: "border-success bg-success/10 hover:bg-success/15 text-foreground",
warning: "border-warning bg-warning/12 hover:bg-warning/18 text-foreground",
destructive:
"border-destructive bg-destructive/8 hover:bg-destructive/12 text-foreground",
};
const minutesOf = (d: Date) => d.getHours() * 60 + d.getMinutes();
const hourLabel = (h: number) =>
h === 0 || h === 24
? "12 AM"
: h === 12
? "12 PM"
: h < 12
? `${h} AM`
: `${h - 12} PM`;
const timeLabel = (d: Date) => {
const h = d.getHours();
const m = d.getMinutes();
const hr = ((h + 11) % 12) + 1;
return `${hr}${m ? `:${String(m).padStart(2, "0")}` : ""} ${h < 12 ? "AM" : "PM"}`;
};
interface Positioned {
event: ScheduleEvent;
top: number;
height: number;
col: number;
cols: number;
}
/**
* Overlap layout: cluster transitively-overlapping events, then assign
* each to the first column whose previous occupant has ended. Every
* member of a cluster shares the cluster's column count, so widths tile
* the row exactly.
*/
function layoutEvents(
events: ScheduleEvent[],
startMin: number,
endMin: number,
pxPerMin: number,
): Positioned[] {
const sorted = events
.filter((e) => minutesOf(e.end) > startMin && minutesOf(e.start) < endMin)
.sort(
(a, b) =>
minutesOf(a.start) - minutesOf(b.start) ||
minutesOf(b.end) - minutesOf(a.end),
);
const out: Positioned[] = [];
let cluster: { item: Positioned; endMin: number }[] = [];
let clusterEnd = -1;
let columns: number[] = []; // end minute occupying each column
const flush = () => {
for (const { item } of cluster) item.cols = columns.length;
cluster = [];
columns = [];
};
for (const ev of sorted) {
const s = Math.max(minutesOf(ev.start), startMin);
const e = Math.min(minutesOf(ev.end), endMin);
if (s >= clusterEnd && cluster.length > 0) flush();
let col = columns.findIndex((end) => end <= s);
if (col === -1) {
col = columns.length;
columns.push(e);
} else {
columns[col] = e;
}
const item: Positioned = {
event: ev,
top: (s - startMin) * pxPerMin,
height: Math.max(24, (e - s) * pxPerMin - 2),
col,
cols: 1,
};
out.push(item);
cluster.push({ item, endMin: e });
clusterEnd = Math.max(clusterEnd, e);
}
flush();
return out;
}
export interface DayScheduleProps
extends Omit<React.ComponentProps<"div">, "onSelect"> {
/** The day being displayed. */
date?: Date;
events?: ScheduleEvent[];
/** Visible range, in whole hours (24h clock). */
startHour?: number;
endHour?: number;
/** Pixels per hour. */
hourHeight?: number;
/** Base "now" for the ticking line. Pass a fixed date for
* deterministic renders; the line still advances from it. */
now?: Date;
onEventSelect?: (event: ScheduleEvent) => void;
/** Disables the mount stagger. */
static?: boolean;
}
/**
* A vertical day timeline: exact hour gridlines, overlap-aware event
* blocks that tile shared columns, and a now-line that ticks every half
* minute — pausing while the tab is hidden or the timeline offscreen.
*/
export function DaySchedule({
date,
events = [],
startHour = 7,
endHour = 19,
hourHeight = 56,
now,
onEventSelect,
static: isStatic = false,
className,
"aria-label": ariaLabel,
...props
}: DayScheduleProps) {
const reduced = useReducedMotion() ?? false;
const rootRef = React.useRef<HTMLDivElement>(null);
const inView = useInView(rootRef, { amount: 0.05 });
const [fallbackNow] = React.useState(() => new Date());
const base = now ?? fallbackNow;
const baseTime = base.getTime();
const day = date ? startOfDay(date) : startOfDay(base);
// Ticks from the base instant: elapsed wall time is added in an
// effect (never during render), and the interval only runs while the
// component is in view and the tab visible.
const [elapsed, setElapsed] = React.useState(0);
React.useEffect(() => {
if (!inView) return;
const mountedAt = Date.now();
const initial = elapsed; // eslint-disable-line react-hooks/exhaustive-deps
const update = () => setElapsed(initial + (Date.now() - mountedAt));
const id = setInterval(() => {
if (!document.hidden) update();
}, 30_000);
const onVisible = () => {
if (!document.hidden) update();
};
document.addEventListener("visibilitychange", onVisible);
return () => {
clearInterval(id);
document.removeEventListener("visibilitychange", onVisible);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inView, baseTime]);
const current = new Date(baseTime + elapsed);
const startMin = startHour * 60;
const endMin = endHour * 60;
const pxPerMin = hourHeight / 60;
const totalHeight = (endMin - startMin) * pxPerMin;
const positioned = React.useMemo(
() => layoutEvents(events, startMin, endMin, pxPerMin),
[events, startMin, endMin, pxPerMin],
);
const nowMin = minutesOf(current);
const showNowLine =
sameDay(current, day) && nowMin >= startMin && nowMin <= endMin;
const hours = Array.from(
{ length: endHour - startHour + 1 },
(_, i) => startHour + i,
);
return (
<div
ref={rootRef}
className={cn("flex w-full min-w-0 select-none", className)}
aria-label={ariaLabel ?? "Day schedule"}
{...props}
>
{/* Hour gutter */}
<div
className="relative w-12 shrink-0"
style={{ height: totalHeight }}
aria-hidden
>
{hours.map((h, i) => (
<span
key={h}
className="absolute right-2 -translate-y-1/2 text-[10px] text-muted-foreground tabular-nums"
style={{ top: i * hourHeight }}
>
{hourLabel(h)}
</span>
))}
</div>
{/* Timeline */}
<div className="relative min-w-0 flex-1" style={{ height: totalHeight }}>
{hours.map((h, i) => (
<div
key={h}
aria-hidden
className={cn(
"absolute inset-x-0 border-t",
i === 0 || i === hours.length - 1
? "border-border"
: "border-border/70",
)}
style={{ top: i * hourHeight }}
/>
))}
<div role="list" aria-label="Events">
{positioned.map(({ event, top, height, col, cols }, i) => {
const tone = event.tone ?? "default";
return (
<motion.button
key={event.id}
role="listitem"
type="button"
initial={
isStatic || reduced
? false
: { opacity: 0, y: 8, filter: "blur(4px)" }
}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
transition={{
duration: 0.25,
ease: [0.22, 1, 0.36, 1],
delay: Math.min(i * 0.04, 0.24),
}}
onClick={() => onEventSelect?.(event)}
aria-label={`${event.title}, ${timeLabel(event.start)} to ${timeLabel(event.end)}${event.location ? `, ${event.location}` : ""}`}
className={cn(
"absolute overflow-hidden rounded-md border-l-2 px-2 py-1 text-left outline-none",
"transition-[background-color,box-shadow,scale] duration-150 ease-out focus-visible:z-20 focus-visible:ring-2 focus-visible:ring-ring active:not-disabled:scale-[0.99]",
TONE_BLOCK[tone],
)}
style={{
top,
height,
left: `calc(${(col / cols) * 100}% + ${col === 0 ? 6 : 2}px)`,
width: `calc(${(1 / cols) * 100}% - ${col === 0 ? 8 : 4}px)`,
}}
>
<span className="block truncate text-xs leading-4 font-medium">
{event.title}
</span>
<span className="block truncate text-[10px] leading-3.5 text-muted-foreground tabular-nums">
{timeLabel(event.start)} – {timeLabel(event.end)}
{event.location ? ` · ${event.location}` : ""}
</span>
</motion.button>
);
})}
</div>
{showNowLine && (
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 z-10"
style={{
transform: `translateY(${(nowMin - startMin) * pxPerMin}px)`,
}}
>
<div className="relative">
<span className="absolute top-1/2 -left-1 size-2 -translate-y-1/2 rounded-full bg-destructive" />
<div className="border-t border-destructive" />
</div>
</div>
)}
</div>
</div>
);
}