# Grain Drift

> A drifting four-colour mesh gradient under moving film grain, drawn by a shader.

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

## Install

```bash
npx kinetik-ui add grain-drift
```

With the shadcn CLI:

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

## Usage

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

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

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

## Performance

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.

## Source

`components/kinetik/grain-drift.tsx`

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

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