App screens
Calendar & schedule
A real events calendar: a month Calendar whose `renderDay` marks days with a scheduled event, a Past Event list below it, and an Upcoming Event list on the right — each row date-badged and color-coded — with a real Pagination control underneath.
How it's built
`renderDay` compares each cell's date against a list of real event dates (computed from today, not hardcoded) to decide whether to draw the small dot underneath.
Pagination's own numbered arrows are hidden (`hideNavigation`) so the surrounding `Prev`/`Next` text links can flank it — the same real component, styled to fit a text-first pagination bar.
Every date on this page — the selected day, the event dots, the upcoming list — is computed as an offset from the real current date, so the pattern never looks stuck in a past year.
Source
One file · copy & pasteimport { useMemo, useState } from "react";
import { Button } from "oks-ui/button";
import { Calendar } from "oks-ui/calendar";
import type { CalendarDayContext } from "oks-ui/calendar";
import { Divider } from "oks-ui/divider";
import { Pagination } from "oks-ui/pagination";
import { ChevronRightIcon } from "../../showcase/icons";
const toISODate = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
const isSameDate = (a: Date, b: Date) =>
a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
type UpcomingEvent = {
id: number;
dayOffset: number;
time: string;
price: string;
venue: string;
title: string;
description: string;
};
// Built off real day offsets from "today" rather than fixed 2019 dates —
// this component only ever renders client-side (PatternPreview loads every
// pattern with `ssr: false`), so there's no server/client hydration
// mismatch to worry about.
function buildUpcoming(): UpcomingEvent[] {
return [
{ id: 1, dayOffset: 2, time: "12:30pm", price: "Free", venue: "Beacon Coworking, Riverside", title: "Startup metrics and dashboards", description: "The five key metrics that drive an early-stage startup, and how to build a dashboard to track them." },
{ id: 2, dayOffset: 11, time: "6:30pm", price: "Free", venue: "Foundry Hall, Docklands", title: "All things distributed systems", description: "A conversation on distributed systems design, consensus, and the trade-offs nobody tells you about." },
{ id: 3, dayOffset: 19, time: "6:00pm", price: "$24.00", venue: "Foundry Hall, Docklands", title: "Introduction to Web Accessibility", description: "Accessibility looks complex from the outside — this workshop unravels the essentials any team can apply." },
{ id: 4, dayOffset: 26, time: "6:30pm", price: "Free", venue: "Foundry Hall, Docklands", title: "Founder Meetup: Scaling Sales", description: "A working session on scaling a sales team from the first hire through the first ten reps." },
{ id: 5, dayOffset: 47, time: "5:30pm", price: "Free", venue: "Beacon Coworking, Riverside", title: "5 Steps to Growth: Lessons from a Scaleup", description: "An exclusive session for the local startup community on what actually moved the growth needle." },
];
}
const PAST_EVENTS = [
{ id: 1, title: "Introduction to Web Accessibility", description: "Many small teams don't get the reach they want from launch day due to overlooked accessibility basics.", daysAgo: 12 },
{ id: 2, title: "Introduction to Content Marketing", description: "Many small businesses don't get the results they want from content due to inconsistent publishing.", daysAgo: 19 },
];
const EVENT_COLORS = [
"var(--oks-color-info-500)",
"var(--oks-color-primary-600)",
"var(--oks-color-warning-500)",
"var(--oks-color-success-500)",
"var(--oks-color-danger-500)",
];
export function CalendarSchedule() {
const [today] = useState(() => new Date());
const [selected, setSelected] = useState(() => toISODate(today));
const [month, setMonth] = useState<Date>(() => new Date(today.getFullYear(), today.getMonth(), 1));
const [page, setPage] = useState(2);
const upcoming = useMemo(() => buildUpcoming(), []);
const eventDates = useMemo(
() => upcoming.map((e) => new Date(today.getFullYear(), today.getMonth(), today.getDate() + e.dayOffset)),
[today, upcoming],
);
const monthLabel = month.toLocaleDateString("en-US", { month: "long" });
const yearLabel = month.getFullYear();
const renderDay = ({ date, inMonth }: CalendarDayContext) => {
const hasEvent = inMonth && eventDates.some((d) => isSameDate(d, date));
return (
<span className="relative flex h-full w-full items-center justify-center">
{date.getDate()}
{hasEvent ? (
<span aria-hidden="true" className="absolute bottom-0.5 h-1 w-1 rounded-full" style={{ background: "var(--oks-color-info-500)" }} />
) : null}
</span>
);
};
return (
// @container has to live on its OWN wrapper, not the grid element whose
// @3xl:grid-cols is conditioned by it — an element can't query its own
// container for its own styling (circular per the CSS Containment
// spec), so that utility would silently be evaluated against the
// wrong (ancestor) container and never apply.
<div className="@container w-full">
<div className="grid grid-cols-[minmax(0,1fr)] items-start gap-6 bg-paper p-6 @3xl:grid-cols-2">
{/* calendar + past events */}
<div className="flex flex-col gap-5 @3xl:border-r @3xl:border-line @3xl:pr-6">
<div className="flex items-center justify-between">
<h2 className="text-[1.15rem] font-bold text-ink">
{monthLabel} <span className="font-normal text-ink-muted">{yearLabel}</span>
</h2>
<div className="flex items-center gap-1">
<Button
isIconOnly
aria-label="Previous month"
variant="bordered"
radius="full"
size="xs"
onClick={() => setMonth((m) => new Date(m.getFullYear(), m.getMonth() - 1, 1))}
>
‹
</Button>
<Button
isIconOnly
aria-label="Next month"
variant="bordered"
radius="full"
size="xs"
onClick={() => setMonth((m) => new Date(m.getFullYear(), m.getMonth() + 1, 1))}
>
›
</Button>
</div>
</div>
<Calendar
value={selected}
onChange={(v) => v && setSelected(v)}
month={month}
onMonthChange={setMonth}
weekStartsOn={1}
renderDay={renderDay}
classNames={{ base: "w-full", header: "hidden" }}
/>
<Divider />
<div className="flex flex-col gap-4">
<h3 className="text-[1.05rem] font-bold text-ink">Past Event</h3>
{PAST_EVENTS.map((ev) => {
const date = new Date(today.getFullYear(), today.getMonth(), today.getDate() - ev.daysAgo);
return (
<div key={ev.id} className="flex flex-col gap-1">
<Button variant="link" size="sm" className="h-auto self-start whitespace-normal px-0 text-left font-semibold">
{ev.title}
</Button>
<p className="text-[0.85rem] text-ink-muted">{ev.description}</p>
<p className="text-[0.78rem] text-ink-faint">
{date.toLocaleDateString("en-US", { day: "2-digit", month: "short", year: "numeric" })}
</p>
</div>
);
})}
</div>
</div>
{/* upcoming events */}
<div className="flex flex-col gap-5">
<div className="flex items-center justify-between">
<h2 className="text-[1.15rem] font-bold text-ink">Upcoming Event</h2>
<div className="flex items-center gap-1">
<Button isIconOnly aria-label="Previous events" variant="bordered" radius="full" size="xs">
‹
</Button>
<Button isIconOnly aria-label="More events" variant="bordered" radius="full" size="xs">
›
</Button>
</div>
</div>
<div className="flex flex-col divide-y divide-line">
{upcoming.map((ev, i) => {
const date = new Date(today.getFullYear(), today.getMonth(), today.getDate() + ev.dayOffset);
return (
<div key={ev.id} className="flex gap-3 py-4 first:pt-0">
<div className="w-12 shrink-0 text-center">
<p className="text-[1rem] font-bold text-ink">{date.toLocaleDateString("en-US", { day: "numeric" })}</p>
<p className="text-[0.75rem] uppercase text-ink-faint">{date.toLocaleDateString("en-US", { month: "short" })}</p>
</div>
<span aria-hidden="true" className="w-1 shrink-0 self-stretch rounded-full" style={{ background: EVENT_COLORS[i % EVENT_COLORS.length] }} />
<div className="min-w-0 flex-1">
<p className="text-[0.78rem] font-medium text-[color:var(--oks-color-info-700)]">{ev.venue}</p>
<p className="text-[0.95rem] font-semibold text-ink">{ev.title}</p>
<p className="text-[0.78rem] text-ink-faint">
{ev.time} · <span className="text-[color:var(--oks-color-info-700)]">{ev.price}</span>
</p>
<p className="mt-1 truncate text-[0.82rem] text-ink-muted">{ev.description}</p>
</div>
<Button isIconOnly aria-label={`View ${ev.title}`} variant="bordered" size="sm" className="shrink-0 self-center">
<ChevronRightIcon size={16} />
</Button>
</div>
);
})}
</div>
<div className="flex items-center justify-center gap-2">
<Button variant="link" size="sm" isDisabled={page === 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
‹ Prev
</Button>
<Pagination page={page} pageCount={5} onChange={setPage} size="sm" hideNavigation />
<Button variant="link" size="sm" isDisabled={page === 5} onClick={() => setPage((p) => Math.min(5, p + 1))}>
Next ›
</Button>
</div>
</div>
</div>
</div>
);
}