Kinetik UI

Ripple Field

.md

A dot-grid surface that ripples outward from every touch, drawn by a shader.

v1.0.0Live on web@shopify/react-native-skiareact-native-gesture-handlerreact-native-reanimatedreact-native-worklets
Component preview

Installation

Kinetik CLI
npx kinetik-ui add ripple-field

The CLI copies the source and installs native dependencies with npx expo install, so versions match your Expo SDK. With shadcn: npx shadcn@latest add https://kinetik-ui.dev/r/ripple-field.json

Install manually

  1. Install dependencies
    npx expo install @shopify/react-native-skia react-native-gesture-handler react-native-reanimated react-native-worklets
  2. Copy the source files
    • components/kinetik/ripple-field.tsx
    • components/kinetik/ripple-field.shader.ts
    View source files

Usage

import { RippleField } from '@/components/kinetik/ripple-field';

<RippleField style={StyleSheet.absoluteFill}>
  <Welcome />
</RippleField>;

Every touch sends a wave through the dot grid; up to four waves overlap. The shader only runs while a wave is alive, so an untouched field costs nothing per frame.

When to use

  • Playful idle surfaces: welcome screens, empty states, a lock or splash screen.
  • Spaces where touching "nothing" should still feel responsive.

When not to use

  • Behind interactive lists or forms; every tap would ripple and distract.
  • As feedback for a specific action. Animate the control itself instead.

Notes

  • Touches are tracked without claiming the gesture, so buttons and scroll views on top keep working.
  • haptics adds a light tap per touch; it is off by default because the visual already answers the touch.
  • Under reduced motion the grid stays still.

Props

RippleFieldProps

PropTypeDefaultDescription
childrenReactNode—
spacingnumber18Distance between grid dots, in pt. Defaults to 18.
strengthnumber0.8How far the waves push the dots, 0–1. Defaults to 0.8.
accentColorstring—Colour of the wave fronts. Defaults to the theme ice.
hapticsbooleanfalseA light tap on each touch. Defaults to false.
reduceEffectsbooleanfalseStatic grid with no ripples. Reduced-motion users always get this.
styleStyleProp<ViewStyle>—

Performance and accessibility

Performance

Budget
A full-surface shader runs only for 2.5 s after a touch; at rest the grid is drawn once and nothing runs per frame.
Measured

Pixel 9 emulator, API 35, release build · interaction median 1.06× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 149 MB app memory

Real-device measurement pending.

Accessibility

Reduced motion
A static dot grid; touches do not start ripples.
Screen readers
Decorative: the canvas is hidden from assistive tech. Children stay accessible.
Touch target
The whole surface; it never claims the gesture from children or scroll views.

Try it on a device

Scan with a phone that has the Kinetik playground installed to open this demo with real haptics and sensors.

Source files

components/kinetik/ripple-field.tsx
import { Canvas, Fill, Shader, type SkSize } from '@shopify/react-native-skia';
import { useEffect, useState, type ReactNode } from 'react';
import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { useDerivedValue, useSharedValue } from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';

import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { useShaderClock } from '../../lib/kinetik/hooks/use-shader-clock';
import { colorUniform, runtimeEffect } from '../../lib/kinetik/shaders/sksl';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { RIPPLE_SHADER } from './ripple-field.shader';

export type RippleFieldProps = {
  children?: ReactNode;
  /** Distance between grid dots, in pt. Defaults to 18. */
  spacing?: number;
  /** How far the waves push the dots, 0–1. Defaults to 0.8. */
  strength?: number;
  /** Colour of the wave fronts. Defaults to the theme ice. */
  accentColor?: string;
  /** A light tap on each touch. Defaults to false. */
  haptics?: boolean;
  /** Static grid with no ripples. Reduced-motion users always get this. */
  reduceEffects?: boolean;
  style?: StyleProp<ViewStyle>;
};

const LIFE_MS = 2500;
type Ripple = [number, number, number];
const OFF: Ripple = [0, 0, -100];

/**
 * A dot-grid surface that ripples outward from every touch. Up to four waves
 * overlap. The shader only animates while a wave is alive; at rest it costs
 * nothing per frame. Touches never claim the gesture, so it can sit under
 * scrolling content.
 */
