Guided session with duration chips
import * as React from "react";
import { View } from "react-native";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import { Button, Chip, Icon, Image, Slider, Text } from "@lumioui/ui";
import { useHaptic, useReducedMotion } from "@lumioui/core";
import Animated, {
Easing,
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
} from "react-native-reanimated";
import { World } from "../shared";
export interface MeditationPlayerScreenProps {
title?: string;
guide?: string;
/** "10 min" */
duration?: string;
artworkUrl?: string;
position?: number;
positionLabel?: string;
durationLabel?: string;
playing?: boolean;
onPlayPause?: () => void;
onBack?: () => void;
}
/** One breath phase — component-specific, far slower than UI timing tokens. */
const BREATH_MS = 4000;
/** Meditation player — soft artwork, duration chips, minimal transport. */
export function MeditationPlayerScreen({
title = "Evening wind-down",
guide = "Guided · with Mira",
duration = "10 min",
artworkUrl = "https://images.unsplash.com/photo-1506126613408-eca07ce68773?w=700&h=700&fit=crop&q=70&auto=format",
position = 34,
positionLabel = "3:24",
durationLabel = "10:00",
playing = true,
onPlayPause,
onBack,
}: MeditationPlayerScreenProps) {
const haptics = useHaptic();
const insets = useSafeAreaInsets();
const [isPlaying, setIsPlaying] = React.useState(playing);
const [pos, setPos] = React.useState(position);
const [len, setLen] = React.useState(duration);
const [sound, setSound] = React.useState("Rain");
React.useEffect(() => setIsPlaying(playing), [playing]);
React.useEffect(() => setPos(position), [position]);
// Breathing sphere — the outer halo swells and settles in sync with the cue.
const reduced = useReducedMotion();
const breath = useSharedValue(1);
React.useEffect(() => {
breath.value =
isPlaying && !reduced
? withRepeat(
withTiming(1.06, {
duration: BREATH_MS,
easing: Easing.inOut(Easing.ease),
}),
-1,
true,
)
: withTiming(1, { duration: 600 });
}, [isPlaying, reduced]);
const breathStyle = useAnimatedStyle(() => ({
transform: [{ scale: breath.value }],
}));
const lenSeconds = (parseInt(len, 10) || 10) * 60;
const fmt = (s: number) =>
`${Math.floor(s / 60)}:${String(Math.round(s % 60)).padStart(2, "0")}`;
const curLabel =
position === pos ? positionLabel : fmt((pos / 100) * lenSeconds);
const totLabel =
len === duration && position === pos ? durationLabel : fmt(lenSeconds);
return (
<World primary="#16A34A">
<SafeAreaView className="flex-1 bg-[#E5E3EB] dark:bg-[#14111D]" edges={["top", "bottom"]}>
<View className="flex-row items-center justify-between px-4 pt-2">
<Button
variant="ghost"
size="icon"
haptic="light"
onPress={() => onBack?.()}
accessibilityLabel="Go back"
className="h-10 w-10"
>
<Icon name="chevron-down" size="md" color="foreground" />
</Button>
<Button
variant="ghost"
size="icon"
haptic="light"
onPress={() => haptics.light()}
accessibilityLabel="More options"
className="h-10 w-10"
>
<Icon name="ellipsis" size="sm" color="foreground" />
</Button>
</View>
<View className="flex-1 items-center justify-between px-8 pb-4">
{/* Artwork — halo rings breathing out from the disc. */}
<View className="items-center pt-6">
<View className="items-center justify-center">
<View
className="absolute left-1/2 top-1/2 h-64 w-64 rounded-full border border-[#16A34A]/15"
style={{ marginLeft: -128, marginTop: -128 }}
accessibilityElementsHidden
/>
<Animated.View
className="absolute left-1/2 top-1/2 h-80 w-80 rounded-full border border-[#16A34A]/10"
style={[{ marginLeft: -160, marginTop: -160 }, breathStyle]}
accessibilityElementsHidden
/>
<Image
source={{ uri: artworkUrl }}
className="h-56 w-56 rounded-full bg-muted/40"
accessibilityLabel={`${title} artwork`}
/>
</View>
<Text variant="h3" className="mt-5">
{title}
</Text>
<Text variant="small" className="text-muted-foreground">
{guide}
</Text>
<Text variant="small" className="pt-1 italic text-[#16A34A]/80">
{isPlaying ? "Breathe in… breathe out." : "Session paused"}
</Text>
{/* Practice streak — calm apps sell consistency. */}
<Text variant="muted" className="pt-1 text-xs">
Day 4 of your daily practice
</Text>
</View>
{/* Duration + ambient sound — compose the session. */}
<View className="items-center gap-2.5 pt-4">
<View className="flex-row gap-2">
{["5 min", "10 min", "20 min"].map((d) => (
<Chip
key={d}
variant="filter"
selected={len === d}
onPress={() => {
haptics.selection();
setLen(d);
}}
accessibilityLabel={`${d} session`}
>
{d}
</Chip>
))}
</View>
<View className="flex-row items-center gap-2">
{(
[
{ label: "Rain", icon: "droplet" },
{ label: "Forest", icon: "leaf" },
{ label: "Night", icon: "moon" },
] as const
).map((s) => (
<Chip
key={s.label}
variant="filter"
selected={sound === s.label}
onPress={() => {
haptics.selection();
setSound(s.label);
}}
accessibilityLabel={`${s.label} ambient sound`}
>
{s.label}
</Chip>
))}
</View>
</View>
{/* Scrub + transport — quiet, centered, minimal. */}
<View className="w-full pt-2">
<Slider
value={pos}
onValueChange={setPos}
min={0}
max={100}
accessibilityLabel="Session position"
/>
<View className="flex-row items-center justify-between">
<Text variant="small" className="text-muted-foreground">
{curLabel}
</Text>
<Text variant="small" className="text-muted-foreground">
{totLabel}
</Text>
</View>
<View
className="mt-3 flex-row items-center justify-center gap-8"
style={{ paddingBottom: insets.bottom + 12 }}
>
<Button
variant="ghost"
size="icon"
haptic="light"
onPress={() => setPos((p) => Math.max(0, p - 15))}
accessibilityLabel="Skip backward"
className="h-11 w-11"
>
<Icon name="skip-back" size="md" color="foreground" />
</Button>
<Button
size="icon"
haptic="medium"
onPress={() => {
haptics.medium();
setIsPlaying((p) => !p);
onPlayPause?.();
}}
accessibilityLabel={isPlaying ? "Pause" : "Play"}
accessibilityState={{ checked: isPlaying }}
className="bg-[#1B1B26] h-16 w-16 rounded-full p-0"
>
<Icon
name={isPlaying ? "pause" : "play"}
size="lg"
color="#BEF04C"
/>
</Button>
<Button
variant="ghost"
size="icon"
haptic="light"
onPress={() => setPos((p) => Math.min(100, p + 15))}
accessibilityLabel="Skip forward"
className="h-11 w-11"
>
<Icon name="skip-forward" size="md" color="foreground" />
</Button>
</View>
</View>
</View>
</SafeAreaView>
</World>
);
}
MeditationPlayerScreen.displayName = "MeditationPlayerScreen";
Recreate the "Meditation Player Screen" screen in React Native using LumioUI.
Goal: Guided session with duration chips 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-meditation-player
```
Or pull just the components: `npx lumioui add button chip icon image slider 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
### `button` — Tactile pill button with press spring, ink wash and async loading.
- `<Button>` ×5
### `chip` — Compact selectable pill with press scale and ink overlay.
- `<Chip>` ×2
### `icon` — Lucide icon set mapped to theme colors and sizes.
- `<Icon>` ×5
### `image` — Cached image with placeholder, fallback and aspect handling.
- `<Image>` ×1
### `slider` — Draggable track with haptic ticks and precise thumb.
- `<Slider>` ×1
### `text` — Themed type scale from caption to display.
- `<Text>` ×6
## 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/meditation-player.tsx` exporting MeditationPlayerScreen. 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 { View } from "react-native";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import { Button, Chip, Icon, Image, Slider, Text } from "@lumioui/ui";
import { useHaptic, useReducedMotion } from "@lumioui/core";
import Animated, {
Easing,
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
} from "react-native-reanimated";
import { World } from "../shared";
export interface MeditationPlayerScreenProps {
title?: string;
guide?: string;
/** "10 min" */
duration?: string;
artworkUrl?: string;
position?: number;
positionLabel?: string;
durationLabel?: string;
playing?: boolean;
onPlayPause?: () => void;
onBack?: () => void;
}
/** One breath phase — component-specific, far slower than UI timing tokens. */
const BREATH_MS = 4000;
/** Meditation player — soft artwork, duration chips, minimal transport. */
export function MeditationPlayerScreen({
title = "Evening wind-down",
guide = "Guided · with Mira",
duration = "10 min",
artworkUrl = "https://images.unsplash.com/photo-1506126613408-eca07ce68773?w=700&h=700&fit=crop&q=70&auto=format",
position = 34,
positionLabel = "3:24",
durationLabel = "10:00",
playing = true,
onPlayPause,
onBack,
}: MeditationPlayerScreenProps) {
const haptics = useHaptic();
const insets = useSafeAreaInsets();
const [isPlaying, setIsPlaying] = React.useState(playing);
const [pos, setPos] = React.useState(position);
const [len, setLen] = React.useState(duration);
const [sound, setSound] = React.useState("Rain");
React.useEffect(() => setIsPlaying(playing), [playing]);
React.useEffect(() => setPos(position), [position]);
// Breathing sphere — the outer halo swells and settles in sync with the cue.
const reduced = useReducedMotion();
const breath = useSharedValue(1);
React.useEffect(() => {
breath.value =
isPlaying && !reduced
? withRepeat(
withTiming(1.06, {
duration: BREATH_MS,
easing: Easing.inOut(Easing.ease),
}),
-1,
true,
)
: withTiming(1, { duration: 600 });
}, [isPlaying, reduced]);
const breathStyle = useAnimatedStyle(() => ({
transform: [{ scale: breath.value }],
}));
const lenSeconds = (parseInt(len, 10) || 10) * 60;
const fmt = (s: number) =>
`${Math.floor(s / 60)}:${String(Math.round(s % 60)).padStart(2, "0")}`;
const curLabel =
position === pos ? positionLabel : fmt((pos / 100) * lenSeconds);
const totLabel =
len === duration && position === pos ? durationLabel : fmt(lenSeconds);
return (
<World primary="#16A34A">
<SafeAreaView className="flex-1 bg-[#E5E3EB] dark:bg-[#14111D]" edges={["top", "bottom"]}>
<View className="flex-row items-center justify-between px-4 pt-2">
<Button
variant="ghost"
size="icon"
haptic="light"
onPress={() => onBack?.()}
accessibilityLabel="Go back"
className="h-10 w-10"
>
<Icon name="chevron-down" size="md" color="foreground" />
</Button>
<Button
variant="ghost"
size="icon"
haptic="light"
onPress={() => haptics.light()}
accessibilityLabel="More options"
className="h-10 w-10"
>
<Icon name="ellipsis" size="sm" color="foreground" />
</Button>
</View>
<View className="flex-1 items-center justify-between px-8 pb-4">
{/* Artwork — halo rings breathing out from the disc. */}
<View className="items-center pt-6">
<View className="items-center justify-center">
<View
className="absolute left-1/2 top-1/2 h-64 w-64 rounded-full border border-[#16A34A]/15"
style={{ marginLeft: -128, marginTop: -128 }}
accessibilityElementsHidden
/>
<Animated.View
className="absolute left-1/2 top-1/2 h-80 w-80 rounded-full border border-[#16A34A]/10"
style={[{ marginLeft: -160, marginTop: -160 }, breathStyle]}
accessibilityElementsHidden
/>
<Image
source={{ uri: artworkUrl }}
className="h-56 w-56 rounded-full bg-muted/40"
accessibilityLabel={`${title} artwork`}
/>
</View>
<Text variant="h3" className="mt-5">
{title}
</Text>
<Text variant="small" className="text-muted-foreground">
{guide}
</Text>
<Text variant="small" className="pt-1 italic text-[#16A34A]/80">
{isPlaying ? "Breathe in… breathe out." : "Session paused"}
</Text>
{/* Practice streak — calm apps sell consistency. */}
<Text variant="muted" className="pt-1 text-xs">
Day 4 of your daily practice
</Text>
</View>
{/* Duration + ambient sound — compose the session. */}
<View className="items-center gap-2.5 pt-4">
<View className="flex-row gap-2">
{["5 min", "10 min", "20 min"].map((d) => (
<Chip
key={d}
variant="filter"
selected={len === d}
onPress={() => {
haptics.selection();
setLen(d);
}}
accessibilityLabel={`${d} session`}
>
{d}
</Chip>
))}
</View>
<View className="flex-row items-center gap-2">
{(
[
{ label: "Rain", icon: "droplet" },
{ label: "Forest", icon: "leaf" },
{ label: "Night", icon: "moon" },
] as const
).map((s) => (
<Chip
key={s.label}
variant="filter"
selected={sound === s.label}
onPress={() => {
haptics.selection();
setSound(s.label);
}}
accessibilityLabel={`${s.label} ambient sound`}
>
{s.label}
</Chip>
))}
</View>
</View>
{/* Scrub + transport — quiet, centered, minimal. */}
<View className="w-full pt-2">
<Slider
value={pos}
onValueChange={setPos}
min={0}
max={100}
accessibilityLabel="Session position"
/>
<View className="flex-row items-center justify-between">
<Text variant="small" className="text-muted-foreground">
{curLabel}
</Text>
<Text variant="small" className="text-muted-foreground">
{totLabel}
</Text>
</View>
<View
className="mt-3 flex-row items-center justify-center gap-8"
style={{ paddingBottom: insets.bottom + 12 }}
>
<Button
variant="ghost"
size="icon"
haptic="light"
onPress={() => setPos((p) => Math.max(0, p - 15))}
accessibilityLabel="Skip backward"
className="h-11 w-11"
>
<Icon name="skip-back" size="md" color="foreground" />
</Button>
<Button
size="icon"
haptic="medium"
onPress={() => {
haptics.medium();
setIsPlaying((p) => !p);
onPlayPause?.();
}}
accessibilityLabel={isPlaying ? "Pause" : "Play"}
accessibilityState={{ checked: isPlaying }}
className="bg-[#1B1B26] h-16 w-16 rounded-full p-0"
>
<Icon
name={isPlaying ? "pause" : "play"}
size="lg"
color="#BEF04C"
/>
</Button>
<Button
variant="ghost"
size="icon"
haptic="light"
onPress={() => setPos((p) => Math.min(100, p + 15))}
accessibilityLabel="Skip forward"
className="h-11 w-11"
>
<Icon name="skip-forward" size="md" color="foreground" />
</Button>
</View>
</View>
</View>
</SafeAreaView>
</World>
);
}
MeditationPlayerScreen.displayName = "MeditationPlayerScreen";
```
Screen: https://lumioui.dev/screens/health-meditation-player