Habit tracker: week strip, progress header, tappable check rings and streaks
import * as React from "react";
import { useState } from "react";
import { ScrollView, View } from "react-native";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import { Fab, Glass, Icon, type IconName, Progress, Text } from "@lumioui/ui";
import { cn, useHaptic } from "@lumioui/core";
import { LumioMark, Mount, Press, World } from "../shared";
interface Habit {
id: string;
name: string;
streak: number;
icon: IconName;
}
export interface HabitTrackerScreenProps {
habits?: Habit[];
onToggleHabit?: (id: string, done: boolean) => void;
onAddHabit?: () => void;
onDaySelect?: (day: number) => void;
onBack?: () => void;
}
const HABITS: Habit[] = [
{ id: "1", name: "Read 20 pages", streak: 12, icon: "book-open" },
{ id: "2", name: "Run 3 km", streak: 4, icon: "zap" },
{ id: "3", name: "Meditate", streak: 21, icon: "sun" },
{ id: "4", name: "No sugar", streak: 2, icon: "wheat" },
{ id: "5", name: "Journal", streak: 7, icon: "edit" },
];
const WEEK = ["M", "T", "W", "T", "F", "S", "S"];
const DATES = [21, 22, 23, 24, 25, 26, 27];
/** Per-habit accent — soft tint resting, fills solid when done. Cycled so
* custom `habits` lists stay varied without a data field. */
const HABIT_TONES = [
{ on: "bg-[#16A34A]", off: "bg-[#16A34A]/15", fg: "primary" },
{ on: "bg-warning", off: "bg-warning/15", fg: "warning" },
{ on: "bg-chart-3", off: "bg-chart-3/15", fg: "chart3" },
{ on: "bg-success", off: "bg-success/15", fg: "success" },
{ on: "bg-chart-2", off: "bg-chart-2/15", fg: "chart2" },
] as const;
/** Completion per weekday this week — trailing days are upcoming. */
const WEEK_DONE = [1, 0.8, 0.6, 0, 0, 0, 0];
/** Level track — matches the quest screens' milestone language. */
const XP = 135;
const XP_MAX = 150;
const MILESTONES = [30, 60, 90, 120, 150];
/** Habit tracker — Momentum/Streaks idiom: a week strip with a selectable
* day, a "X of Y done" progress header, and habit rows whose check rings
* fill on tap. The streak count is the motivation. */
export function HabitTrackerScreen({
habits = HABITS,
onToggleHabit,
onAddHabit,
onDaySelect,
onBack,
}: HabitTrackerScreenProps) {
const haptics = useHaptic();
const insets = useSafeAreaInsets();
const [day, setDay] = useState(2); // Wednesday (today)
const [done, setDone] = useState<Record<string, boolean>>({
"1": true,
"3": true,
"5": true,
});
const doneCount = habits.filter((h) => done[h.id]).length;
const pct = habits.length > 0 ? Math.round((doneCount / habits.length) * 100) : 0;
const toggleHabit = (id: string) => {
haptics.selection();
const next = !done[id];
setDone((d) => ({ ...d, [id]: next }));
onToggleHabit?.(id, next);
};
return (
<World primary="#16A34A">
<SafeAreaView className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]" edges={["top"]}>
<View pointerEvents="box-none" className="absolute inset-x-4 z-10" style={{ top: insets.top + 8 }}>
<View className="flex-row items-center justify-between">
<Glass interactive effect="regular" className="h-11 w-11 items-center justify-center rounded-full">
<Press
haptic="light"
onPress={() => onBack?.()}
accessibilityRole="button"
accessibilityLabel="Go back"
className="h-9 w-9 items-center justify-center"
>
<Icon name="chevron-left" size="md" color="foreground" />
</Press>
</Glass>
<View className="w-11" />
</View>
<View pointerEvents="none" className="absolute inset-0 items-center justify-center">
<View className="h-11 flex-row items-center justify-center gap-2 rounded-full pl-3.5 pr-5">
<Glass effect="regular" className="absolute inset-0 rounded-full" />
<LumioMark size={18} />
<Text accessibilityRole="header" className="text-sm font-bold tracking-tight">
Today
</Text>
</View>
</View>
</View>
<View className="flex-row items-center justify-end gap-1.5 px-4 pb-1" style={{ paddingTop: insets.top + 58 }}>
{/* Currency pills — the quest family's gem + longest-streak brag. */}
<View className="ml-auto flex-row items-center gap-1.5">
<View className="overflow-hidden rounded-full">
<Glass effect="regular" className="absolute inset-0 rounded-full" pointerEvents="none" />
<View className="flex-row items-center gap-1 px-2.5 py-1.5">
<Text className="text-[13px]">💎</Text>
<Text className="text-[13px] font-bold tabular-nums">235</Text>
</View>
</View>
<View className="overflow-hidden rounded-full">
<Glass effect="regular" className="absolute inset-0 rounded-full" pointerEvents="none" />
<View className="flex-row items-center gap-1 px-2.5 py-1.5">
<Text className="text-[13px]">🔥</Text>
<Text className="text-[13px] font-bold tabular-nums">21d</Text>
</View>
</View>
</View>
</View>
<ScrollView showsVerticalScrollIndicator={false} contentContainerClassName="gap-6 px-6 pb-10 pt-4">
{/* Week strip — selectable day cells, today ringed. */}
<Mount className="flex-row justify-between">
{WEEK.map((d, i) => {
const selected = i === day;
return (
<Press
key={`${d}-${i}`}
onPress={() => {
haptics.selection();
setDay(i);
onDaySelect?.(i);
}}
accessibilityRole="button"
accessibilityLabel={`${d} ${DATES[i]}`}
accessibilityState={{ selected }}
className={cn(
"h-14 w-11 items-center justify-center gap-0.5 rounded-2xl",
selected ? "bg-[#16A34A]" : "bg-white dark:bg-[#1A1A20]"
)}
>
<Text className={cn("text-xs", selected ? "text-primary-foreground/70" : "text-muted-foreground")}>
{d}
</Text>
<Text className={cn("font-semibold tabular-nums", selected ? "text-primary-foreground" : "")}>
{DATES[i]}
</Text>
</Press>
);
})}
</Mount>
{/* Progress header */}
<Mount delay={60} className="gap-2.5">
<View className="flex-row items-baseline justify-between">
<Text variant="h2">
{doneCount} of {habits.length} done
</Text>
<Text variant="muted" className="tabular-nums">
{pct}%
</Text>
</View>
<Progress value={pct} />
</Mount>
{/* Level strip — the milestone track that makes the XP real. */}
<Mount delay={90} className="overflow-hidden rounded-3xl">
<Glass interactive effect="regular" tintColor="#D8F1DC" sheen={0.4} className="absolute inset-0 rounded-3xl" pointerEvents="none" />
<View className="gap-3 p-4">
<View className="flex-row items-center justify-between">
<Text className="text-[13px] font-bold text-[#14532D]">👑 Level 4</Text>
<Text className="text-[12px] font-semibold text-[#14532D]/60">⚡ {XP}/{XP_MAX} XP</Text>
</View>
<View className="relative h-8 justify-center">
<View className="h-1.5 rounded-full bg-[#15803D]/15" />
<View
className="absolute h-1.5 rounded-full bg-[#22C55E]"
style={{ width: `${(XP / XP_MAX) * 96}%` }}
/>
<View className="absolute inset-x-0 flex-row justify-between">
{MILESTONES.map((m) => (
<View
key={m}
className="h-8 w-8 items-center justify-center rounded-full"
style={{ backgroundColor: m <= XP ? "#22C55E" : "#DCF5E3" }}
>
<Text className="text-[11px]">{m <= XP ? "✓" : "🏆"}</Text>
</View>
))}
</View>
</View>
</View>
</Mount>
{/* Habit rows — check ring fills on tap; each pays out XP. */}
<Mount delay={120} className="gap-2.5">
{habits.map((h, hi) => {
const checked = !!done[h.id];
const tone = HABIT_TONES[hi % HABIT_TONES.length]!;
return (
<Press
key={h.id}
onPress={() => toggleHabit(h.id)}
accessibilityRole="button"
accessibilityLabel={`${h.name}, ${h.streak} day streak${checked ? ", done" : ""}`}
accessibilityState={{ checked }}
className={cn(
"flex-row items-center gap-3.5 rounded-3xl p-4",
checked ? "bg-[#16A34A]/10" : "bg-white dark:bg-[#1A1A20]"
)}
>
<View
className={cn(
"h-9 w-9 items-center justify-center rounded-full",
checked ? tone.on : tone.off
)}
>
<Icon
name={h.icon}
size="sm"
color={checked ? "primaryForeground" : tone.fg}
/>
</View>
<View className="flex-1">
<Text variant="small" className="font-semibold">
{h.name}
</Text>
<View className="flex-row items-center gap-1">
<Icon name="zap" size="sm" color="warning" />
<Text variant="muted" className="text-xs tabular-nums">
{h.streak} day streak · +10 XP
</Text>
</View>
</View>
<View
className={cn(
"h-7 w-7 items-center justify-center rounded-full border-2",
checked ? "border-[#16A34A] bg-[#16A34A]" : "border-border bg-transparent"
)}
>
{checked ? <Icon name="check" size="sm" color="primaryForeground" /> : null}
</View>
</Press>
);
})}
</Mount>
{/* Week at a glance — mini completion bars, today carries state. */}
<Mount delay={180} className="gap-4 overflow-hidden rounded-3xl bg-white p-5 dark:bg-[#1A1A20]">
<Text className="font-semibold">This week</Text>
<View className="flex-row items-end justify-between">
{WEEK.map((d, i) => (
<View key={`${d}-${i}`} className="items-center gap-2">
<View className="h-12 w-4 justify-end overflow-hidden rounded-full bg-muted">
{(WEEK_DONE[i] ?? 0) > 0 && (
<View
className={cn("w-full rounded-full", i === day ? "bg-[#16A34A]" : "bg-foreground/70")}
style={{ height: `${(WEEK_DONE[i] ?? 0) * 100}%` }}
/>
)}
</View>
<Text className="text-xs text-muted-foreground">{d}</Text>
</View>
))}
</View>
</Mount>
</ScrollView>
<Fab
icon={<Icon name="plus" size="md" color="#1B1B26" />}
haptic="medium"
onPress={() => onAddHabit?.()}
accessibilityLabel="New habit"
className="bg-[#BEF04C]"
/>
</SafeAreaView>
</World>
);
}
HabitTrackerScreen.displayName = "HabitTrackerScreen";
Recreate the "Habit Tracker Screen" screen in React Native using LumioUI.
Goal: Habit tracker: week strip, progress header, tappable check rings and streaks 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 health-habit-tracker
```
Or pull just the components: `npx lumioui add fab icon progress text`
Native packages:
- @lumioui/ui@latest
- @lumioui/core@latest
- react-native-safe-area-context@*
- 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
### `fab` — Liquid-glass floating action button — glass disc or filled pill, extended label mode, auto-async loading swap, mount pop
- `<Fab>` ×1
### `icon` — Lucide icon set mapped to theme colors and sizes.
- `<Icon>` ×5
### `progress` — Animated fill bar with indeterminate sweep mode.
- `<Progress>` ×1
### `text` — Themed type scale from caption to display.
- `<Text>` ×16
## 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/health/habit-tracker.tsx` exporting HabitTrackerScreen. 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 { useState } from "react";
import { ScrollView, View } from "react-native";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import { Fab, Glass, Icon, type IconName, Progress, Text } from "@lumioui/ui";
import { cn, useHaptic } from "@lumioui/core";
import { LumioMark, Mount, Press, World } from "../shared";
interface Habit {
id: string;
name: string;
streak: number;
icon: IconName;
}
export interface HabitTrackerScreenProps {
habits?: Habit[];
onToggleHabit?: (id: string, done: boolean) => void;
onAddHabit?: () => void;
onDaySelect?: (day: number) => void;
onBack?: () => void;
}
const HABITS: Habit[] = [
{ id: "1", name: "Read 20 pages", streak: 12, icon: "book-open" },
{ id: "2", name: "Run 3 km", streak: 4, icon: "zap" },
{ id: "3", name: "Meditate", streak: 21, icon: "sun" },
{ id: "4", name: "No sugar", streak: 2, icon: "wheat" },
{ id: "5", name: "Journal", streak: 7, icon: "edit" },
];
const WEEK = ["M", "T", "W", "T", "F", "S", "S"];
const DATES = [21, 22, 23, 24, 25, 26, 27];
/** Per-habit accent — soft tint resting, fills solid when done. Cycled so
* custom `habits` lists stay varied without a data field. */
const HABIT_TONES = [
{ on: "bg-[#16A34A]", off: "bg-[#16A34A]/15", fg: "primary" },
{ on: "bg-warning", off: "bg-warning/15", fg: "warning" },
{ on: "bg-chart-3", off: "bg-chart-3/15", fg: "chart3" },
{ on: "bg-success", off: "bg-success/15", fg: "success" },
{ on: "bg-chart-2", off: "bg-chart-2/15", fg: "chart2" },
] as const;
/** Completion per weekday this week — trailing days are upcoming. */
const WEEK_DONE = [1, 0.8, 0.6, 0, 0, 0, 0];
/** Level track — matches the quest screens' milestone language. */
const XP = 135;
const XP_MAX = 150;
const MILESTONES = [30, 60, 90, 120, 150];
/** Habit tracker — Momentum/Streaks idiom: a week strip with a selectable
* day, a "X of Y done" progress header, and habit rows whose check rings
* fill on tap. The streak count is the motivation. */
export function HabitTrackerScreen({
habits = HABITS,
onToggleHabit,
onAddHabit,
onDaySelect,
onBack,
}: HabitTrackerScreenProps) {
const haptics = useHaptic();
const insets = useSafeAreaInsets();
const [day, setDay] = useState(2); // Wednesday (today)
const [done, setDone] = useState<Record<string, boolean>>({
"1": true,
"3": true,
"5": true,
});
const doneCount = habits.filter((h) => done[h.id]).length;
const pct = habits.length > 0 ? Math.round((doneCount / habits.length) * 100) : 0;
const toggleHabit = (id: string) => {
haptics.selection();
const next = !done[id];
setDone((d) => ({ ...d, [id]: next }));
onToggleHabit?.(id, next);
};
return (
<World primary="#16A34A">
<SafeAreaView className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]" edges={["top"]}>
<View pointerEvents="box-none" className="absolute inset-x-4 z-10" style={{ top: insets.top + 8 }}>
<View className="flex-row items-center justify-between">
<Glass interactive effect="regular" className="h-11 w-11 items-center justify-center rounded-full">
<Press
haptic="light"
onPress={() => onBack?.()}
accessibilityRole="button"
accessibilityLabel="Go back"
className="h-9 w-9 items-center justify-center"
>
<Icon name="chevron-left" size="md" color="foreground" />
</Press>
</Glass>
<View className="w-11" />
</View>
<View pointerEvents="none" className="absolute inset-0 items-center justify-center">
<View className="h-11 flex-row items-center justify-center gap-2 rounded-full pl-3.5 pr-5">
<Glass effect="regular" className="absolute inset-0 rounded-full" />
<LumioMark size={18} />
<Text accessibilityRole="header" className="text-sm font-bold tracking-tight">
Today
</Text>
</View>
</View>
</View>
<View className="flex-row items-center justify-end gap-1.5 px-4 pb-1" style={{ paddingTop: insets.top + 58 }}>
{/* Currency pills — the quest family's gem + longest-streak brag. */}
<View className="ml-auto flex-row items-center gap-1.5">
<View className="overflow-hidden rounded-full">
<Glass effect="regular" className="absolute inset-0 rounded-full" pointerEvents="none" />
<View className="flex-row items-center gap-1 px-2.5 py-1.5">
<Text className="text-[13px]">💎</Text>
<Text className="text-[13px] font-bold tabular-nums">235</Text>
</View>
</View>
<View className="overflow-hidden rounded-full">
<Glass effect="regular" className="absolute inset-0 rounded-full" pointerEvents="none" />
<View className="flex-row items-center gap-1 px-2.5 py-1.5">
<Text className="text-[13px]">🔥</Text>
<Text className="text-[13px] font-bold tabular-nums">21d</Text>
</View>
</View>
</View>
</View>
<ScrollView showsVerticalScrollIndicator={false} contentContainerClassName="gap-6 px-6 pb-10 pt-4">
{/* Week strip — selectable day cells, today ringed. */}
<Mount className="flex-row justify-between">
{WEEK.map((d, i) => {
const selected = i === day;
return (
<Press
key={`${d}-${i}`}
onPress={() => {
haptics.selection();
setDay(i);
onDaySelect?.(i);
}}
accessibilityRole="button"
accessibilityLabel={`${d} ${DATES[i]}`}
accessibilityState={{ selected }}
className={cn(
"h-14 w-11 items-center justify-center gap-0.5 rounded-2xl",
selected ? "bg-[#16A34A]" : "bg-white dark:bg-[#1A1A20]"
)}
>
<Text className={cn("text-xs", selected ? "text-primary-foreground/70" : "text-muted-foreground")}>
{d}
</Text>
<Text className={cn("font-semibold tabular-nums", selected ? "text-primary-foreground" : "")}>
{DATES[i]}
</Text>
</Press>
);
})}
</Mount>
{/* Progress header */}
<Mount delay={60} className="gap-2.5">
<View className="flex-row items-baseline justify-between">
<Text variant="h2">
{doneCount} of {habits.length} done
</Text>
<Text variant="muted" className="tabular-nums">
{pct}%
</Text>
</View>
<Progress value={pct} />
</Mount>
{/* Level strip — the milestone track that makes the XP real. */}
<Mount delay={90} className="overflow-hidden rounded-3xl">
<Glass interactive effect="regular" tintColor="#D8F1DC" sheen={0.4} className="absolute inset-0 rounded-3xl" pointerEvents="none" />
<View className="gap-3 p-4">
<View className="flex-row items-center justify-between">
<Text className="text-[13px] font-bold text-[#14532D]">👑 Level 4</Text>
<Text className="text-[12px] font-semibold text-[#14532D]/60">⚡ {XP}/{XP_MAX} XP</Text>
</View>
<View className="relative h-8 justify-center">
<View className="h-1.5 rounded-full bg-[#15803D]/15" />
<View
className="absolute h-1.5 rounded-full bg-[#22C55E]"
style={{ width: `${(XP / XP_MAX) * 96}%` }}
/>
<View className="absolute inset-x-0 flex-row justify-between">
{MILESTONES.map((m) => (
<View
key={m}
className="h-8 w-8 items-center justify-center rounded-full"
style={{ backgroundColor: m <= XP ? "#22C55E" : "#DCF5E3" }}
>
<Text className="text-[11px]">{m <= XP ? "✓" : "🏆"}</Text>
</View>
))}
</View>
</View>
</View>
</Mount>
{/* Habit rows — check ring fills on tap; each pays out XP. */}
<Mount delay={120} className="gap-2.5">
{habits.map((h, hi) => {
const checked = !!done[h.id];
const tone = HABIT_TONES[hi % HABIT_TONES.length]!;
return (
<Press
key={h.id}
onPress={() => toggleHabit(h.id)}
accessibilityRole="button"
accessibilityLabel={`${h.name}, ${h.streak} day streak${checked ? ", done" : ""}`}
accessibilityState={{ checked }}
className={cn(
"flex-row items-center gap-3.5 rounded-3xl p-4",
checked ? "bg-[#16A34A]/10" : "bg-white dark:bg-[#1A1A20]"
)}
>
<View
className={cn(
"h-9 w-9 items-center justify-center rounded-full",
checked ? tone.on : tone.off
)}
>
<Icon
name={h.icon}
size="sm"
color={checked ? "primaryForeground" : tone.fg}
/>
</View>
<View className="flex-1">
<Text variant="small" className="font-semibold">
{h.name}
</Text>
<View className="flex-row items-center gap-1">
<Icon name="zap" size="sm" color="warning" />
<Text variant="muted" className="text-xs tabular-nums">
{h.streak} day streak · +10 XP
</Text>
</View>
</View>
<View
className={cn(
"h-7 w-7 items-center justify-center rounded-full border-2",
checked ? "border-[#16A34A] bg-[#16A34A]" : "border-border bg-transparent"
)}
>
{checked ? <Icon name="check" size="sm" color="primaryForeground" /> : null}
</View>
</Press>
);
})}
</Mount>
{/* Week at a glance — mini completion bars, today carries state. */}
<Mount delay={180} className="gap-4 overflow-hidden rounded-3xl bg-white p-5 dark:bg-[#1A1A20]">
<Text className="font-semibold">This week</Text>
<View className="flex-row items-end justify-between">
{WEEK.map((d, i) => (
<View key={`${d}-${i}`} className="items-center gap-2">
<View className="h-12 w-4 justify-end overflow-hidden rounded-full bg-muted">
{(WEEK_DONE[i] ?? 0) > 0 && (
<View
className={cn("w-full rounded-full", i === day ? "bg-[#16A34A]" : "bg-foreground/70")}
style={{ height: `${(WEEK_DONE[i] ?? 0) * 100}%` }}
/>
)}
</View>
<Text className="text-xs text-muted-foreground">{d}</Text>
</View>
))}
</View>
</Mount>
</ScrollView>
<Fab
icon={<Icon name="plus" size="md" color="#1B1B26" />}
haptic="medium"
onPress={() => onAddHabit?.()}
accessibilityLabel="New habit"
className="bg-[#BEF04C]"
/>
</SafeAreaView>
</World>
);
}
HabitTrackerScreen.displayName = "HabitTrackerScreen";
```
Screen: https://lumioui.dev/screens/health-habit-tracker