Month grid with events
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";
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