# Calendar

> A complete screen built from AppShell, Button, EventCard, IconButton and 4 more components.

Pattern · Pro kit · [Live page](https://ui.twentyfirst.media/pro/patterns/CalendarScreen/)

## Code

```tsx
import * as React from "react";
import {
  AppShell,
  Button,
  EventCard,
  IconButton,
  PageHeader,
  Panel,
  Stagger,
  Tabs,
} from "@twentyfirstmedia/pro";

const DAYS = ["Mon 13", "Tue 14", "Wed 15", "Thu 16", "Fri 17"];
const EV = [
  {
    d: 0,
    s: 9,
    e: 10,
    t: "Standup",
    tint: "sky",
    who: "Team",
  },
  {
    d: 0,
    s: 13,
    e: 14.5,
    t: "Hiring panel",
    tint: "lilac",
    who: "Design",
  },
  {
    d: 1,
    s: 10.5,
    e: 12,
    t: "Team sync",
    tint: "sky",
    who: "Growth + Design",
  },
  {
    d: 1,
    s: 15,
    e: 16,
    t: "Partner call",
    tint: "mint",
    who: "Northwind",
  },
  {
    d: 2,
    s: 9,
    e: 10,
    t: "Standup",
    tint: "sky",
    who: "Team",
  },
  {
    d: 2,
    s: 14,
    e: 15.5,
    t: "Design review",
    tint: "lilac",
    who: "Billing flow",
  },
  {
    d: 3,
    s: 11,
    e: 12.5,
    t: "Pricing review",
    tint: "butter",
    who: "Moved from Wed",
  },
  {
    d: 3,
    s: 16,
    e: 17,
    t: "Incident retro",
    tint: "blush",
    who: "Webhooks #88",
  },
  {
    d: 4,
    s: 9,
    e: 10,
    t: "Standup",
    tint: "sky",
    who: "Team",
  },
  {
    d: 4,
    s: 15.5,
    e: 17,
    t: "Q2 invoices due",
    tint: "butter",
    who: "Finance",
  },
];
const START = 8,
  END = 18,
  ROW = 48;
function App() {
  const hours = [];
  for (let i = START; i <= END; i++) hours.push(i);
  return (
    <AppShell
      brand="Halo"
      tag="Admin"
      user={{ name: "Mara Ellis", email: "mara@halo.app" }}
      active="Calendar"
      dock={false}
      side={
        <Panel side title="Up next" subtitle="Thursday, April 16">
          <div
            className="tf-card tf-in"
            style={
              {
                minHeight: 0,
                gap: 12,
                padding: 16,
                "--d": "200ms",
              } as React.CSSProperties
            }
          >
            <div
              className="tf-row"
              style={{
                justifyContent: "space-between",
              }}
            >
              <span
                style={{
                  fontWeight: "var(--type-title-weight)",
                }}
              >
                April 2026
              </span>
              <span
                className="tf-row"
                style={{
                  gap: 4,
                }}
              >
                <IconButton icon="chevronLeft" label="Previous month" size="sm" />
                <IconButton icon="chevronRight" label="Next month" size="sm" />
              </span>
            </div>
            <div
              style={{
                display: "grid",
                gridTemplateColumns: "repeat(7, 1fr)",
                gap: 4,
                textAlign: "center",
                font: "var(--type-label-weight) 11px/28px var(--font-sans)",
              }}
            >
              {["M", "T", "W", "T", "F", "S", "S"].map((d, i) => (
                <span
                  key={"h" + i}
                  style={{
                    color: "var(--ink-muted)",
                  }}
                >
                  {d}
                </span>
              ))}
              {([null, null] as (number | null)[])
                .concat(
                  Array.from(
                    {
                      length: 30,
                    },
                    (_, i) => i + 1,
                  ),
                )
                .map((n, i) => {
                  const on = n != null && n >= 13 && n <= 17,
                    today = n === 16;
                  return (
                    <span
                      key={i}
                      style={{
                        borderRadius: "var(--radius-pill)",
                        background: today ? "var(--primary)" : on ? "var(--sky)" : "transparent",
                        color: today ? "var(--primary-foreground)" : "var(--ink)",
                      }}
                    >
                      {n || ""}
                    </span>
                  );
                })}
            </div>
          </div>
          <Stagger className="tf-col" delay={320}>
            <EventCard
              kind="review"
              label="Pricing review"
              date="Thu, Apr 16"
              time="11:00"
              tint="butter"
              icon="card"
            >
              Moved from Wednesday so Growth can join.
            </EventCard>
            <EventCard kind="incident" label="Incident retro" date="Thu, Apr 16" time="16:00">
              What delayed EU webhooks and how we stop it.
            </EventCard>
          </Stagger>
        </Panel>
      }
    >
      <div className="tf-in">
        <PageHeader
          title="Calendar"
          subtitle="Week 16 · 10 events"
          actions={[
            <Tabs key="t" items={["Day", "Week", "Month"]} defaultValue="Week" />,
            <Button key="n" icon="plus">
              New event
            </Button>,
          ]}
        />
      </div>
      <div
        className="tf-panel tf-in"
        style={
          {
            "--d": "120ms",
            padding: 16,
            gap: 0,
            overflowX: "auto",
          } as React.CSSProperties
        }
        tabIndex={0}
        role="region"
        aria-label="Week view"
      >
        <div
          style={{
            display: "grid",
            gridTemplateColumns: "48px repeat(5, 1fr)",
            minWidth: 560,
            gap: 8,
            paddingBottom: 12,
          }}
        >
          <span />
          {DAYS.map((d, i) => (
            <div
              key={d}
              style={{
                textAlign: "center",
                font: "var(--type-title-weight) 13px/18px var(--font-sans)",
                padding: "8px 0",
                borderRadius: "var(--radius-pill)",
                background: i === 3 ? "var(--primary)" : "transparent",
                color: i === 3 ? "var(--primary-foreground)" : "var(--ink)",
              }}
            >
              {d}
            </div>
          ))}
        </div>
        <div
          style={{
            display: "grid",
            gridTemplateColumns: "48px repeat(5, 1fr)",
            minWidth: 560,
            gap: 8,
            position: "relative",
          }}
        >
          <div>
            {hours.slice(0, -1).map((hr) => (
              <div
                key={hr}
                style={{
                  height: ROW,
                  font: "var(--type-label-weight) 11px/1 var(--font-sans)",
                  color: "var(--ink-muted)",
                }}
              >
                {(hr < 10 ? "0" : "") + hr + ":00"}
              </div>
            ))}
          </div>
          {DAYS.map((d, di) => (
            <div
              key={d}
              style={{
                position: "relative",
                height: ROW * (END - START),
                backgroundImage:
                  "repeating-linear-gradient(to bottom, var(--line) 0 1px, transparent 1px " +
                  ROW +
                  "px)",
                borderRadius: "var(--radius-sm)",
              }}
            >
              {EV.filter((e) => e.d === di).map((e, i) => (
                <div
                  key={i}
                  className={"tf-in tf-bg-" + e.tint}
                  style={
                    {
                      "--d": 200 + di * 70 + i * 60 + "ms",
                      position: "absolute",
                      left: 4,
                      right: 4,
                      top: (e.s - START) * ROW + 2,
                      height: (e.e - e.s) * ROW - 4,
                      borderRadius: "var(--radius-sm)",
                      padding: "8px 12px",
                      boxSizing: "border-box",
                      overflow: "hidden",
                    } as React.CSSProperties
                  }
                >
                  <div
                    style={{
                      font: "var(--type-title-weight) 12px/16px var(--font-sans)",
                    }}
                  >
                    {e.t}
                  </div>
                  <div
                    style={{
                      font: "var(--type-body-weight) 11px/15px var(--font-sans)",
                      opacity: 0.78,
                    }}
                  >
                    {e.who}
                  </div>
                </div>
              ))}
            </div>
          ))}
        </div>
      </div>
    </AppShell>
  );
}
export default function Preview() {
  return <App />;
}
```

## Props

### AppShell

| Prop | Type | Notes |
| --- | --- | --- |
| `active?` | `string` |  |
| `brand?` | `ReactNode` | The product name, or a node (a logo mark with the name; collapsed sidebars show it whole). |
| `tag?` | `string` |  |
| `nav?` | `NavItem[]` |  |
| `user?` | `{ name: string; email?: string }` |  |
| `onNavigate?` | `(label: string) => void` |  |
| `topExtra?` | `ReactNode` |  |
| `sheetStyle?` | `CSSProperties` |  |
| `contentKey?` | `string \| number` |  |
| `side?` | `ReactNode` |  |
| `dock?` | `boolean` |  |
| `dockItems?` | `DockProps["items"]` |  |
| `railTop?` | `RailItem[]` |  |
| `railBottom?` | `RailItem[]` |  |
| `navigation?` | `"top" \| "sidebar"` | `top` (default): navigation in the top bar, tools in the side rail. `sidebar`: a labelled sidebar on the frame, with groups, nesting and a drawer on phones. |
| `sidebarGroups?` | `SidebarGroup[]` | With `navigation="sidebar"`: groups instead of the flat `nav` list. |
| `sidebar?` | `Partial<Omit<SidebarProps, "variant" \| "groups">>` | With `navigation="sidebar"`: more sidebar props (collapsed, storageKey, shortcut, linkAs …). |
| `workspaces?` | `WorkspaceSwitcherProps` | With `navigation="sidebar"`: the workspace switcher at the top instead of the brand. |
| `userMenu?` | `(MenuItem \| "-")[]` | The account menu's entries (with `navigation="sidebar"` at the foot of the sidebar). |
| `children?` | `ReactNode` |  |
| `className?` | `string` | Extra class on the root element. |
| `style?` | `CSSProperties` | Inline style on the root element. |

### Button

| Prop | Type | Notes |
| --- | --- | --- |
| `variant?` | `"primary" \| "secondary" \| "soft" \| "ghost" \| "danger" \| "danger-solid"` |  |
| `size?` | `"md" \| "sm"` |  |
| `icon?` | `IconName` |  |
| `iconRight?` | `IconName` |  |
| `disabled?` | `boolean` |  |
| `type?` | `"button" \| "submit" \| "reset"` |  |
| `onClick?` | `(e: MouseEvent<HTMLButtonElement>) => void` |  |
| `children?` | `ReactNode` |  |
| `asChild?` | `boolean` | Render the single child element (a link, a router Link) with the button's look instead of a <button>. |
| `id?` | `string` |  |
| `name?` | `string` | Form field name and value, sent with the form when this button submits it. |
| `value?` | `string` |  |
| `form?` | `string` | The id of the form this button belongs to, when it sits outside it. |
| `title?` | `string` |  |
| `tabIndex?` | `number` |  |
| `autoFocus?` | `boolean` |  |
| `aria-label?` | `string` |  |
| `aria-labelledby?` | `string` |  |
| `aria-describedby?` | `string` |  |
| `aria-expanded?` | `boolean \| "true" \| "false"` | For disclosure and menu triggers: whether what the button controls is open. |
| `aria-controls?` | `string` |  |
| `aria-haspopup?` | `boolean \| "true" \| "false" \| "menu" \| "listbox" \| "tree" \| "grid" \| "dialog"` |  |
| `aria-pressed?` | `boolean \| "true" \| "false" \| "mixed"` | For toggle buttons. |
| `aria-current?` | `boolean \| "true" \| "false" \| "page" \| "step" \| "location" \| "date" \| "time"` |  |
| `className?` | `string` | Extra class on the root element. |
| `style?` | `CSSProperties` | Inline style on the root element. |

### EventCard

| Prop | Type | Notes |
| --- | --- | --- |
| `kind?` | `"meeting" \| "deadline" \| "review" \| "launch" \| "incident"` |  |
| `label` | `string` | Required |
| `date` | `string` | Required |
| `time` | `string` | Required |
| `tint?` | `Tint` |  |
| `icon?` | `IconName` |  |
| `actions?` | `boolean` |  |
| `children?` | `ReactNode` |  |
| `className?` | `string` | Extra class on the root element. |
| `style?` | `CSSProperties` | Inline style on the root element. |

### IconButton

| Prop | Type | Notes |
| --- | --- | --- |
| `icon` | `IconName` | Required |
| `label` | `string` | Accessible name and tooltip (`aria-label` and `title`); `aria-label` or `title` override them. |
| `variant?` | `"soft" \| "solid" \| "outline" \| "ghost"` |  |
| `size?` | `"sm" \| "md" \| "lg"` |  |
| `type?` | `"button" \| "submit" \| "reset"` |  |
| `onClick?` | `(e: MouseEvent<HTMLButtonElement>) => void` |  |
| `disabled?` | `boolean` |  |
| `asChild?` | `boolean` | Render the single child element (a link) with the button's look; the icon is placed inside it. |
| `children?` | `ReactNode` |  |
| `id?` | `string` |  |
| `name?` | `string` | Form field name and value, sent with the form when this button submits it. |
| `value?` | `string` |  |
| `form?` | `string` | The id of the form this button belongs to, when it sits outside it. |
| `title?` | `string` |  |
| `tabIndex?` | `number` |  |
| `autoFocus?` | `boolean` |  |
| `aria-label?` | `string` |  |
| `aria-labelledby?` | `string` |  |
| `aria-describedby?` | `string` |  |
| `aria-expanded?` | `boolean \| "true" \| "false"` | For disclosure and menu triggers: whether what the button controls is open. |
| `aria-controls?` | `string` |  |
| `aria-haspopup?` | `boolean \| "true" \| "false" \| "menu" \| "listbox" \| "tree" \| "grid" \| "dialog"` |  |
| `aria-pressed?` | `boolean \| "true" \| "false" \| "mixed"` | For toggle buttons. |
| `aria-current?` | `boolean \| "true" \| "false" \| "page" \| "step" \| "location" \| "date" \| "time"` |  |
| `className?` | `string` | Extra class on the root element. |
| `style?` | `CSSProperties` | Inline style on the root element. |

### PageHeader

| Prop | Type | Notes |
| --- | --- | --- |
| `crumbs?` | `(string \| Crumb)[]` | A breadcrumb above the title (see `Breadcrumb`): labels, or items with `href`/`onClick`. |
| `crumbLinkAs?` | `ElementType` | The element crumb links render as, e.g. Next.js `Link`. |
| `title` | `string` | Required |
| `subtitle?` | `string` |  |
| `actions?` | `ReactNode[]` |  |
| `className?` | `string` | Extra class on the root element. |
| `style?` | `CSSProperties` | Inline style on the root element. |

### Panel

| Prop | Type | Notes |
| --- | --- | --- |
| `side?` | `boolean` |  |
| `title?` | `string` |  |
| `subtitle?` | `string` |  |
| `action?` | `ReactNode` |  |
| `children?` | `ReactNode` |  |
| `surface?` | `Surface` | `"glass"`: translucent glass over what is behind (maps, 3D, video, photos); `"solid"` opts out inside a glass `UIProvider`. |
| `className?` | `string` | Extra class on the root element. |
| `style?` | `CSSProperties` | Inline style on the root element. |

### Stagger

| Prop | Type | Notes |
| --- | --- | --- |
| `as?` | `string` |  |
| `className?` | `string` |  |
| `style?` | `CSSProperties` |  |
| `delay?` | `number` | Extra delay before the first child, in ms. |
| `reveal?` | `boolean` | Below the fold, wait until the group scrolls into view (default true). |
| `children?` | `ReactNode` |  |

### Tabs

| Prop | Type | Notes |
| --- | --- | --- |
| `items` | `string[]` | Required |
| `value?` | `string` |  |
| `defaultValue?` | `string` |  |
| `onChange?` | `(v: string) => void` |  |
| `block?` | `boolean` |  |
| `panels?` | `Record<string, ReactNode>` | Content per tab; renders the tab panels under the bar, wired with tab/tabpanel roles. |
| `label?` | `string` | Accessible name of the tab list. |
| `className?` | `string` | Extra class on the root element. |
| `style?` | `CSSProperties` | Inline style on the root element. |
