Aurora Veil
A slow, flowing aurora drawn by a fragment shader, for screen and hero backgrounds.
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' },
});Installation
npx kinetik-ui add aurora-veilpnpm dlx kinetik-ui add aurora-veilbunx kinetik-ui add aurora-veilyarn dlx kinetik-ui add aurora-veilThe 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
- Install dependencies
npx expo install @shopify/react-native-skia react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/aurora-veil.tsxcomponents/kinetik/aurora-veil.shader.ts
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
colorsfor brand ribbons.
Props
AuroraVeilProps
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | 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. |
baseColor | string | — | Ground colour behind the ribbons. Defaults to the theme background. |
intensity | number | 0.85 | Strength of the ribbons, 0–1. Defaults to 0.85. |
speed | number | 1 | Animation speed multiplier. Defaults to 1. |
frameRate | 60 | 30 | 60 | Shader updates per second. 30 halves the GPU cost on weaker devices. Defaults to 60. |
reduceEffects | boolean | false | Draw a single still frame: no per-frame work. Reduced-motion users always get this. |
active | boolean | true | Pause the animation, e.g. while the screen is off-screen. Defaults to true. |
style | StyleProp<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);
}
`;