v0.1 public beta early access on GitHub
Home / Screens / productivity-calendar-month
Screen template

Calendar Month Screen

Month grid with events

pro Expo Go ✓ iosandroid gestures: tap · scroll haptics: light · selection
$ npx lumioui add productivity-calendar-month
import { CalendarMonthScreen } from "@/screens/productivity/calendar-month";
Registry dependencies3
Native packages installed6
@lumioui/ui@latestreact-native-safe-area-context@*@lumioui/core@latestreact-native-reanimated@*react-native-svg@*nativewind@*
productivity/calendar-month.tsxsource · yours to edit
import * as React from "react";
import { ScrollView, View } from "react-native";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import { Button, Glass, Icon, Text } from "@lumioui/ui";
import { Mount, Press, World } from "../shared";

export interface AgendaEvent {
  title: string;
  detail: string;
  /** Tailwind bg class for the event's accent bar + grid dot. */
  bar: string;
}

export interface CalendarMonthScreenProps {
  /** Initial month label, e.g. "September 2026" — parsed into the cursor. */
  month?: string;
  /** Events keyed by ISO day ("2026-09-23"); drives dots AND the agenda card. */
  agenda?: Record<string, AgendaEvent[]>;
  onDayPress?: (iso: string) => void;
  onBack?: () => void;
}

const DAYS = ["M", "T", "W", "T", "F", "S", "S"];

const DEFAULT_AGENDA: Record<string, AgendaEvent[]> = {
  "2026-09-04": [{ title: "Sprint planning", detail: "9:30 – 10:30 · Room 4", bar: "bg-[#65A30D]" }],
  "2026-09-09": [{ title: "Dentist", detail: "12:15 · Downtown", bar: "bg-destructive" }],
  "2026-09-14": [
    { title: "Ship registry v2", detail: "All day", bar: "bg-success" },
    { title: "Team dinner", detail: "7:30 PM · Noe", bar: "bg-warning" },
  ],
  "2026-09-23": [
    { title: "Design review", detail: "10:00 – 10:45 · Studio", bar: "bg-[#65A30D]" },
    { title: "Flight check-in opens", detail: "8:15 PM · TP 268", bar: "bg-warning" },
  ],
  "2026-09-28": [{ title: "Monthly report due", detail: "5:00 PM", bar: "bg-chart-3" }],
};

const pad = (n: number) => String(n).padStart(2, "0");
const isoOf = (y: number, m: number, d: number) => `${y}-${pad(m + 1)}-${pad(d)}`;

/** Calendar month — navigable month grid, per-day dots + agenda bound to the
 *  selected day. */
