Kinetik UI

Grain Drift

.md

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

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

Installation

Kinetik CLI
npx kinetik-ui add grain-drift

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/grain-drift.json

Install manually

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

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

PropTypeDefaultDescription
childrenReactNode—
colors[string, string, string, string]—Four mesh colours. Defaults to ember, ice, surface and background from the theme.
grainnumber0.5Film grain strength, 0–1. Defaults to 0.5. Set 0 for a clean gradient.
speednumber1Drift speed multiplier. Defaults to 1.
frameRate60 | 3060Shader updates per second. 30 halves the GPU cost. Defaults to 60.
reduceEffectsbooleanfalseDraw a single still frame. Reduced-motion users always get this.
activebooleantruePause while off-screen. Defaults to true.
styleStyleProp<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);
}
`;