export function RippleField({
  children,
  spacing = 18,
  strength = 0.8,
  accentColor,
  haptics = false,
  reduceEffects = false,
  style,
}: RippleFieldProps) {
  const theme = useKinetikTheme();
  const reduced = useReduceMotion();
  const still = reduced || reduceEffects;
  const haptic = useHaptic(haptics);
  const size = useSharedValue<SkSize>({ width: 0, height: 0 });
  const [awake, setAwake] = useState(false);
  const [touches, setTouches] = useState(0);
  const time = useShaderClock({ running: awake && !still });
  const ripples = useSharedValue<Ripple[]>([OFF, OFF, OFF, OFF]);
  const next = useSharedValue(0);

  // Keep the clock running until the last wave has faded, then stop it.
  useEffect(() => {
    if (touches === 0) return;
    const t = setTimeout(() => setAwake(false), LIFE_MS);
    return () => clearTimeout(t);
  }, [touches]);

  const wake = () => {
    setAwake(true);
    setTouches((n) => n + 1);
    haptic('tap');
  };

  const gesture = Gesture.Manual()
    .withTestId('ripple-field')
    .enabled(!still)
    .onTouchesDown((e) => {
      const list = ripples.get().slice();
      for (const t of e.changedTouches) {
        list[next.get() % 4] = [t.x, t.y, time.get()];
        next.set(next.get() + 1);
      }
      ripples.set(list);
      scheduleOnRN(wake);
    });

  const base = colorUniform(theme.colors.background);
  const dot = colorUniform(theme.colors.textSubtle);
  const accent = colorUniform(accentColor ?? theme.colors.ice);
  const k = Math.max(0, Math.min(1, strength));

  const uniforms = useDerivedValue(() => {
    const r = ripples.get();
    return {
      res: [size.get().width, size.get().height],
      time: time.get(),
      r0: r[0] ?? OFF,
      r1: r[1] ?? OFF,
      r2: r[2] ?? OFF,
      r3: r[3] ?? OFF,
      spacing,
      strength: k,
      base,
      dotColor: dot,
      accent,
    };
  });

  return (
    <GestureDetector gesture={gesture}>
      <View collapsable={false} style={[styles.root, style]}>
        {/* Hidden from touch and screen readers on the wrapper: on the web the canvas ignores these props. */}
        <View
          pointerEvents="none"
          style={StyleSheet.absoluteFill}
          accessibilityElementsHidden
          importantForAccessibility="no-hide-descendants"
        >
          <Canvas style={StyleSheet.absoluteFill} onSize={size}>
            <Fill>
              <Shader source={runtimeEffect(RIPPLE_SHADER)} uniforms={uniforms} />
            </Fill>
          </Canvas>
        </View>
        {children}
      </View>
    </GestureDetector>
  );
}

const styles = StyleSheet.create({
  root: { overflow: 'hidden' },
});
components/kinetik/ripple-field.shader.ts
/**
 * A dot grid displaced by up to four expanding ripples.
 * Uniforms: res, time, ripple origins/start times (r0–r3: x, y, start),
 * spacing (pt), strength (0–1), base / dot / accent colours.
 */
export const RIPPLE_SHADER = `
uniform float2 res;
uniform float time;
uniform float3 r0;
uniform float3 r1;
uniform float3 r2;
uniform float3 r3;
uniform float spacing;
uniform float strength;
uniform half4 base;
uniform half4 dotColor;
uniform half4 accent;

const float LIFE = 2.4;
const float SPEED = 340.0;

// Displacement (xy) and glow (z) contributed by one ripple at point p.
float3 ripple(float2 p, float3 r) {
  float age = time - r.z;
  if (age < 0.0 || age > LIFE) return float3(0.0);
  float2 dir = p - r.xy;
  float d = length(dir);
  float x = d - age * SPEED;
  float fade = 1.0 - age / LIFE;
  float env = exp(-(x * x) / 1800.0) * fade * fade;
  float wave = sin(x * 0.09) * env;
  float2 n = d > 0.001 ? dir / d : float2(0.0);
  return float3(n * wave * 14.0 * strength, env);
}

half4 main(float2 xy) {
  float3 a = ripple(xy, r0) + ripple(xy, r1) + ripple(xy, r2) + ripple(xy, r3);
  float2 q = xy - a.xy;
  float glow = clamp(a.z, 0.0, 1.0);

  float2 cell = (fract(q / spacing) - 0.5) * spacing;
  float d = length(cell);
  float radius = 1.3 + glow * 1.6;
  float dotMask = smoothstep(radius + 0.8, radius - 0.4, d);

  half3 col = base.rgb;
  half3 dotRgb = mix(dotColor.rgb, accent.rgb, glow);
  col = mix(col, dotRgb, dotMask * (0.55 + 0.45 * glow));
  // A faint tint travels with each wave front.
  col = mix(col, accent.rgb, glow * 0.08);
  return half4(col, 1.0);
}
`;