export function CalendarMonthScreen({
  month = "September 2026",
  agenda = DEFAULT_AGENDA,
  onDayPress,
  onBack,
}: CalendarMonthScreenProps) {
  const insets = useSafeAreaInsets();
  const [cursor, setCursor] = React.useState(() => {
    const d = new Date(`${month} 1`);
    return isNaN(d.getTime()) ? new Date(2026, 8, 1) : d;
  });
  const [selected, setSelected] = React.useState<string>(() => {
    const withEvents = Object.keys(agenda).sort();
    return withEvents[0] ?? isoOf(2026, 8, 23);
  });

  const year = cursor.getFullYear();
  const mon = cursor.getMonth();
  const daysInMonth = new Date(year, mon + 1, 0).getDate();
  const leadBlanks = (new Date(year, mon, 1).getDay() + 6) % 7; // Mon-based
  const monthLabel = cursor.toLocaleString("en-US", { month: "long", year: "numeric" });

  const shift = (dir: -1 | 1) => setCursor(new Date(year, mon + dir, 1));

  const pick = (day: number) => {
    const key = isoOf(year, mon, day);
    setSelected(key);
    onDayPress?.(key);
  };

  const selEvents = agenda[selected] ?? [];
  const selLabel = (() => {
    const [y = 2026, m = 9, d = 1] = selected.split("-").map(Number);
    const dt = new Date(y, m - 1, d);
    return `${dt.toLocaleString("en-US", { weekday: "long" })} · ${dt.toLocaleString("en-US", { month: "short", day: "numeric" })}`;
  })();

  const cells: (number | null)[] = [
    ...Array.from({ length: leadBlanks }, () => null),
    ...Array.from({ length: daysInMonth }, (_, i) => i + 1),
  ];

  return (
    <World primary="#65A30D">
    <SafeAreaView className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]" edges={["top", "bottom"]}>
      {/* Back on the left; month label; prev/next month on the right — the
          chevron pair is unambiguous month nav, not a disguised back button. */}
      <View className="flex-row items-center px-4 pb-1 pt-2">
        <Button
          variant="ghost"
          size="icon"
          haptic="light"
          onPress={() => onBack?.()}
          accessibilityLabel="Go back"
          className="-ml-2 h-10 w-10"
        >
          <Icon name="chevron-left" size="md" color="foreground" />
        </Button>
        <View className="flex-1">
          <Text variant="h2" accessibilityRole="header">
            {monthLabel}
          </Text>
          {/* Load caption — months read as busy or open. */}
          <Text variant="muted" className="text-xs">
            14 events · 2 heavy days
          </Text>
        </View>
        <Button
          variant="ghost"
          size="icon"
          haptic="light"
          onPress={() => shift(-1)}
          accessibilityLabel="Previous month"
          className="h-10 w-10"
        >
          <Icon name="chevron-left" size="md" color="foreground" />
        </Button>
        <Button
          variant="ghost"
          size="icon"
          haptic="light"
          onPress={() => shift(1)}
          accessibilityLabel="Next month"
          className="h-10 w-10"
        >
          <Icon name="chevron-right" size="md" color="foreground" />
        </Button>
      </View>

      <ScrollView
        showsVerticalScrollIndicator={false}
        contentContainerStyle={{ paddingBottom: insets.bottom + 24, flexGrow: 1 }}
      >
        {/* Weekday header. */}
        <View className="flex-row px-3 pt-2">
          {DAYS.map((d, i) => (
            <Text
              key={i}
              className="flex-1 text-center text-xs font-medium text-muted-foreground"
            >
              {d}
            </Text>
          ))}
        </View>

        {/* Day grid — numeral over centered event dots, true-circle selection. */}
        <View className="flex-row flex-wrap px-3 pt-1">
          {cells.map((day, i) => {
            if (day === null) {
              return <View key={`b${i}`} className="w-[14.28%] py-1" />;
            }
            const key = isoOf(year, mon, day);
            const isSel = key === selected;
            const dots = [...new Set((agenda[key] ?? []).map((e) => e.bar))].slice(0, 3);
            return (
              <View key={key} className="w-[14.28%] items-center py-1">
                <Press
                  haptic="light"
                  pressedScale={0.9}
                  onPress={() => pick(day)}
                  accessibilityLabel={`${monthLabel.split(" ")[0]} ${day}`}
                  accessibilityState={{ selected: isSel }}
                  className="items-center gap-1"
                >
                  <View
                    className={`h-10 w-10 items-center justify-center rounded-full ${
                      isSel ? "bg-[#65A30D]" : ""
                    }`}
                  >
                    <Text
                      className={`text-sm ${
                        isSel ? "font-semibold text-primary-foreground" : ""
                      }`}
                    >
                      {day}
                    </Text>
                  </View>
                  <View className="h-1.5 flex-row gap-1">
                    {dots.map((c, di) => (
                      <View
                        key={di}
                        className={`h-1.5 w-1.5 rounded-full ${isSel ? "bg-primary-foreground" : c}`}
                      />
                    ))}
                  </View>
                </Press>
              </View>
            );
          })}
        </View>

        {/* Coming up — the next few days' events so the detail area keeps
            something on screen even when today is quiet. */}
        {(() => {
          const upcoming = Object.entries(agenda)
            .filter(([iso]) => iso > selected)
            .sort(([a], [b]) => (a < b ? -1 : 1))
            .slice(0, 3);
          if (upcoming.length === 0) return null;
          return (
            <View className="mx-4 mt-3 gap-2 overflow-hidden rounded-2xl bg-white p-5 dark:bg-[#1A1A20]">
              <Text className="text-xs font-medium uppercase text-muted-foreground">
                Coming up
              </Text>
              {upcoming.map(([iso, evs]) => {
                const [, , dd] = iso.split("-").map(Number);
                return (
                  <View key={iso} className="flex-row items-center gap-2.5 pt-1">
                    <View className="h-8 w-8 items-center justify-center rounded-xl bg-muted">
                      <Text className="text-xs font-semibold tabular-nums">{dd}</Text>
                    </View>
                    <View className="flex-1">
                      <Text className="text-sm font-medium" numberOfLines={1}>
                        {evs[0]?.title}
                      </Text>
                      <Text className="text-xs text-muted-foreground" numberOfLines={1}>
                        {evs.length > 1 ? `+${evs.length - 1} more · ` : ""}{evs[0]?.detail}
                      </Text>
                    </View>
                    <View className={`h-2 w-2 rounded-full ${evs[0]?.bar ?? "bg-[#65A30D]"}`} />
                  </View>
                );
              })}
            </View>
          );
        })()}

        {/* Selected-day agenda — follows the grid, bound to `selected`. */}
        <Mount delay={120}>
          <View className="mx-4 mt-3 gap-2 overflow-hidden rounded-2xl bg-white p-5 dark:bg-[#1A1A20]">
            <Text className="text-xs font-medium uppercase text-muted-foreground">
              {selLabel}
            </Text>
            {selEvents.length === 0 ? (
              <View className="flex-row items-center gap-2.5 pt-1">
                <Icon name="calendar" size="md" color="mutedForeground" />
                <Text className="text-sm text-muted-foreground">No events this day</Text>
              </View>
            ) : (
              selEvents.map((e, ei) => (
                <View key={ei} className="flex-row items-center gap-2.5 pt-1">
                  <View className={`h-8 w-1 rounded-full ${e.bar}`} />
                  <View className="flex-1">
                    <Text className="text-sm font-medium">{e.title}</Text>
                    <Text className="text-xs text-muted-foreground">{e.detail}</Text>
                  </View>
                </View>
              ))
            )}
          </View>
        </Mount>
      </ScrollView>
    </SafeAreaView>
    </World>
  );
}
CalendarMonthScreen.displayName = "CalendarMonthScreen";
agent prompt — paste into Claude Code, Cursor, Devin…
Recreate the "Calendar Month Screen" screen in React Native using LumioUI.

