Skip to content

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.

calendar-schedule.tsx

Built with

How it’s built

  1. 01renderDay 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.
  2. 02Pagination'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.
  3. 03Every 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

Two files · copy & paste

Styling: this source uses Tailwind utility classes and this site’s colour tokens (for example text-ink, bg-paper and border-line). The oks-ui components render without them; swap those classes for your own.

calendar-schedule.tsx
import { 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 "./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)",
];

const 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>
  );
};

export default CalendarSchedule;

It imports ./icons, so save this next to it:

icons.tsx
export const ChevronRightIcon = ({ size = 18 }: { size?: number }) => {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="m9 6 6 6-6 6" />
    </svg>
  );
};