Kinetik UI

Aurora Veil

.md

A slow, flowing aurora drawn by a fragment shader, for screen and hero backgrounds.

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

Installation

Kinetik CLI
npx kinetik-ui add aurora-veil

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/aurora-veil.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/aurora-veil.tsx
    • components/kinetik/aurora-veil.shader.ts
    View source files

Usage

import { AuroraVeil } from '@/components/kinetik/aurora-veil';

<AuroraVeil style={StyleSheet.absoluteFill}>
  <Hero />
</AuroraVeil>;

The aurora fills its container and draws behind its children. It fades into baseColor (the theme background by default) towards the bottom, so text placed low on the surface stays readable.

When to use

  • Hero areas, onboarding and empty states that should feel alive without asking for attention.
  • One large surface per screen.

When not to use

  • Behind dense text or forms. Motion behind reading slows people down; use a still frame (reduceEffects) or no background.
  • Several instances at once. Each one is a full-surface shader.

Notes

  • Pass active={false} when the screen loses focus to stop all per-frame work.
  • frameRate={30} halves the GPU cost on weaker devices; the motion is slow enough that it barely shows.
  • Colours come from the theme by default; pass colors for brand ribbons.

Props

AuroraVeilProps

PropTypeDefaultDescription
childrenReactNode—Content drawn on top of the aurora.
colors[string, string, string]—Ribbon colours, back to front. Defaults to ice, textSubtle and ember from the theme.
baseColorstring—Ground colour behind the ribbons. Defaults to the theme background.
intensitynumber0.85Strength of the ribbons, 0–1. Defaults to 0.85.
speednumber1Animation speed multiplier. Defaults to 1.
frameRate60 | 3060Shader updates per second. 30 halves the GPU cost on weaker devices. Defaults to 60.
reduceEffectsbooleanfalseDraw a single still frame: no per-frame work. Reduced-motion users always get this.
activebooleantruePause the animation, e.g. while the screen is off-screen. Defaults to true.
styleStyleProp<ViewStyle>—

Performance and accessibility

Performance

Budget
One full-surface fragment shader (4-octave fbm, twice) per frame. frameRate={30} halves the work; reduceEffects draws a single frame.
Measured

Pixel 9 emulator, API 35, release build · interaction median 1.06× the system Settings app on the same emulator (indicative only) · keeps drawing at rest · 151 MB app memory. Animates by design while active; pass active={false} off-screen. Clock drops to 30 fps with reduceEffects.

Real-device measurement pending.

Accessibility

Reduced motion
A single still frame of the aurora; no per-frame work.
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/aurora-veil.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 { AURORA_SHADER } from './aurora-veil.shader';

export type AuroraVeilProps = {
  /** Content drawn on top of the aurora. */
  children?: ReactNode;
  /** Ribbon colours, back to front. Defaults to ice, textSubtle and ember from the theme. */
  colors?: [string, string, string];
  /** Ground colour behind the ribbons. Defaults to the theme background. */
  baseColor?: string;
  /** Strength of the ribbons, 0–1. Defaults to 0.85. */
  intensity?: number;
  /** Animation speed multiplier. Defaults to 1. */
  speed?: number;
  /** Shader updates per second. 30 halves the GPU cost on weaker devices. Defaults to 60. */
  frameRate?: 60 | 30;
  /** Draw a single still frame: no per-frame work. Reduced-motion users always get this. */
  reduceEffects?: boolean;
  /** Pause the animation, e.g. while the screen is off-screen. Defaults to true. */
  active?: boolean;
  style?: StyleProp<ViewStyle>;
};

/**
 * A slow, flowing aurora drawn by a fragment shader. Use it as a screen or
 * hero background; children render on top.
 */
export function AuroraVeil({
  children,
  colors,
  baseColor,
  intensity = 0.85,
  speed = 1,
  frameRate = 60,
  reduceEffects = false,
  active = true,
  style,
}: AuroraVeilProps) {
  const theme = useKinetikTheme();
  const reduced = useReduceMotion();
  const still = reduced || reduceEffects;
  const size = useSharedValue<SkSize>({ width: 0, height: 0 });
  // Still frames start a few seconds in, where the ribbons are well formed.
  const time = useShaderClock({ running: active && !still, speed, frameRate, start: 4 });

  const [a, b, c] = colors ?? [theme.colors.ice, theme.colors.textSubtle, theme.colors.ember];
  const base = colorUniform(baseColor ?? theme.colors.background);
  const c0 = colorUniform(a);
  const c1 = colorUniform(b);
  const c2 = colorUniform(c);
  const k = Math.max(0, Math.min(1, intensity));

  const uniforms = useDerivedValue(() => ({
    res: [size.get().width, size.get().height],
    time: time.get(),
    intensity: k,
    base,
    c0,
    c1,
    c2,
  }));

  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(AURORA_SHADER)} uniforms={uniforms} />
          </Fill>
        </Canvas>
      </View>
      {children}
    </View>
  );
}

const styles = StyleSheet.create({
  root: { overflow: 'hidden' },
});
components/kinetik/aurora-veil.shader.ts
import { NOISE } from '../../lib/kinetik/shaders/sksl';

/**
 * Three aurora ribbons that sway and shimmer over a base colour.
 * Uniforms: res (px), time (s), intensity (0–1), base + three ribbon colours.
 */
export const AURORA_SHADER = `
uniform float2 res;
uniform float time;
uniform float intensity;
uniform half4 base;
uniform half4 c0;
uniform half4 c1;
uniform half4 c2;
${NOISE}

float ribbon(float y, float center, float width) {
  float d = (y - center) / width;
  return exp(-d * d);
}

half4 main(float2 xy) {
  float2 uv = xy / res;
  float aspect = res.x / max(res.y, 1.0);
  float x = uv.x * aspect;
  float t = time * 0.12;

  // Slow domain warp gives the curtains their drift.
  float warp = fbm(float2(x * 1.3 + t, uv.y * 0.8 - t * 0.6));
  float sway = warp * 0.35;

  // Fine vertical rays, like light through the curtain.
  float rays = 0.55 + 0.45 * fbm(float2(x * 9.0 + warp * 2.0, t * 1.6));

  float r0 = ribbon(uv.y, 0.30 + 0.10 * sin(x * 2.2 + t * 2.6) + sway * 0.2, 0.10) * rays;
  float r1 = ribbon(uv.y, 0.46 + 0.08 * sin(x * 1.6 - t * 2.1 + 1.7) + sway * 0.25, 0.13) * rays;
  float r2 = ribbon(uv.y, 0.22 + 0.12 * sin(x * 2.9 + t * 1.4 + 3.1) - sway * 0.15, 0.07) * rays;

  half3 col = base.rgb;
  col = mix(col, c0.rgb, clamp(r0 * intensity, 0.0, 1.0));
  col = mix(col, c1.rgb, clamp(r1 * intensity * 0.85, 0.0, 1.0));
  col = mix(col, c2.rgb, clamp(r2 * intensity * 0.75, 0.0, 1.0));

  // Fade back into the base towards the bottom so content stays legible.
  col = mix(col, base.rgb, smoothstep(0.45, 1.0, uv.y) * 0.85);
  return half4(col, 1.0);
}
`;