Goal: Month grid with events Match the reference implementation's layout, behavior, and motion exactly — this is a production screen, not a mockup.

## Setup

Fastest path — install the screen plus its component deps:

```bash
npx lumioui add productivity-calendar-month
```

Or pull just the components: `npx lumioui add button icon text`

Native packages:
- @lumioui/ui@latest
- react-native-safe-area-context@*
- @lumioui/core@latest
- react-native-reanimated@*
- react-native-svg@*
- nativewind@*

Stack assumptions: Expo SDK 54 / RN 0.81, Reanimated 4, NativeWind v4, TypeScript strict, Expo Go compatible.

## Components — how, where, when

### `button` — Tactile pill button with press spring, ink wash and async loading.
- `<Button>` ×3

### `icon` — Lucide icon set mapped to theme colors and sizes.
- `<Icon>` ×4

### `text` — Themed type scale from caption to display.
- `<Text>` ×12

## Conventions (non-negotiable)

- Styling via NativeWind `className` with semantic tokens only: `bg-background`, `text-foreground`, `bg-card`, `text-card-foreground`, `bg-secondary`, `bg-muted`, `text-muted-foreground`, `bg-accent`, `bg-destructive`, `border-border`. Hard-coded colors only inside media overlays (`text-white`, `bg-black/N`).
- Motion via the token presets from `@lumioui/core` (`spring.press`, `spring.surface`, `spring.glide`, `timing.fast`, `timing.slow`, …) — no bespoke spring configs or durations.
- Gate transform motion behind `useReducedMotion()`; keep opacity/color feedback, drop travel and overshoot.
- Haptics via `useHaptic()`: `light` on press, `selection` on toggle/select, `warning` on invalid input. Never import expo-haptics directly.
- Press feedback via `usePressAnimation()` (~0.97 scale), not `activeOpacity` tricks.
- a11y: `accessibilityRole` + `accessibilityLabel` on every control, touch targets ≥44pt.
- Entrance choreography: staggered `Mount` wrappers (delay 0/60/120…) so regions land in sequence instead of popping in at once.
- Wrap the screen in `SafeAreaView`; keep dark mode working via the semantic tokens.

## Deliverable

One file: `screens/productivity/calendar-month.tsx` exporting CalendarMonthScreen. Preserve the props interface — the host app wires the callbacks (navigation, submit handlers) through them.

## Reference implementation

The LumioUI source for this screen — reproduce it faithfully:

