Ripple Field
A dot-grid surface that ripples outward from every touch, drawn by a shader.
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' },
});Installation
npx kinetik-ui add ripple-fieldpnpm dlx kinetik-ui add ripple-fieldbunx kinetik-ui add ripple-fieldyarn dlx kinetik-ui add ripple-fieldThe 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
- Install dependencies
npx expo install @shopify/react-native-skia react-native-gesture-handler react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/ripple-field.tsxcomponents/kinetik/ripple-field.shader.ts
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.
hapticsadds 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
| 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> | — |
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);
}
`;