Building a Liquid Progress Bar in React Native That Sloshes When You Tilt the Phone
Reanimated worklets, react-native-svg and a damped spring: how I separated gravity from a shaking hand and made the water rock back the way it does in a glass.
My hydration app shows the day’s progress as water in a ring. The first version was what you’d expect: a sine wave in an SVG, moving sideways forever. It looked fine in a screenshot and fake in the hand.
What made it feel real turned out to be one thing: when you tilt the phone, the water stays level. It swings past level, rocks back and settles. Shake the phone and the waves whip up. Turn it upside down and the water ends up at the top of the screen.
I’ve since pulled that water out of the app into an open-source package, react-native-liquid-fill. It’s one of several packages that came out of my apps; the rest are at developer.living/projects. This post is about how it works, and about the three places where the obvious version broke: the accelerometer, a phone lying flat on a table, and frames that don’t line up with sensor readings.
The shape of it
The whole component is one <Svg>:
- a clip path in the shape of the container (a circle, a rounded rect or any SVG path you pass in),
- two wave paths inside it, a pale one at the back and a darker gradient at the front, travelling in opposite directions,
- a handful of bubbles rising through the water.
The trick that makes tilt cheap: the water is always drawn level in its own frame and then rotated as a whole around the container’s centre. Tilt never changes the wave maths, only the angle it gets turned by.
For that to work, the body of the water has to cover the container at any angle, so each wave path extends half the container’s diagonal from the centre in every direction, and drops well below the floor:
export function wavePath(cx, cy, reach, level, phase, amplitude, wavelength, rotation) {
'worklet';
const segments = 36;
const cos = Math.cos(rotation);
const sin = Math.sin(rotation);
// Rotate a point in the water's frame around the container's centre.
const point = (x: number, y: number) =>
`${cx + (x - cx) * cos - (y - cy) * sin} ${cy + (x - cx) * sin + (y - cy) * cos}`;
const left = cx - reach;
const span = reach * 2;
const floor = cy + reach + amplitude * 4;
let d = '';
for (let i = 0; i <= segments; i += 1) {
const x = left + (span * i) / segments;
const y = level + amplitude * Math.sin(((x - left) / wavelength) * Math.PI * 2 + phase);
d += `${i === 0 ? 'M' : 'L'}${point(x, y)} `;
}
return `${d}L${point(left + span, floor)} L${point(left, floor)} Z`;
}Thirty-six straight segments are enough to read as a smooth curve, and the path is plain enough to rebuild on every frame.
In a circle, rotating the water is exact: a circle looks the same from any angle, so the level doesn’t change. In other shapes the surface still stays level and swings, but the water isn’t re-measured for the new angle. It’s a convincing look, not a fluid simulation, and I’d rather say that up front.
Everything runs on the UI thread
A wave that stutters every time the JS thread is busy is worse than no wave. So all of it (waves, bubbles, the slosh physics) runs in Reanimated worklets, driven by a single useFrameCallback:
const frame = useFrameCallback((info) => {
// A dropped frame must not kick the surface, so the step is capped.
const dt = Math.min((info.timeSincePreviousFrame ?? 16) / 1000, 1 / 30);
phaseBack.value = (phaseBack.value + dt * backSpeed) % TAU;
phaseFront.value = (phaseFront.value + dt * frontSpeed) % TAU;
clock.value += dt;
}, false);The two waves are useAnimatedProps on an Animated.createAnimatedComponent(Path), so each frame rebuilds a d string on the UI thread and React never re-renders.
That 1 / 30 cap matters more than it looks. Without it, a 300 ms hitch (an app coming back from the background, say) becomes a single 300 ms physics step, and the spring I'll describe below sends the water flying. Capping the step means a dropped frame just makes the water a little slower for a moment.
The component also takes paused, which calls frame.setActive(false). A screen nobody is looking at shouldn't be burning frames on waves.
Reading the phone: the accelerometer lies a little differently on each platform
For the tilt I need one thing: which way is down, in screen coordinates. An accelerometer at rest measures gravity, so in theory it’s atan2(x, y) and done.
In practice there were three surprises before I got to any maths.
iOS and Android disagree on the sign. Android reports the reaction to gravity: +1 g on the axis pointing up. iOS reports gravity itself: −1 g on that axis. Same vector, opposite sign. One constant fixes it:
const GRAVITY_SIGN = Platform.OS === 'ios' ? 1 : -1;Reanimated’s sensor hook wasn’t the right sensor. My first instinct was useAnimatedSensor, because it delivers readings straight to the UI thread. But on Android its "accelerometer" is the fused linear-acceleration sensor, with gravity already taken out, which is exactly the part I need. It isn't there at all on phones without a gyroscope. So the package reads the raw accelerometer through expo-sensors and hands each reading to the UI thread itself.
Android 12 quietly slows the sensor down. Without the HIGH_SAMPLING_RATE_SENSORS permission, the accelerometer runs at the platform's "normal" rate: one reading every 200 ms, no matter what interval you ask for. Five readings a second makes the water visibly steppy. If you use tilt, add it to app.json:
{ "expo": { "android": { "permissions": ["android.permission.HIGH_SAMPLING_RATE_SENSORS"] } } }The JS side of the sensor does as little as possible. Each reading is flipped into screen space and written to a shared value, and never read back:
const stop = source(({ x, y, z, timestamp }) => {
felt.value = { x: sign * x, y: -sign * y, z: sign * z, t: timestamp };
});Everything else happens in a useAnimatedReaction on the UI thread, next to the frame loop that draws the water.
Separating gravity from the hand
An accelerometer doesn’t measure gravity. It measures gravity plus whatever your hand is doing to the phone. Feed the raw angle straight into the water and every little shake reads as the phone turning: the surface jitters constantly.
The fix is a low-pass filter. Gravity is the slow part of the signal, the hand is the fast part. Smooth the readings and what’s left is gravity; subtract that from the raw reading and what’s left is the hand.
The common way to write this is gravity += (reading - gravity) * 0.1. The problem is that 0.1 is a share per reading, and the reading rate isn't fixed: 60 Hz on one phone, 100 Hz on another, 5 Hz on Android 12 without the permission. The same code would respond at wildly different speeds.
So the filter is defined by a time constant instead, and the share is derived from how long each reading actually stands for:
const GRAVITY_LAG = 0.13; // seconds
const gap = now.t - prev.t;
// A timestamp that runs backwards or jumps (new subscription, stalled sensor)
// counts as one frame, not as a quarter-second shove.
const dt = gap > 0 && gap <= 0.25 ? gap : 1 / 60;
const smoothing = 1 - Math.exp(-dt / GRAVITY_LAG);
gravity.x += (now.x - gravity.x) * smoothing;
gravity.y += (now.y - gravity.y) * smoothing;
gravity.z += (now.z - gravity.z) * smoothing;1 - exp(-dt / τ) is the exact share an exponential filter with time constant τ should take over a step of dt, so the water responds the same way at any sensor rate. 0.13 s is slow enough that a shake doesn't register as a turn and quick enough that a real turn catches up in a few tenths of a second.
What’s left over becomes two separate signals:
- stir: how hard the phone is being shaken, on all three axes. It makes the waves taller.
- jolt: how far a sideways shove tipped the gravity the water feels. It pushes the surface sideways.
Both are sums over readings, weighted by how long each reading stands for, so a slow sensor shoves the water just as hard in fewer, larger steps.
The phone lying on the table
This was the bug I didn’t see coming. Lay the phone flat and the water slowly drifts to one side and sits there, or twitches back and forth.
When the phone is flat, gravity points straight through the screen. Its component in the screen plane is close to zero, and atan2 of a vector that's nearly zero is pure noise. A tiny wobble can swing the "down" direction by 90°.
Get Dmitriy Belenov’s stories in your inbox
Join Medium for free to get updates from this writer.
You can’t pick a single cut-off either: set a threshold and a phone laid down slowly snaps the water across the moment it crosses it. So there’s a band. Above 0.35 g of in-plane gravity (a lean of about 20°) the water follows the lean. Below 0.15 g (about 9°) it settles at the bottom of the screen, the way it sits when you hold the phone upright. In between, it blends:
const lean = Math.hypot(gravity.x, gravity.y);
const down = unwrap(angle.value, Math.atan2(-gravity.x, gravity.y));
const upright = Math.round(angle.value / TAU) * TAU;
const follow = clamp((lean - 0.15) / (0.35 - 0.15), 0, 1);
angle.value = upright + (down - upright) * follow;Jolts are ignored entirely below 0.2 g of lean, so tapping a phone on the table doesn’t shove anything.
Note the unwrap. atan2 returns angles in ±π, so a phone turning past upside down jumps from +179° to −179°. Feed that into a spring and the water swings all the way round the long way. unwrap keeps the angle continuous: it moves each new angle onto the same turn as the previous one, so the difference is always under half a turn.
function unwrap(prev: number, next: number): number {
'worklet';
const tau = Math.PI * 2;
return prev + ((((next - prev) % tau) + tau * 1.5) % tau) - Math.PI;
}A phone turned round three times reads 6π, and the water follows it the short way every time.
Making it slosh: a damped spring
With a clean angle, the easy thing is to rotate the water by it directly. It works, and it feels dead: the water is glued to the screen.
Real water in a glass lags behind, overshoots and rocks back. That is a damped spring, and it’s six lines inside the frame callback:
const STIFFNESS = 38;
const DAMPING = 3.4;
const pull = STIFFNESS * (angle.value - surface.value) - DAMPING * velocity.value;
velocity.value += pull * dt + shove * KICK;
velocity.value = clamp(velocity.value, -MAX_SPIN, MAX_SPIN);
surface.value += velocity.value * dt;surface is the angle the water is actually drawn at; it chases angle on the spring. The numbers come from how water in a hand-held glass behaves: √38 ≈ 6.2 rad/s is about 1 Hz, and 3.4 / (2 × √38) gives a damping ratio of about 0.28, low enough to overshoot a couple of times and high enough to settle within a second or so.
The jolt goes in as a kick to the spring’s velocity. One more detail here: sensor readings don’t arrive in step with frames. One frame gets two readings, the next gets none. Applying each shove the moment it lands makes the surface jerk. So the waiting shove is drawn down over a few frames instead:
const take = Math.min(1, dt * 18); // share of the waiting shove taken this frame
const shove = jolt.value * take;
jolt.value -= shove;The same shove, spread over three or four frames, reads as one push. And MAX_SPIN caps the swing speed, so however hard you shake the phone the water never spins like a washing machine.
Waves that react
The last piece is what makes a shake feel physical rather than just rotated. The frame callback keeps a swell value between 0 and 1: how hard the surface is rocking right now, from the spring's speed and the shaking.
const energy = Math.min(1, Math.max(Math.abs(velocity.value) * 0.6, agitation.value));
swell.value += (energy - swell.value) * Math.min(1, dt * 5);Swell makes the waves taller (up to 2.6× their resting height) and faster. And the crests drift towards the low side of the tilt, by shifting each wave’s phase by how far the surface is off level:
phase: phaseFront.value - (surface.value - angle.value) * 2.6,
amplitude: frontAmplitude * (1 + swell.value * 1.6),The back and front waves use different multipliers, so they separate slightly while the water rocks. It’s a small thing, and it’s the thing people notice.
Bubbles with no state
The bubbles look like particles, but none of them keeps any state. Each bubble’s position is a pure function of one shared clock:
const rise = (clock.value * seed.speed + seed.offset) % 1;
const x = width * (seed.x + Math.sin(clock.value * seed.swaySpeed + seed.offset * TAU) * seed.sway);
const y = floor - (floor - ceiling) * rise;That buys three things. Seven bubbles cost seven tiny worklets a frame and nothing else. When a paused screen comes back, they carry on from where they should be instead of all setting off together. And because the seeds are written by hand rather than generated with Math.random(), the same bubbles come back on every render, so nothing pops into a new place.
Bubbles are laid out in the water’s own frame and rotated with it, so they rise towards the top of the screen even upside down. Each one fades out just below the surface (water lets a bubble go at the surface, it doesn’t sit on top of it), and they disappear entirely when there’s too little water to rise through.
Any shape
Because the water is just clipped, the container can be anything. circle, rounded and rect are built in, and path takes any SVG path with the box it was drawn in:
<LiquidFill
width={100}
height={125}
path="M50 0 C50 0 95 52 95 80 A45 45 0 0 1 5 80 C5 52 50 0 50 0 Z"
pathSize={{ width: 100, height: 125 }}
progress={0.6}
/>A drop, a bottle, a battery, a card. One thing to keep in mind: progress is the height of the water, not its volume. In a bottle with a narrow neck, 90% full by height isn't 90% by volume.
Using it
npm install react-native-liquid-fill
npx expo install react-native-reanimated react-native-svg expo-sensorsimport { LiquidFill } from 'react-native-liquid-fill';
import { useAccelerometerTilt } from 'react-native-liquid-fill/expo';
function Water({ progress }: { progress: number }) {
const tilt = useAccelerometerTilt();
return <LiquidFill size={160} progress={progress} tilt={tilt} accessibilityLabel="Water today" />;
}expo-sensors is optional. Leave out tilt and the water stays level and just rolls. If you use a different sensor library, useTilt(source) takes any accelerometer.
Tie the sensor and the animation to screen focus, so neither runs on a screen nobody can see:
const focused = useIsFocused();
const tilt = useAccelerometerTilt({ enabled: focused });
<LiquidFill progress={progress} tilt={tilt} paused={!focused} />There’s also a ProgressRing that springs on the same curve as the water, so a ring and the water inside it rise together, and draws a second, lighter lap past 100%. That's the ring in the GIF at the top.
Both components are exposed to screen readers as progress bars and read out their percentage.
What I’d take away from this
Most of the work wasn’t the waves. It was deciding what the water should ignore: the hand, a phone lying flat, a stalled sensor, a dropped frame, two readings landing in one frame. Every one of those looked fine in the emulator and felt wrong in the hand.
If you build anything with the accelerometer, the two ideas worth stealing are these. Define filters by a time constant, not a share per reading, so they hold at any sensor rate. And cap the physics step, so a hitch never turns into a kick.
The package is MIT-licensed:
- npm: react-native-liquid-fill
- GitHub: nahkar/react-native-liquid-fill
- Demos and docs: developer.living/projects/react-native-liquid-fill
- More of my packages, apps and extensions: developer.living/projects
It came out of DrinkUp, the water tracker it was built for. Next up in this series: why wheel pickers feel different on iOS and Android, and what it takes to make them feel the same.
If this was useful, a clap helps other React Native developers find it. Questions and edge cases I missed are very welcome in the comments.










