Liquid Metal Toggle

  • Skia
  • Reanimated
  • Gesture Handler
  • Haptics

A switch whose thumb melts as it travels and sets back into a solid bead at either end. The whole look is one SKSL shader clipped to the track, fed every frame by a single sprung progress value between 0 and 1.

Blobs that merge

fieldAt adds up r² / d² for every blob, and smoothstep turns the total into alpha around a threshold of 0.9. Where two blobs sit close together, their fields add up past the threshold in the gap between them, so they join through a neck instead of overlapping as two circles.

float fieldAt(float2 p) {  float f = 0.0;  for (int i = 0; i < ${MAX_BLOBS}; i++) {    if (float(i) >= u_count) { break; }    float4 b = u_blobs[i];    float2 d = p - b.xy;    float dist2 = max(dot(d, d), 1.0);    f += (b.z * b.z) / dist2;  }  return f;}

Liquid only in flight

thumbBlobs scales everything by 4 * p * (1 - p), which is 0 at both ends of the track and 1 halfway. Mid-slide, a trailing blob stretches up to 2.4 thumb radii behind the head while a leading one runs ahead, and both shrink as they pull away. The same curve splits red and blue edges by up to 7 px. The math is written out inline, because a worklet calling another module worklet crashes on the UI runtime.

// travelEnvelope(p), inlinedconst env = 4 * p * (1 - p);// …const reach = thumbR * (0.7 + 1.7 * g) * env;const satR = thumbR * (0.92 - 0.45 * g * env);// …const trailRaw = headX - dir * reach;const leadRaw = headX + dir * reach * 0.55;

Taps, drags and one spring

The pan only activates after 10 px of sideways travel, so a quick tap still reaches the tap gesture. Tap, drag release, the value control and replay all finish on the same spring, damped close to critical so a bounce doesn't bring the mid-slide stretch back. The haptic fires only when the side actually changes, and a cancelled drag still settles on the nearer side.

const SNAP_SPRING = { damping: 17, stiffness: 90, mass: 1 } as const;// …const pan = Gesture.Pan()  .activeOffsetX([-10, 10])  // …  .onEnd(() => {    const target = progress.get() >= 0.5 ? 1 : 0;    const changed = target !== state.get();    state.set(target);    progress.set(withSpring(target, SNAP_SPRING));    if (changed) scheduleOnRN(tapHaptic);  })

Tuning

  • #37D67A
  • #444A57
  • 0.7
  • Off
<LiquidMetalToggle params={{ onColor: "#37D67A", offColor: "#444A57", goo: 0.7, value: false }} fireSignal={0} />