```tsx
import * as React from "react";
import { ScrollView, View } from "react-native";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import { Button, Glass, Icon, Text } from "@lumioui/ui";
import { Mount, Press, World } from "../shared";

export interface AgendaEvent {
  title: string;
  detail: string;
  /** Tailwind bg class for the event's accent bar + grid dot. */
  bar: string;
}

export interface CalendarMonthScreenProps {
  /** Initial month label, e.g. "September 2026" — parsed into the cursor. */
  month?: string;
  /** Events keyed by ISO day ("2026-09-23"); drives dots AND the agenda card. */
  agenda?: Record<string, AgendaEvent[]>;
  onDayPress?: (iso: string) => void;
  onBack?: () => void;
}

const DAYS = ["M", "T", "W", "T", "F", "S", "S"];

const DEFAULT_AGENDA: Record<string, AgendaEvent[]> = {
  "2026-09-04": [{ title: "Sprint planning", detail: "9:30 – 10:30 · Room 4", bar: "bg-[#65A30D]" }],
  "2026-09-09": [{ title: "Dentist", detail: "12:15 · Downtown", bar: "bg-destructive" }],
  "2026-09-14": [
    { title: "Ship registry v2", detail: "All day", bar: "bg-success" },
    { title: "Team dinner", detail: "7:30 PM · Noe", bar: "bg-warning" },
  ],
  "2026-09-23": [
    { title: "Design review", detail: "10:00 – 10:45 · Studio", bar: "bg-[#65A30D]" },
    { title: "Flight check-in opens", detail: "8:15 PM · TP 268", bar: "bg-warning" },
  ],
  "2026-09-28": [{ title: "Monthly report due", detail: "5:00 PM", bar: "bg-chart-3" }],
};

const pad = (n: number) => String(n).padStart(2, "0");
const isoOf = (y: number, m: number, d: number) => `${y}-${pad(m + 1)}-${pad(d)}`;

/** Calendar month — navigable month grid, per-day dots + agenda bound to the
 *  selected day. */
export function CalendarMonthScreen({
  month = "September 2026",
  agenda = DEFAULT_AGENDA,
  onDayPress,
  onBack,
}: CalendarMonthScreenProps) {
  const insets = useSafeAreaInsets();
  const [cursor, setCursor] = React.useState(() => {
    const d = new Date(`${month} 1`);
    return isNaN(d.getTime()) ? new Date(2026, 8, 1) : d;
  });
  const [selected, setSelected] = React.useState<string>(() => {
    const withEvents = Object.keys(agenda).sort();
    return withEvents[0] ?? isoOf(2026, 8, 23);
  });

  const year = cursor.getFullYear();
  const mon = cursor.getMonth();
  const daysInMonth = new Date(year, mon + 1, 0).getDate();
  const leadBlanks = (new Date(year, mon, 1).getDay() + 6) % 7; // Mon-based
  const monthLabel = cursor.toLocaleString("en-US", { month: "long", year: "numeric" });

  const shift = (dir: -1 | 1) => setCursor(new Date(year, mon + dir, 1));

  const pick = (day: number) => {
    const key = isoOf(year, mon, day);
    setSelected(key);
    onDayPress?.(key);
  };

  const selEvents = agenda[selected] ?? [];
  const selLabel = (() => {
    const [y = 2026, m = 9, d = 1] = selected.split("-").map(Number);
    const dt = new Date(y, m - 1, d);
    return `${dt.toLocaleString("en-US", { weekday: "long" })} · ${dt.toLocaleString("en-US", { month: "short", day: "numeric" })}`;
  })();

  const cells: (number | null)[] = [
    ...Array.from({ length: leadBlanks }, () => null),
    ...Array.from({ length: daysInMonth }, (_, i) => i + 1),
  ];

  return (
    <World primary="#65A30D">
    <SafeAreaView className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]" edges={["top", "bottom"]}>
      {/* Back on the left; month label; prev/next month on the right — the
          chevron pair is unambiguous month nav, not a disguised back button. */}
      <View className="flex-row items-center px-4 pb-1 pt-2">
        <Button
          variant="ghost"
          size="icon"
          haptic="light"
          onPress={() => onBack?.()}
          accessibilityLabel="Go back"
          className="-ml-2 h-10 w-10"
        >
          <Icon name="chevron-left" size="md" color="foreground" />
        </Button>
        <View className="flex-1">
          <Text variant="h2" accessibilityRole="header">
            {monthLabel}
          </Text>
          {/* Load caption — months read as busy or open. */}
          <Text variant="muted" className="text-xs">
            14 events · 2 heavy days
          </Text>
        </View>
        <Button
          variant="ghost"
          size="icon"
          haptic="light"
          onPress={() => shift(-1)}
          accessibilityLabel="Previous month"
          className="h-10 w-10"
        >
          <Icon name="chevron-left" size="md" color="foreground" />
        </Button>
        <Button
          variant="ghost"
          size="icon"
          haptic="light"
          onPress={() => shift(1)}
          accessibilityLabel="Next month"
          className="h-10 w-10"
        >
          <Icon name="chevron-right" size="md" color="foreground" />
        </Button>
      </View>

      <ScrollView
        showsVerticalScrollIndicator={false}
        contentContainerStyle={{ paddingBottom: insets.bottom + 24, flexGrow: 1 }}
      >
        {/* Weekday header. */}
        <View className="flex-row px-3 pt-2">
          {DAYS.map((d, i) => (
            <Text
              key={i}
              className="flex-1 text-center text-xs font-medium text-muted-foreground"
            >
              {d}
            </Text>
          ))}
        </View>

        {/* Day grid — numeral over centered event dots, true-circle selection. */}
        <View className="flex-row flex-wrap px-3 pt-1">
          {cells.map((day, i) => {
            if (day === null) {
              return <View key={`b${i}`} className="w-[14.28%] py-1" />;
            }
            const key = isoOf(year, mon, day);
            const isSel = key === selected;
            const dots = [...new Set((agenda[key] ?? []).map((e) => e.bar))].slice(0, 3);
            return (
              <View key={key} className="w-[14.28%] items-center py-1">
                <Press
                  haptic="light"
                  pressedScale={0.9}
                  onPress={() => pick(day)}
                  accessibilityLabel={`${monthLabel.split(" ")[0]} ${day}`}
                  accessibilityState={{ selected: isSel }}
                  className="items-center gap-1"
                >
                  <View
                    className={`h-10 w-10 items-center justify-center rounded-full ${
                      isSel ? "bg-[#65A30D]" : ""
                    }`}
                  >
                    <Text
                      className={`text-sm ${
                        isSel ? "font-semibold text-primary-foreground" : ""
                      }`}
                    >
                      {day}
                    </Text>
                  </View>
                  <View className="h-1.5 flex-row gap-1">
                    {dots.map((c, di) => (
                      <View
                        key={di}
                        className={`h-1.5 w-1.5 rounded-full ${isSel ? "bg-primary-foreground" : c}`}
                      />
                    ))}
                  </View>
                </Press>
              </View>
            );
          })}
        </View>

        {/* Coming up — the next few days' events so the detail area keeps
            something on screen even when today is quiet. */}
        {(() => {
          const upcoming = Object.entries(agenda)
            .filter(([iso]) => iso > selected)
            .sort(([a], [b]) => (a < b ? -1 : 1))
            .slice(0, 3);
          if (upcoming.length === 0) return null;
          return (
            <View className="mx-4 mt-3 gap-2 overflow-hidden rounded-2xl bg-white p-5 dark:bg-[#1A1A20]">
              <Text className="text-xs font-medium uppercase text-muted-foreground">
                Coming up
              </Text>
              {upcoming.map(([iso, evs]) => {
                const [, , dd] = iso.split("-").map(Number);
                return (
                  <View key={iso} className="flex-row items-center gap-2.5 pt-1">
                    <View className="h-8 w-8 items-center justify-center rounded-xl bg-muted">
                      <Text className="text-xs font-semibold tabular-nums">{dd}</Text>
                    </View>
                    <View className="flex-1">
                      <Text className="text-sm font-medium" numberOfLines={1}>
                        {evs[0]?.title}
                      </Text>
                      <Text className="text-xs text-muted-foreground" numberOfLines={1}>
                        {evs.length > 1 ? `+${evs.length - 1} more · ` : ""}{evs[0]?.detail}
                      </Text>
                    </View>
                    <View className={`h-2 w-2 rounded-full ${evs[0]?.bar ?? "bg-[#65A30D]"}`} />
                  </View>
                );
              })}
            </View>
          );
        })()}

        {/* Selected-day agenda — follows the grid, bound to `selected`. */}
        <Mount delay={120}>
          <View className="mx-4 mt-3 gap-2 overflow-hidden rounded-2xl bg-white p-5 dark:bg-[#1A1A20]">
            <Text className="text-xs font-medium uppercase text-muted-foreground">
              {selLabel}
            </Text>
            {selEvents.length === 0 ? (
              <View className="flex-row items-center gap-2.5 pt-1">
                <Icon name="calendar" size="md" color="mutedForeground" />
                <Text className="text-sm text-muted-foreground">No events this day</Text>
              </View>
            ) : (
              selEvents.map((e, ei) => (
                <View key={ei} className="flex-row items-center gap-2.5 pt-1">
                  <View className={`h-8 w-1 rounded-full ${e.bar}`} />
                  <View className="flex-1">
                    <Text className="text-sm font-medium">{e.title}</Text>
                    <Text className="text-xs text-muted-foreground">{e.detail}</Text>
                  </View>
                </View>
              ))
            )}
          </View>
        </Mount>
      </ScrollView>
    </SafeAreaView>
    </World>
  );
}
CalendarMonthScreen.displayName = "CalendarMonthScreen";
```

Screen: https://lumioui.dev/screens/productivity-calendar-month
iPhone 17 · iOS
9:41
Live preview — the real screen via Expo Web · open full ↗