About this animation
A full-screen guided-breathing landscape where three soft hill layers rise and fall like a slow earthen tide.
How it works
- The component measures its own container and derives every position and size from its width and height.
- One monotonic
cycleTimeshared value advances in a Reanimated frame callback. Breath fullness, phase boundaries, cycle count, and completion all derive from that clock. - Each hill is a closed Skia path with three cubic Bézier crests. The rear, middle, and front layers evaluate breath fullness with progressively longer delays to create parallax.
- A Skia gradient blends its horizon color toward a brighter inhaled color, while the orb follows the same eased breath value.
- A UI-thread reaction detects phase changes and only crosses to JavaScript when the accessible instruction or cycle counter must update.
- Palette changes interpolate every sky, landscape, orb, and text color over one second.
Install
npx expo install @shopify/react-native-skia react-native-reanimated react-native-workletsOn web, CanvasKit must finish loading before this component mounts. Call LoadSkiaWeb in your web entry point, then render the application:
import { LoadSkiaWeb } from '@shopify/react-native-skia/lib/module/web';
LoadSkiaWeb().then(() => {
root.render(<App />);
});Usage
import BreathingHills from './BreathingHills';
export function BreathingScreen() {
return <BreathingHills />;
}Customize timing, labels, palette, cycle count, callbacks, and imperative controls:
import { useRef } from 'react';
import BreathingHills, { type BreathingHillsRef } from './BreathingHills';
export function BreathingScreen() {
const breathingRef = useRef<BreathingHillsRef>(null);
return (
<BreathingHills
ref={breathingRef}
inhaleSeconds={5}
holdSeconds={2}
exhaleSeconds={7}
cycles={5}
palette="night"
onPhaseChange={(phase, cycle) => trackPhase(phase, cycle)}
onComplete={() => navigation.goBack()}
/>
);
}The forwarded ref exposes pause(), resume(), and restart().
Props
| Prop | Type | Default | Description |
|---|---|---|---|
inhaleSeconds | number | 4 | Seconds spent inhaling. |
holdSeconds | number | 2 | Seconds spent holding. |
exhaleSeconds | number | 6 | Seconds spent exhaling. |
palette | 'dusk' | 'sage' | 'night' | HillsPalette | 'dusk' | Built-in preset or custom sky, hill, orb, and text colors. |
inhaleLabel | string | 'Breathe in' | Instruction shown while inhaling. |
holdLabel | string | 'Hold' | Instruction shown while holding. |
exhaleLabel | string | 'Breathe out' | Instruction shown while exhaling. |
cycles | number | 0 | Number of cycles to run; zero repeats forever. |
fontFamily | string | System font | Font family for instructions and the cycle counter. |
onPhaseChange | (phase, cycle) => void | — | Called when a phase begins, with a zero-based cycle number. |
onComplete | () => void | — | Called after a finite session finishes. |
style | StyleProp<ViewStyle> | — | Outer-container style. |
testID | string | — | Test identifier for the outer container. |
Compatibility
- Expo SDK 57
- React Native 0.86
- Reanimated 4.5
- React Native Worklets 0.10
- React Native Skia 2.6
- iOS: supported, not yet verified on a device
- Android: supported, not yet verified on a device
- Web: supported, not yet visually verified
- Works in Expo Go because Skia, Reanimated, and Worklets are included in the SDK
Accessibility
The current breathing instruction is exposed as a polite live-region update, and hidden phase labels are removed from the accessibility tree. With reduced motion enabled, all hills follow the same breath value without parallax or horizontal crest movement; the breathing timing and phase announcements remain intact.




