Grain Drift
A drifting four-colour mesh gradient under moving film grain, drawn by a shader.
import { Canvas, Fill, Shader, type SkSize } from '@shopify/react-native-skia';
import type { ReactNode } from 'react';
import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
import { useDerivedValue, useSharedValue } from 'react-native-reanimated';
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 { GRAIN_SHADER } from './grain-drift.shader';
export type GrainDriftProps = {
children?: ReactNode;
/** Four mesh colours. Defaults to ember, ice, surface and background from the theme. */
colors?: [string, string, string, string];
/** Film grain strength, 0–1. Defaults to 0.5. Set 0 for a clean gradient. */
grain?: number;
/** Drift speed multiplier. Defaults to 1. */
speed?: number;
/** Shader updates per second. 30 halves the GPU cost. Defaults to 60. */
frameRate?: 60 | 30;
/** Draw a single still frame. Reduced-motion users always get this. */
reduceEffects?: boolean;
/** Pause while off-screen. Defaults to true. */
active?: boolean;
style?: StyleProp<ViewStyle>;
};
/**
* A drifting mesh gradient with moving film grain: four colour points on slow
* orbits, blended on the GPU. Children render on top.
*/
export function GrainDrift({
children,
colors,
grain = 0.5,
speed = 1,
frameRate = 60,
reduceEffects = false,
active = true,
style,
}: GrainDriftProps) {
const theme = useKinetikTheme();
const reduced = useReduceMotion();
const still = reduced || reduceEffects;
const size = useSharedValue<SkSize>({ width: 0, height: 0 });
const time = useShaderClock({ running: active && !still, speed, frameRate, start: 2 });
const [p0, p1, p2, p3] = colors ?? [
theme.colors.ember,
theme.colors.ice,
theme.colors.surfaceRaised,
theme.colors.background,
];
const c0 = colorUniform(p0);
const c1 = colorUniform(p1);
const c2 = colorUniform(p2);
const c3 = colorUniform(p3);
const g = Math.max(0, Math.min(1, grain));
const uniforms = useDerivedValue(() => ({
res: [size.get().width, size.get().height],
time: time.get(),
grain: g,
c0,
c1,
c2,
c3,
}));
return (
<View 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(GRAIN_SHADER)} uniforms={uniforms} />
</Fill>
</Canvas>
</View>
{children}
</View>
);
}
const styles = StyleSheet.create({
root: { overflow: 'hidden' },
});Installation
npx kinetik-ui add grain-driftpnpm dlx kinetik-ui add grain-driftbunx kinetik-ui add grain-driftyarn dlx kinetik-ui add grain-driftThe 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/grain-drift.json
Install manually
- Install dependencies
npx expo install @shopify/react-native-skia react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/grain-drift.tsxcomponents/kinetik/grain-drift.shader.ts
Usage
import { GrainDrift } from '@/components/kinetik/grain-drift';
<GrainDrift colors={['#FF6A3D', '#8FD8FF', '#1E2330', '#0B0D12']} style={{ height: 320 }}>
<NowPlaying />
</GrainDrift>;Four colour points drift on slow orbits and blend into a soft mesh gradient. A film grain, re-seeded 24 times a second like real film, sits on top. Set grain={0} for a clean gradient.
When to use
- Media surfaces: now-playing cards, covers, story backgrounds.
- Brand moments where a flat colour feels too static.
When not to use
- Behind small or low-contrast text; the moving grain adds noise. Keep text large and on a solid backdrop.
- Long lists. Use it for one hero surface.
Notes
- Pass
active={false}while off-screen;frameRate={30}halves the shader work. - Under reduced motion the gradient is drawn once and stays still.
Props
GrainDriftProps
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | |
colors | [string, string, string, string] | — | Four mesh colours. Defaults to ember, ice, surface and background from the theme. |
grain | number | 0.5 | Film grain strength, 0–1. Defaults to 0.5. Set 0 for a clean gradient. |
speed | number | 1 | Drift speed multiplier. Defaults to 1. |
frameRate | 60 | 30 | 60 | Shader updates per second. 30 halves the GPU cost. Defaults to 60. |
reduceEffects | boolean | false | Draw a single still frame. Reduced-motion users always get this. |
active | boolean | true | Pause while off-screen. Defaults to true. |
style | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- One full-surface shader per frame: four distance weights, two noise lookups and one hash per pixel. frameRate={30} halves it.
- Measured
Pixel 9 emulator, API 35, release build · interaction median 1.12× the system Settings app on the same emulator (indicative only) · keeps drawing at rest · 147 MB app memory. Animates by design while active; pass active={false} off-screen.
Real-device measurement pending.
Accessibility
- Reduced motion
- A single still frame without drift or grain animation.
- Screen readers
- Decorative: the canvas is hidden from assistive tech. Children stay accessible.
- Touch target
- Not interactive.
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/grain-drift.tsx
import { Canvas, Fill, Shader, type SkSize } from '@shopify/react-native-skia';
import type { ReactNode } from 'react';
import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
import { useDerivedValue, useSharedValue } from 'react-native-reanimated';
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 { GRAIN_SHADER } from './grain-drift.shader';
export type GrainDriftProps = {
children?: ReactNode;
/** Four mesh colours. Defaults to ember, ice, surface and background from the theme. */
colors?: [string, string, string, string];
/** Film grain strength, 0–1. Defaults to 0.5. Set 0 for a clean gradient. */
grain?: number;
/** Drift speed multiplier. Defaults to 1. */
speed?: number;
/** Shader updates per second. 30 halves the GPU cost. Defaults to 60. */
frameRate?: 60 | 30;
/** Draw a single still frame. Reduced-motion users always get this. */
reduceEffects?: boolean;
/** Pause while off-screen. Defaults to true. */
active?: boolean;
style?: StyleProp<ViewStyle>;
};
/**
* A drifting mesh gradient with moving film grain: four colour points on slow
* orbits, blended on the GPU. Children render on top.
*/
export function GrainDrift({
children,
colors,
grain = 0.5,
speed = 1,
frameRate = 60,
reduceEffects = false,
active = true,
style,
}: GrainDriftProps) {
const theme = useKinetikTheme();
const reduced = useReduceMotion();
const still = reduced || reduceEffects;
const size = useSharedValue<SkSize>({ width: 0, height: 0 });
const time = useShaderClock({ running: active && !still, speed, frameRate, start: 2 });
const [p0, p1, p2, p3] = colors ?? [
theme.colors.ember,
theme.colors.ice,
theme.colors.surfaceRaised,
theme.colors.background,
];
const c0 = colorUniform(p0);
const c1 = colorUniform(p1);
const c2 = colorUniform(p2);
const c3 = colorUniform(p3);
const g = Math.max(0, Math.min(1, grain));
const uniforms = useDerivedValue(() => ({
res: [size.get().width, size.get().height],
time: time.get(),
grain: g,
c0,
c1,
c2,
c3,
}));
return (
<View 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(GRAIN_SHADER)} uniforms={uniforms} />
</Fill>
</Canvas>
</View>
{children}
</View>
);
}
const styles = StyleSheet.create({
root: { overflow: 'hidden' },
});components/kinetik/grain-drift.shader.ts
import { NOISE } from '../../lib/kinetik/shaders/sksl';
/**
* A mesh gradient of four colour points drifting on slow orbits, under a
* moving film grain. Uniforms: res, time, grain (0–1), four colours.
*/
export const GRAIN_SHADER = `
uniform float2 res;
uniform float time;
uniform float grain;
uniform half4 c0;
uniform half4 c1;
uniform half4 c2;
uniform half4 c3;
${NOISE}
float2 orbit(float2 center, float radius, float speed, float phase) {
float a = time * speed + phase;
return center + radius * float2(cos(a), sin(a * 1.3));
}
half4 main(float2 xy) {
float2 uv = xy / res;
float aspect = res.x / max(res.y, 1.0);
float2 p = float2(uv.x * aspect, uv.y);
// A little noise in the lookup softens the blend into a painted look.
p += (float2(valueNoise(p * 3.0 + time * 0.05), valueNoise(p * 3.0 - time * 0.04)) - 0.5) * 0.12;
float2 a = orbit(float2(0.25 * aspect, 0.25), 0.18, 0.21, 0.0);
float2 b = orbit(float2(0.80 * aspect, 0.30), 0.20, 0.17, 2.1);
float2 c = orbit(float2(0.30 * aspect, 0.80), 0.22, 0.13, 4.0);
float2 d = orbit(float2(0.75 * aspect, 0.75), 0.16, 0.19, 5.3);
// Inverse-distance weights: each point owns the area around it.
float wa = 1.0 / pow(distance(p, a) + 0.05, 2.2);
float wb = 1.0 / pow(distance(p, b) + 0.05, 2.2);
float wc = 1.0 / pow(distance(p, c) + 0.05, 2.2);
float wd = 1.0 / pow(distance(p, d) + 0.05, 2.2);
half3 col = (c0.rgb * wa + c1.rgb * wb + c2.rgb * wc + c3.rgb * wd) / (wa + wb + wc + wd);
// Film grain re-seeded at 24 fps, like real film.
float frame = floor(time * 24.0);
float g = hash21(xy + frame * 17.0) - 0.5;
col += g * grain * 0.16;
return half4(col, 1.0);
}
`;