Upcoming events by day with a Now line and a Join that counts down.
01Preview
Up next
02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:
npm install motion @number-flow/react03Usage
import { Agenda } from "@/components/ui/agenda";
<Agenda
title="Up next"
events={events}
onEventClick={(event) => openEvent(event.id)}
onCreate={() => setComposerOpen(true)}
className="h-[480px]"
/>04Source
"use client";
import NumberFlow, { NumberFlowGroup } from "@number-flow/react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { cn } from "@/lib/cn";
import { ArrowDown, ArrowUp, ChevronDown, Plus } from "@/lib/icons";
import { ease, spring } from "@/lib/motion";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
export type EventTone = "neutral" | "info" | "success" | "warning" | "danger";
export type AgendaEvent = {
id: string;
title: string;
start: Date;
/** Exclusive. */
end: Date;
allDay?: boolean;
location?: string;
/** A video call link. Shows a Join button until the event ends. */
meetingUrl?: string;
/** Short line under the title: attendees, a project, a room. */
detail?: string;
/** Meaning only: a deadline, an outage, a launch. */
tone?: EventTone;
status?: "confirmed" | "tentative" | "canceled";
};
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate());
const addDays = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
const dayNum = (d: Date) => d.getFullYear() * 10000 + d.getMonth() * 100 + d.getDate();
const cmpDay = (a: Date, b: Date) => dayNum(a) - dayNum(b);
const diffDays = (a: Date, b: Date) =>
Math.round((Date.UTC(b.getFullYear(), b.getMonth(), b.getDate()) - Date.UTC(a.getFullYear(), a.getMonth(), a.getDate())) / 864e5);
const pad = (n: number) => String(n).padStart(2, "0");
const dayKey = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
const isAllDay = (e: AgendaEvent) => !!e.allDay || e.end.getTime() - e.start.getTime() >= 864e5;
const lastDay = (e: AgendaEvent) => {
const last = startOfDay(new Date(Math.max(e.end.getTime() - 1, e.start.getTime())));
return cmpDay(last, e.start) < 0 ? startOfDay(e.start) : last;
};
const duration = (mins: number) => (mins < 60 ? `${mins}m` : mins % 60 ? `${Math.floor(mins / 60)}h ${mins % 60}m` : `${mins / 60}h`);
function subscribeLanguage(notify: () => void) {
window.addEventListener("languagechange", notify);
return () => window.removeEventListener("languagechange", notify);
}
const useLocale = (locale?: string) => {
const browser = useSyncExternalStore(subscribeLanguage, () => navigator.language, () => "en-US");
return locale ?? browser;
};
/**
* A clock that ticks every `ms`, aligned to the boundary, and resyncs when the tab
* comes back. Null during the server render so nothing time-relative is prerendered.
*/
export function useClock(ms: number, override?: Date) {
const subscribe = useCallback(
(notify: () => void) => {
let timer = 0;
const arm = () => {
timer = window.setTimeout(() => (notify(), arm()), ms - (Date.now() % ms) + 10);
};
arm();
const onVisible = () => document.visibilityState === "visible" && notify();
document.addEventListener("visibilitychange", onVisible);
return () => {
window.clearTimeout(timer);
document.removeEventListener("visibilitychange", onVisible);
};
},
[ms],
);
const t = useSyncExternalStore(subscribe, () => Math.floor(Date.now() / ms) * ms, () => null);
return useMemo(() => override ?? (t === null ? null : new Date(t)), [t, override]);
}
/* ------------------------------------------------------------------ */
/* Grouping */
/* ------------------------------------------------------------------ */
type Row = { event: AgendaEvent; key: string; allDay: boolean; dayIndex: number; dayCount: number };
type Group = { date: Date; key: string; rows: Row[] };
/** Events from today on, one group per day. Multi-day events appear on each day they cover. */
export function groupByDay(events: AgendaEvent[], today: Date, days: number): Group[] {
const groups = new Map<string, Group>();
const ensure = (d: Date) => {
const k = dayKey(d);
let g = groups.get(k);
if (!g) groups.set(k, (g = { date: d, key: k, rows: [] }));
return g;
};
ensure(today);
const horizon = addDays(today, days - 1);
for (const ev of events) {
const allDay = isAllDay(ev);
const first = startOfDay(ev.start);
const last = allDay ? lastDay(ev) : first;
const count = diffDays(first, last) + 1;
for (let i = 0; i < count; i++) {
const d = addDays(first, i);
if (cmpDay(d, today) < 0 || cmpDay(d, horizon) > 0) continue;
ensure(d).rows.push({ event: ev, key: count > 1 ? `${ev.id}@${dayKey(d)}` : ev.id, allDay, dayIndex: i + 1, dayCount: count });
}
}
return [...groups.values()]
.sort((a, b) => cmpDay(a.date, b.date))
.map((g) => ({
...g,
rows: g.rows.sort((a, b) => Number(b.allDay) - Number(a.allDay) || a.event.start.getTime() - b.event.start.getTime() || a.event.end.getTime() - b.event.end.getTime()),
}));
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export type AgendaProps = Omit<React.ComponentProps<"div">, "children" | "title"> & {
events: AgendaEvent[];
/** Heading shown above the list, with the time beside it. */
title?: React.ReactNode;
/** How many days ahead to list, today included. */
days?: number;
/** Minutes before the start when Join becomes the primary action. */
joinLeadMinutes?: number;
/** Called when Join is pressed. Call event.preventDefault() to handle the link yourself. */
onJoin?: (event: AgendaEvent, e: React.MouseEvent<HTMLAnchorElement>) => void;
onEventClick?: (event: AgendaEvent) => void;
/** Shows a New event action in the empty state. */
onCreate?: () => void;
loading?: boolean;
locale?: string;
/** Pin the clock for tests and screenshots. */
now?: Date;
labels?: { today?: string; tomorrow?: string; now?: string; join?: string; joinNow?: string; empty?: string; nothingElse?: string; newEvent?: string };
};
type JoinState = "none" | "idle" | "soon" | "live";
export function Agenda({
events,
title,
days = 14,
joinLeadMinutes = 5,
onJoin,
onEventClick,
onCreate,
loading = false,
locale: localeProp,
now: nowProp,
labels,
className,
...rest
}: AgendaProps) {
const L = {
today: "Today",
tomorrow: "Tomorrow",
now: "Now",
join: "Join",
joinNow: "Join now",
empty: "No upcoming events",
nothingElse: "Nothing else today",
newEvent: "New event",
...labels,
};
const reduce = !!useReducedMotion();
const locale = useLocale(localeProp);
const lead = joinLeadMinutes * 60000;
// Tick every second only while a Join countdown is on screen; otherwise once a minute.
const minute = useClock(60000, nowProp);
const counting = useMemo(() => {
if (!minute) return false;
const t = minute.getTime();
return events.some((e) => e.meetingUrl && e.start.getTime() - t <= lead + 60000 && e.start.getTime() - t > -60000);
}, [events, minute, lead]);
const [visible, setVisible] = useState(true);
const second = useClock(counting && visible ? 1000 : 60000, nowProp);
const now = second ?? minute;
const today = useMemo(() => (now ? startOfDay(now) : null), [now]);
const nowMs = now?.getTime() ?? 0;
const fmt = useMemo(() => {
const make = (o: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat(locale, o);
return {
weekday: make({ weekday: "long" }),
date: make({ month: "short", day: "numeric" }),
time: make({ hour: "numeric", minute: "2-digit" }),
long: make({ weekday: "long", day: "numeric", month: "long" }),
};
}, [locale]);
const groups = useMemo(() => (today && !loading ? groupByDay(events, today, days) : null), [events, today, days, loading]);
const upcomingCount = useMemo(() => {
const ids = new Set<string>();
for (const g of groups ?? []) for (const r of g.rows) if (r.event.end.getTime() > nowMs && r.event.status !== "canceled") ids.add(r.event.id);
return ids.size;
}, [groups, nowMs]);
/* ---------- scrolling and the Now marker ---------- */
const rootRef = useRef<HTMLDivElement>(null);
const scrollerRef = useRef<HTMLDivElement>(null);
// State, not a ref: the marker remounts further down the list as events start, and the observer must follow it.
const [markerEl, setMarkerEl] = useState<HTMLLIElement | null>(null);
const [markerSide, setMarkerSide] = useState<"above" | "below" | null>(null);
const [showEarlier, setShowEarlier] = useState(false);
// Pause the per-second tick when the list is off screen.
useEffect(() => {
const el = rootRef.current;
if (!el) return;
const io = new IntersectionObserver(([e]) => setVisible(e.isIntersecting));
io.observe(el);
return () => io.disconnect();
}, []);
useEffect(() => {
const scroller = scrollerRef.current;
const marker = markerEl;
if (!scroller || !marker) return;
// Today is always the first group and finished events fold away, so the list opens at now by itself.
const io = new IntersectionObserver(
([e]) => {
if (e.isIntersecting) return setMarkerSide(null);
setMarkerSide(e.boundingClientRect.top < (e.rootBounds?.top ?? 0) + 40 ? "above" : "below");
},
{ root: scroller, rootMargin: "-36px 0px 0px 0px" },
);
io.observe(marker);
return () => io.disconnect();
}, [markerEl]);
const jumpToNow = () => {
const scroller = scrollerRef.current;
// Land on the call in progress if there is one, so it isn't hidden just above the marker.
const target = scroller?.querySelector<HTMLElement>("[data-live]") ?? markerEl;
if (!scroller || !target) return;
scroller.scrollTo({ top: Math.max(0, target.offsetTop - 44), behavior: reduce ? "auto" : "smooth" });
};
/* ---------- keyboard: arrows and j/k between rows ---------- */
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
const keys = ["ArrowDown", "ArrowUp", "j", "k", "Home", "End"];
if (!keys.includes(e.key) || e.metaKey || e.ctrlKey || e.altKey) return;
// Folded-away earlier events are inert, so they're skipped.
const rows = [...(scrollerRef.current?.querySelectorAll<HTMLElement>("[data-agenda-row]") ?? [])].filter((r) => !r.closest("[inert]"));
const i = rows.indexOf(document.activeElement as HTMLElement);
if (i === -1 && !(e.target as HTMLElement).closest("[data-agenda-row], [data-join]")) return;
e.preventDefault();
const from = i === -1 ? rows.indexOf((e.target as HTMLElement).closest("li")?.querySelector<HTMLElement>("[data-agenda-row]") ?? rows[0]) : i;
const next = e.key === "Home" ? 0 : e.key === "End" ? rows.length - 1 : Math.max(0, Math.min(rows.length - 1, from + (e.key === "ArrowDown" || e.key === "j" ? 1 : -1)));
rows[next]?.focus();
rows[next]?.scrollIntoView({ block: "nearest" });
};
const joinState = (e: AgendaEvent): JoinState => {
if (!e.meetingUrl || e.status === "canceled" || !now) return "none";
const s = e.start.getTime();
const end = e.end.getTime();
if (nowMs >= end) return "none";
if (nowMs >= s) return "live";
if (s - nowMs <= lead) return "soon";
return "idle";
};
const dayLabel = (d: Date) => {
if (!today) return "";
const diff = diffDays(today, d);
return diff === 0 ? L.today : diff === 1 ? L.tomorrow : fmt.weekday.format(d);
};
return (
<div
ref={rootRef}
data-slot="agenda"
className={cn("relative flex min-h-[320px] min-w-0 flex-col overflow-hidden rounded-xl border border-line bg-raised shadow-[var(--shadow)]", className)}
onKeyDown={onKeyDown}
{...rest}
>
{title && (
<div className="flex h-12 shrink-0 items-center justify-between gap-3 border-b border-line px-4">
<h2 className="truncate text-[14px] font-medium tracking-[-0.015em] text-fg">{title}</h2>
<span className="shrink-0 text-[12px] text-fg-3 tabular" suppressHydrationWarning>
{now ? `${upcomingCount === 0 ? "Nothing" : upcomingCount === 1 ? "1 event" : `${upcomingCount} events`} ahead` : ""}
</span>
</div>
)}
<div ref={scrollerRef} className="relative min-h-0 flex-1 overflow-y-auto overscroll-contain" aria-busy={loading || !now || undefined}>
{!groups ? (
<Skeleton />
) : upcomingCount === 0 && groups.every((g) => g.rows.length === 0) ? (
<div className="flex h-full min-h-[220px] flex-col items-center justify-center gap-3 px-6 text-center">
<span className="grid size-9 place-items-center rounded-full bg-hover text-fg-3">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<rect x="2.5" y="3.25" width="11" height="10.25" rx="1.75" />
<path d="M2.5 6.5h11M5.5 2v2.5M10.5 2v2.5" />
</svg>
</span>
<p className="text-[13px] text-fg-2">{L.empty}</p>
{onCreate && (
<button type="button" onClick={onCreate} className={cn(btn, "gap-1.5 bg-fg px-3 text-frame hover:bg-fg/90")}>
<Plus size={14} />
{L.newEvent}
</button>
)}
</div>
) : (
groups.map((g) => {
const isToday = today && cmpDay(g.date, today) === 0;
const past = isToday ? g.rows.filter((r) => !r.allDay && r.event.end.getTime() <= nowMs) : [];
const current = isToday ? g.rows.filter((r) => r.allDay || r.event.end.getTime() > nowMs) : g.rows;
// The marker sits before the first event that hasn't started; in-progress events stay above it.
const markerAt = isToday ? current.findIndex((r) => !r.allDay && r.event.start.getTime() > nowMs) : -1;
const nextUp = markerAt >= 0 ? current[markerAt] : null;
if (!isToday && g.rows.length === 0) return null;
return (
<section key={g.key} aria-labelledby={`agenda-${g.key}`}>
<h3
id={`agenda-${g.key}`}
className="sticky top-0 z-[2] flex h-9 items-baseline gap-2 border-b border-line bg-raised px-4 pt-2.5"
>
<span className={cn("text-[12.5px] font-medium", isToday ? "text-fg" : "text-fg-2")}>{dayLabel(g.date)}</span>
<span className="text-[12px] text-fg-3 tabular">{fmt.date.format(g.date)}</span>
</h3>
<ul className="flex flex-col py-1">
{past.length > 0 && (
<li>
<button
type="button"
onClick={() => setShowEarlier((s) => !s)}
aria-expanded={showEarlier}
className="flex h-8 w-full items-center gap-1.5 px-4 text-left text-[12px] text-fg-3 outline-none transition-colors duration-150 hover:text-fg focus-visible:bg-hover"
>
<ChevronDown size={14} className={cn("transition-transform duration-200 ease-out-expo", !showEarlier && "-rotate-90")} />
{showEarlier ? "Hide" : "Show"} {past.length === 1 ? "1 earlier event" : `${past.length} earlier events`}
</button>
<div className={cn("grid transition-[grid-template-rows] duration-250 ease-in-out-quart motion-reduce:transition-none", showEarlier ? "grid-rows-[1fr]" : "grid-rows-[0fr]")}>
<ul className="min-h-0 overflow-hidden" inert={!showEarlier}>
{past.map((r) => (
<AgendaRow key={r.key} row={r} state="past" join="none" now={nowMs} fmt={fmt} L={L} reduce={reduce} onJoin={onJoin} onEventClick={onEventClick} />
))}
</ul>
</div>
</li>
)}
{current.map((r, i) => {
const live = !r.allDay && r.event.start.getTime() <= nowMs && r.event.end.getTime() > nowMs;
return (
<FragmentWithMarker
key={r.key}
marker={i === markerAt ? <NowMarker ref={setMarkerEl} label={L.now} time={now ? fmt.time.format(now) : ""} /> : null}
>
<AgendaRow
row={r}
state={live ? "live" : r === nextUp ? "next" : "upcoming"}
join={joinState(r.event)}
now={nowMs}
fmt={fmt}
L={L}
reduce={reduce}
onJoin={onJoin}
onEventClick={onEventClick}
/>
</FragmentWithMarker>
);
})}
{isToday && markerAt === -1 && (
<>
<NowMarker ref={setMarkerEl} label={L.now} time={now ? fmt.time.format(now) : ""} />
{!current.some((r) => !r.allDay && r.event.end.getTime() > nowMs) && <li className="px-4 pb-2 pl-[84px] text-[12.5px] text-fg-3">{L.nothingElse}</li>}
</>
)}
</ul>
</section>
);
})
)}
</div>
{/* When Now scrolls out of view, a pill brings it back from the side it went. */}
<AnimatePresence>
{markerSide && (
<motion.button
key="jump"
type="button"
onClick={jumpToNow}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: markerSide === "above" ? -6 : 6, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, transition: { duration: 0.12 } }}
transition={reduce ? { duration: 0.15 } : spring.pop}
className={cn(
"absolute left-1/2 z-[3] -ml-9 flex h-7 w-[72px] items-center justify-center gap-1 rounded-full border border-line-2 bg-raised text-[12px] font-medium text-fg shadow-pop select-none",
"outline-none transition-[background-color,scale] duration-150 hover:bg-hover focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3 active:scale-[0.96]",
markerSide === "above" ? (title ? "top-[92px]" : "top-11") : "bottom-3",
)}
>
{markerSide === "above" ? <ArrowUp size={13} /> : <ArrowDown size={13} />}
{L.now}
</motion.button>
)}
</AnimatePresence>
</div>
);
}
function FragmentWithMarker({ marker, children }: { marker: React.ReactNode; children: React.ReactNode }) {
return (
<>
{marker}
{children}
</>
);
}
const btn = cn(
"inline-flex h-7 shrink-0 items-center justify-center rounded-md text-[12.5px] font-medium select-none",
"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3",
"transition-[background-color,border-color,color,scale] duration-150 ease-out active:scale-[0.97] active:duration-75",
);
function NowMarker({ ref, label, time }: { ref: React.Ref<HTMLLIElement>; label: string; time: string }) {
return (
<li ref={ref} aria-label={`${label}, ${time}`} className="relative flex h-6 scroll-mt-10 items-center gap-2 pl-4 pr-4">
<span className="w-[56px] shrink-0 font-mono text-2xs uppercase tracking-[0.08em] text-fg">{label}</span>
<span aria-hidden className="relative h-px flex-1 bg-fg/70">
<span className="absolute -left-[3px] -top-[3px] size-[7px] rounded-full bg-fg" />
</span>
<span className="shrink-0 text-[11px] text-fg-2 tabular" suppressHydrationWarning>
{time}
</span>
</li>
);
}
type RowState = "past" | "live" | "next" | "upcoming";
type Fmt = { time: Intl.DateTimeFormat; long: Intl.DateTimeFormat };
const toneDot: Record<EventTone, string> = { neutral: "bg-fg-3", info: "bg-info", success: "bg-success", warning: "bg-warning", danger: "bg-danger" };
function AgendaRow({
row,
state,
join,
now,
fmt,
L,
reduce,
onJoin,
onEventClick,
}: {
row: Row;
state: RowState;
join: JoinState;
now: number;
fmt: Fmt;
L: { join: string; joinNow: string };
reduce: boolean;
onJoin?: AgendaProps["onJoin"];
onEventClick?: AgendaProps["onEventClick"];
}) {
const e = row.event;
const tone = e.tone ?? "neutral";
const mins = Math.round((e.end.getTime() - e.start.getTime()) / 60000);
const tentative = e.status === "tentative";
const canceled = e.status === "canceled";
const startsIn = Math.max(0, e.start.getTime() - now);
const endsIn = Math.max(0, e.end.getTime() - now);
const progress = state === "live" ? Math.min(1, (now - e.start.getTime()) / Math.max(1, e.end.getTime() - e.start.getTime())) : 0;
const rel =
state === "live"
? `Ends in ${duration(Math.max(1, Math.ceil(endsIn / 60000)))}`
: state === "next" && startsIn < 864e5 && join !== "soon"
? startsIn < 60000
? "Starting now"
: `In ${duration(Math.ceil(startsIn / 60000))}`
: "";
const meta = [row.allDay && row.dayCount > 1 ? `Day ${row.dayIndex} of ${row.dayCount}` : "", e.location, e.detail, tentative ? "Tentative" : canceled ? "Canceled" : ""].filter(Boolean).join(" · ");
const label = `${e.title}, ${row.allDay ? "all day" : fmt.time.formatRange(e.start, e.end)}${meta ? `, ${meta}` : ""}${rel ? `, ${rel.toLowerCase()}` : ""}`;
return (
<li data-live={state === "live" || undefined} className={cn("group/row relative isolate flex items-start gap-3 px-4 py-2", state === "past" && "opacity-55")}>
<div className="w-[56px] shrink-0 pt-px tabular">
{row.allDay ? (
<span className="text-[12px] text-fg-3">All day</span>
) : (
<>
<span className={cn("block text-[12px]", state === "live" ? "font-medium text-fg" : "text-fg-2")}>{fmt.time.format(e.start)}</span>
<span className="block text-[11px] text-fg-4">{duration(mins)}</span>
</>
)}
</div>
<span aria-hidden className={cn("mt-[3px] w-[3px] shrink-0 self-stretch rounded-full", tentative ? "border border-dashed border-fg-4" : toneDot[tone], canceled && "opacity-40")} />
<div className="min-w-0 flex-1">
<button
type="button"
data-agenda-row
aria-label={label}
onClick={() => onEventClick?.(e)}
className={cn(
"line-clamp-2 max-w-full text-left text-[13px] font-medium leading-[18px] tracking-[-0.005em] text-fg outline-none [overflow-wrap:anywhere]",
// The whole row is the target; the Join button sits above it.
"after:absolute after:inset-x-1.5 after:inset-y-0.5 after:-z-10 after:rounded-lg after:transition-colors after:duration-150 hover:after:bg-fg/[0.035] focus-visible:after:bg-hover focus-visible:after:shadow-[inset_0_0_0_1px_var(--line-2)] active:after:bg-fg/[0.06]",
canceled && "text-fg-3 line-through decoration-fg-4",
)}
>
{e.title}
</button>
{(meta || rel) && (
<p className="mt-0.5 flex min-w-0 items-center gap-1.5 text-[12px] leading-4 text-fg-3">
{rel && (
<span className={cn("shrink-0 tabular", state === "live" ? "text-fg" : "text-fg-2")} suppressHydrationWarning>
{rel}
</span>
)}
{rel && meta && <span className="text-fg-4">·</span>}
{meta && <span className="truncate">{meta}</span>}
</p>
)}
{state === "live" && (
<span aria-hidden className="mt-2 block h-[2px] overflow-hidden rounded-full bg-fg/10">
<span className="block h-full origin-left rounded-full bg-fg/60 transition-transform duration-1000 ease-linear motion-reduce:transition-none" style={{ transform: `scaleX(${progress})` }} />
</span>
)}
</div>
{join !== "none" && <JoinButton event={e} state={join} startsIn={startsIn} reduce={reduce} L={L} onJoin={onJoin} />}
</li>
);
}
/**
* Quiet until it matters: a camera icon while the call is far off. Five minutes out
* it grows into a filled Join with a countdown; once the call starts it says Join now
* with a live dot. The width change springs, so the row never jumps.
*/
function JoinButton({
event,
state,
startsIn,
reduce,
L,
onJoin,
}: {
event: AgendaEvent;
state: Exclude<JoinState, "none">;
startsIn: number;
reduce: boolean;
L: { join: string; joinNow: string };
onJoin?: AgendaProps["onJoin"];
}) {
const prominent = state !== "idle";
const secs = Math.ceil(startsIn / 1000);
const t = reduce ? { duration: 0 } : spring.snappy;
return (
<motion.a
data-join
href={event.meetingUrl}
target="_blank"
rel="noopener noreferrer"
aria-label={`${state === "live" ? L.joinNow : L.join} ${event.title}${state === "soon" ? `, starts in ${Math.floor(secs / 60)} minutes ${secs % 60} seconds` : ""}`}
title={state === "idle" ? `${L.join} call` : undefined}
onClick={(e) => onJoin?.(event, e)}
layout
// One nudge as it turns prominent: something changed while you weren't looking.
animate={prominent && !reduce ? { scale: [1, 1.06, 1] } : { scale: 1 }}
whileTap={reduce ? undefined : { scale: 0.95 }}
transition={{ layout: t, scale: { duration: 0.42, ease: ease.out } }}
style={{ borderRadius: 7 }}
data-state={state}
className={cn(
"relative z-[1] mt-px inline-flex h-7 shrink-0 items-center justify-center gap-1.5 overflow-hidden border text-[12px] font-medium whitespace-nowrap select-none",
"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3",
"transition-[background-color,border-color,color] duration-300 ease-out",
"before:absolute before:-inset-2 before:content-[''] pointer-fine:before:hidden",
prominent ? "border-transparent bg-fg px-2.5 text-frame hover:bg-fg/90" : "w-7 border-line-2 text-fg-3 hover:border-fg-4 hover:bg-hover hover:text-fg",
)}
>
<AnimatePresence initial={false} mode="popLayout">
{state === "idle" ? (
<motion.svg
key="icon"
layout="position"
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
initial={{ opacity: 0, scale: 0.6 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.6, transition: { duration: 0.1 } }}
transition={t}
>
<rect x="1.75" y="4" width="9" height="8" rx="1.75" />
<path d="m10.75 7 3.5-2v6l-3.5-2" />
</motion.svg>
) : (
<motion.span
key="label"
layout="position"
className="flex items-center gap-1.5"
initial={reduce ? { opacity: 0 } : { opacity: 0, filter: "blur(2px)" }}
animate={{ opacity: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, transition: { duration: 0.1 } }}
transition={{ duration: 0.2, ease: ease.out }}
>
{state === "live" && (
<span aria-hidden className="relative grid size-1.5 place-items-center">
<span className="absolute inset-0 rounded-full bg-success animate-ping-soft motion-reduce:animate-none" />
<span className="relative size-1.5 rounded-full bg-success" />
</span>
)}
{state === "live" ? L.joinNow : L.join}
{state === "soon" && (
<span aria-hidden className="flex items-center font-normal opacity-70 tabular">
<NumberFlowGroup>
<NumberFlow value={Math.floor(secs / 60)} animated={!reduce} />
<span>:</span>
<NumberFlow value={secs % 60} format={{ minimumIntegerDigits: 2 }} animated={!reduce} trend={-1} />
</NumberFlowGroup>
</span>
)}
</motion.span>
)}
</AnimatePresence>
</motion.a>
);
}
function Skeleton() {
return (
<div aria-hidden className="flex flex-col">
{[3, 2].map((n, g) => (
<div key={g}>
<div className="flex h-9 items-end border-b border-line px-4 pb-2.5">
<span className="h-2.5 w-24 rounded bg-fg/[0.07]" />
</div>
<div className="flex flex-col py-1">
{Array.from({ length: n }, (_, i) => (
<div key={i} className="flex items-start gap-3 px-4 py-2 animate-pulse-soft motion-reduce:animate-none">
<span className="mt-1 h-2.5 w-11 rounded bg-fg/[0.07]" />
<span className="h-8 w-[3px] rounded-full bg-fg/[0.07]" />
<span className="flex flex-1 flex-col gap-2 pt-1">
<span className="h-2.5 rounded bg-fg/[0.08]" style={{ width: `${[62, 48, 70, 55, 40][(g * 3 + i) % 5]}%` }} />
<span className="h-2 w-1/3 rounded bg-fg/[0.05]" />
</span>
</div>
))}
</div>
</div>
))}
</div>
);
}05Props
Agenda
| Prop | Type | Default | Description |
|---|---|---|---|
| events* | AgendaEvent[] | — | Each has id, title, start, end (exclusive), and optional allDay, location, meetingUrl, detail, tone and status. |
| title | ReactNode | — | Heading above the list; the count of events still ahead sits beside it. |
| days | number | 14 | Days listed, today included. Days with nothing on them are skipped; today always shows. |
| joinLeadMinutes | number | 5 | How long before the start Join fills in and begins counting down. |
| onJoin | (event: AgendaEvent, e: MouseEvent) => void | — | Called when Join is pressed. Call e.preventDefault() to open the call yourself instead of the link. |
| onEventClick | (event: AgendaEvent) => void | — | Called when a row is pressed. |
| onCreate | () => void | — | Adds a New event action to the empty state. |
| loading | boolean | false | Skeleton day groups shaped like the list. |
| locale | string | browser language | Weekday names, dates and times. |
| now | Date | — | Pin the clock for tests and screenshots. |
| labels | { today?, tomorrow?, now?, join?, joinNow?, empty?, nothingElse?, newEvent? } | — | Visible strings, for translation. |
useClock
| Prop | Type | Default | Description |
|---|---|---|---|
| ms* | number | — | Tick interval, aligned to the boundary (every second, every minute). Returns the current Date, or null during the server render. |
| override | Date | — | A fixed time to return instead. |
06Notes
Behavior
- Today always leads, with finished events folded behind “Show 2 earlier events”, so the list opens on what's next with no scroll jump. Tomorrow and later days use their weekday name.
- The Now line sits before the first event that hasn't started; a call in progress stays above it with “Ends in 25m” and a thin bar filling as it runs, and the next event says “In 40m”. With nothing left today it reads “Nothing else today”.
- Join is a quiet camera icon until five minutes before the start, then fills in and counts down (4:26); from the start it says Join now with a live dot, and it disappears when the call ends. The clock ticks each second only while a countdown is on screen and visible, otherwise once a minute.
- Multi-day events appear on each day as “Day 2 of 3”. Titles wrap to two lines on narrow widths instead of truncating. When Now scrolls out of view a pill points back to it and lands on the call in progress.
Motion
- Join grows from icon to labeled pill on the snappy spring (width via layout) with a single 1.06 nudge over 420ms, because it changed while you weren't looking. Its countdown digits roll.
- Earlier events unfold with grid rows 0fr to 1fr over 250ms (in-out quart). The Jump to Now pill pops in from the side Now went, on the pop spring.
- Rows press to a darker wash; nothing staggers on re-render as the clock ticks. Reduced motion makes the unfold, layout and nudge instant, stops the digits rolling and the live dot pulsing.
Accessibility
- Each day is a section named by its sticky heading; each row is a button labeled with the title, time, place and how soon it starts, and the whole row is the target.
- Arrow keys or j and k move between rows, Home and End jump to the ends, skipping folded events. Join is a link with a name like “Join Customer call: Northwind, starts in 4 minutes 26 seconds”.
- The earlier-events toggle reports aria-expanded, and the folded rows are inert, so neither Tab nor a screen reader lands on them.