v0.1 public beta early access on GitHub
Home / Screens / health-meditation-player
Screen template

Meditation Player Screen

Guided session with duration chips

pro Expo Go ✓ iosandroid gestures: tap · scroll haptics: light · selection
$ npx lumioui add health-meditation-player
import { MeditationPlayerScreen } from "@/screens/health/meditation-player";
Registry dependencies6
Native packages installed6
@lumioui/ui@latest@lumioui/core@latestreact-native-safe-area-context@*react-native-reanimated@*react-native-svg@*nativewind@*
health/meditation-player.tsxsource · yours to edit
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";
agent prompt — paste into Claude Code, Cursor, Devin…
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
iPhone 17 · iOS
9:41
Live preview — the real screen via Expo Web · open full ↗