Skip to content
oks-ui

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.

calendar-schedule.tsx

How it's built

1

`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.

2

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.

3

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 & paste
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 "../../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>
  );
}

More patterns

App screens
Analytics dashboardA full fintech app screen — collapsible sidebar, header search, wallet cards, an earnings chart, savings goals, and a transactions table.
App screens
Settings panelA real settings screen — top tabs, two-column sections, and a sortable, selectable billing history table.
App screens
Data table viewA full documents list screen — status Tabs, a filter toolbar, and a sortable, selectable Table with real recipient avatars.