# Ripple Field

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

- Category: Background
- Version: 1.0.0
- Platforms: iOS, Android, web
- Dependencies: `@shopify/react-native-skia`, `react-native-gesture-handler`, `react-native-reanimated`, `react-native-worklets`
- Requires: React Native New Architecture (Reanimated 4)

## Install

```bash
npx kinetik-ui add ripple-field
```

With the shadcn CLI:

```bash
npx shadcn@latest add https://kinetik-ui.dev/r/ripple-field.json
```

## Usage

```tsx
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.

## RippleFieldProps

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children`? | `ReactNode` |  |  |
| `spacing`? | `number` | `18` | Distance between grid dots, in pt. Defaults to 18. |
| `strength`? | `number` | `0.8` | How far the waves push the dots, 0–1. Defaults to 0.8. |
| `accentColor`? | `string` |  | Colour of the wave fronts. Defaults to the theme ice. |
| `haptics`? | `boolean` | `false` | A light tap on each touch. Defaults to false. |
| `reduceEffects`? | `boolean` | `false` | Static grid with no ripples. Reduced-motion users always get this. |
| `style`? | `StyleProp<ViewStyle>` |  |  |

## 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.

## Performance

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.

## Source

`components/kinetik/ripple-field.tsx`

```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`

```tsx
/**
 * 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);
}
`;
```
