skia shaders
Signal Orb
A restrained AI presence orb that morphs continuously across assistant states and responds to live audio levels.
iOSAndroidExpo Go
skiareanimated
Installation
Install the dependencies, then copy the files for your preferred renderer.
pnpm add @shopify/react-native-skia react-native-reanimatedBehavior
stateselects the motion pattern andcolorsets its base color.levelsdrives thelisteningandspeakingstates.- Reduced Motion preserves state changes without continuous movement.
Install tailwind variants
pnpm add tailwind-variantsUsage
AssistantPresence.tsximport { SignalOrb } from "@animations/ui/components/skia-shaders/signal-orb/uniwind";export function AssistantPresence() { return ( <SignalOrb className="self-center" size={180} state="thinking" /> );}signal-orb/config.tsimport { SIGNAL_ORB_STATES, type SignalOrbState,} from "./types";export const SIGNAL_ORB_CONFIG = { color: "#45cdb8", levelDuration: 220, sampleCount: 16, size: 160, transitionDuration: 900,} as const;export const SIGNAL_ORB_STATE_INDEX = Object.fromEntries( SIGNAL_ORB_STATES.map((state, index) => [state, index]),) as Record<SignalOrbState, number>;signal-orb/signal-orb-layer.tsximport { Canvas, Fill, Shader, Skia, useCanvasSize, useClock,} from "@shopify/react-native-skia";import { StyleSheet } from "react-native";import { useDerivedValue, useReducedMotion,} from "react-native-reanimated";import { SIGNAL_ORB_CONFIG, SIGNAL_ORB_STATE_INDEX,} from "./config";import type { SignalOrbBaseProps } from "./types";import { useSignalOrb } from "./use-signal-orb";const signalOrbEffect = (() => { const effect = Skia.RuntimeEffect.Make(` uniform float2 resolution; uniform float time; uniform float weights[6]; uniform float energy; uniform float peak; uniform float variation; uniform float motion; uniform float4 tint; float ring(float distanceToRing, float width, float antialias) { return 1.0 - smoothstep( width - antialias, width + antialias, abs(distanceToRing) ); } float stateWave( float angle, float frequency, float offset, float phase, float rotationWeight ) { float stationary = sin(angle * frequency + offset); float rotating = sin(angle * frequency + offset + phase); return mix(stationary, rotating, rotationWeight); } half4 main(float2 position) { float minimumSize = max(1.0, min(resolution.x, resolution.y)); float2 point = (position - resolution * 0.5) / minimumSize; float angle = atan(point.y, point.x); float idle = weights[${SIGNAL_ORB_STATE_INDEX.idle}]; float connecting = weights[${SIGNAL_ORB_STATE_INDEX.connecting}]; float listening = weights[${SIGNAL_ORB_STATE_INDEX.listening}]; float thinking = weights[${SIGNAL_ORB_STATE_INDEX.thinking}]; float speaking = weights[${SIGNAL_ORB_STATE_INDEX.speaking}]; float error = weights[${SIGNAL_ORB_STATE_INDEX.error}]; float phase = time * 2.4 * motion; float rotationWeight = clamp( idle + connecting + thinking + error, 0.0, 1.0 ); float audioWeight = listening + speaking; float audioResponse = energy * audioWeight; float connectingPulse = connecting * (0.5 + 0.5 * sin(phase * 2.4)); float listeningBreath = listening * energy * (0.5 + 0.5 * sin(phase * 1.2)); float speakingEnergy = speaking * (0.35 + energy * 0.65); float speakingPulseA = speakingEnergy * (0.5 + 0.5 * sin(phase * 1.6)); float speakingPulseB = speakingEnergy * (0.5 + 0.5 * sin(phase * 1.6 + 1.9)); float speakingPulseC = speakingEnergy * (0.5 + 0.5 * sin(phase * 1.6 + 3.8)); float listeningFold = listening * energy * (0.060 + 0.025 * (0.5 + 0.5 * sin(phase * 1.2))) * (0.5 + 0.5 * cos(angle * 3.0 + 0.4)); float stateMotion = idle * 0.005 + connecting * 0.014 + listening * 0.012 + thinking * 0.018 + speaking * 0.014 + error * 0.016; float audioDeformation = energy * (listening * 0.035 + speaking * 0.050) + variation * audioWeight * 0.025; float deformation = stateMotion + audioDeformation; float drift = idle * 0.003 + connecting * 0.012 + listening * 0.008 + thinking * 0.016 + speaking * 0.012 + error * 0.012 + audioResponse * 0.006; float ringSpread = idle * 0.014 + connecting * (0.018 + connectingPulse * 0.008) + listening * 0.016 + thinking * 0.023 + speaking * 0.019 + error * 0.018; float baseRadius = 0.385 + connectingPulse * 0.008 + listeningBreath * 0.012 + audioResponse * (listening * 0.018 + speaking * 0.018); float rotatingDrift = drift * rotationWeight; float2 pointA = point - float2(cos(phase * 0.82), sin(phase * 0.68)) * rotatingDrift; float2 pointB = point - float2(cos(phase * 0.61 + 2.1), sin(phase * 0.91 + 1.4)) * rotatingDrift; float2 pointC = point - float2(cos(phase * 0.74 + 4.0), sin(phase * 0.57 + 3.2)) * rotatingDrift; float thinkingRipple = thinking * ( sin(angle * 5.0 - phase * 1.4) * 0.018 + sin(angle * 3.0 + phase * 1.9) * 0.010 ); float errorTension = error * sin(angle * 6.0 + phase * 1.8) * 0.008; float radiusA = baseRadius - ringSpread + stateWave(angle, 2.0, 0.0, phase * 1.15, rotationWeight) * deformation + stateWave(angle, 3.0, 0.0, -phase * 0.72, rotationWeight) * deformation * 0.38 + thinkingRipple + errorTension + listeningBreath * 0.006 + speakingPulseA * 0.012 - listeningFold; float radiusB = baseRadius + stateWave(angle, 2.0, 2.0, -phase * 0.94, rotationWeight) * deformation * 0.86 + stateWave(angle, 4.0, 0.0, phase * 0.58, rotationWeight) * deformation * 0.30 - thinkingRipple * 0.65 + listeningBreath * 0.006 + speakingPulseB * 0.012 - listeningFold; float radiusC = baseRadius + ringSpread + stateWave(angle, 3.0, 4.1, phase * 0.76, rotationWeight) * deformation * 0.72 + stateWave(angle, 2.0, 0.0, -phase * 0.49, rotationWeight) * deformation * 0.34 - errorTension * 0.7 + listeningBreath * 0.006 + speakingPulseC * 0.012 - listeningFold; float distanceA = length(pointA) - radiusA; float distanceB = length(pointB) - radiusB; float distanceC = length(pointC) - radiusC; float antialias = 1.5 / minimumSize; float strokeWidth = 0.011 + connectingPulse * 0.003 + audioResponse * 0.006 + speakingEnergy * 0.006; float ringA = ring(distanceA, strokeWidth, antialias); float ringB = ring(distanceB, strokeWidth, antialias); float ringC = ring(distanceC, strokeWidth, antialias); float3 colorA = mix( tint.rgb * 0.88, float3(0.88, 0.34, 0.30), error ); float3 colorB = mix( min(tint.rgb * 1.08 + float3(0.04), float3(1.0)), float3(0.96, 0.48, 0.38), error ); float3 colorC = mix(colorA, colorB, 0.52); float coverage = ringA + ringB + ringC; float3 color = ( colorA * ringA + colorB * ringB + colorC * ringC ) / max(coverage, 0.001); float halo = ( exp(-abs(distanceA) * 58.0) + exp(-abs(distanceB) * 58.0) + exp(-abs(distanceC) * 58.0) ) * (0.025 + peak * audioWeight * 0.018); float alpha = clamp(coverage * 0.82 + halo, 0.0, 1.0); return half4( color.r * alpha, color.g * alpha, color.b * alpha, alpha ); } `); if (!effect) { throw new Error("Signal Orb shader failed to compile."); } return effect;})();type SignalOrbLayerProps = Pick< SignalOrbBaseProps, "color" | "levels" | "state">;export function SignalOrbLayer({ color, levels, state,}: SignalOrbLayerProps) { const { ref, size: { height, width }, } = useCanvasSize(); const clock = useClock(); const reducedMotion = useReducedMotion(); const tint = Skia.Color( color ?? SIGNAL_ORB_CONFIG.color, ); const { energy, peak, stateWeights, variation } = useSignalOrb({ levels, state }); const uniforms = useDerivedValue(() => ({ energy: energy.get(), motion: reducedMotion ? 0 : 1, peak: peak.get(), resolution: [width, height], time: reducedMotion ? 0 : clock.get() / 1_000, tint, variation: variation.get(), weights: stateWeights.get(), })); return ( <Canvas pointerEvents="none" ref={ref} style={StyleSheet.absoluteFill} > <Fill> <Shader source={signalOrbEffect} uniforms={uniforms} /> </Fill> </Canvas> );}signal-orb/types.tsexport const SIGNAL_ORB_STATES = [ "idle", "connecting", "listening", "thinking", "speaking", "error",] as const;export type SignalOrbState = (typeof SIGNAL_ORB_STATES)[number];export type SignalOrbBaseProps = { /** Accessible description of the assistant and its current state. */ accessibilityLabel?: string; /** Base color used by every state except the error tint. */ color?: string; /** Recent normalized audio samples used by listening and speaking states. */ levels?: readonly number[]; /** Height and width of the square drawing surface. */ size?: number; /** Current assistant state. Changes morph the existing shader continuously. */ state: SignalOrbState;};signal-orb/uniwind/index.tsximport { View } from "react-native";import { SIGNAL_ORB_CONFIG } from "../config";import { SignalOrbLayer } from "../signal-orb-layer";import type { SignalOrbBaseProps } from "../types";import { signalOrbVariants } from "./variants";export type SignalOrbProps = SignalOrbBaseProps & { className?: string;};/** Minimal shader orb for continuous AI assistant states. */export function SignalOrb({ accessibilityLabel, className, color, levels, size = SIGNAL_ORB_CONFIG.size, state,}: SignalOrbProps) { return ( <View accessibilityLabel={ accessibilityLabel ?? `AI assistant ${state}` } accessibilityRole="image" accessible className={signalOrbVariants({ className })} style={{ height: size, width: size }} > <SignalOrbLayer color={color} levels={levels} state={state} /> </View> );}signal-orb/uniwind/variants.tsimport { tv } from "tailwind-variants";export const signalOrbVariants = tv({ base: "relative items-center justify-center",});signal-orb/use-signal-orb.tsimport { useEffect } from "react";import { Easing, useSharedValue, withTiming,} from "react-native-reanimated";import { SIGNAL_ORB_CONFIG } from "./config";import { SIGNAL_ORB_STATES, type SignalOrbState,} from "./types";function getStateWeights(state: SignalOrbState) { return SIGNAL_ORB_STATES.map((item) => item === state ? 1 : 0, );}function getLevelMetrics(levels: readonly number[] | undefined) { const samples = levels ?.slice(-SIGNAL_ORB_CONFIG.sampleCount) .map((level) => Math.min(1, Math.max(0, level))) ?? []; if (samples.length === 0) { return { energy: 0, peak: 0, variation: 0 }; } let squaredTotal = 0; let peak = 0; let variationTotal = 0; for (let index = 0; index < samples.length; index += 1) { const sample = samples[index]; squaredTotal += sample * sample; peak = Math.max(peak, sample); if (index > 0) { variationTotal += Math.abs(sample - samples[index - 1]); } } return { energy: Math.sqrt(squaredTotal / samples.length), peak, variation: samples.length > 1 ? variationTotal / (samples.length - 1) : 0, };}export function useSignalOrb({ levels, state,}: { levels?: readonly number[]; state: SignalOrbState;}) { const metrics = getLevelMetrics(levels); const energy = useSharedValue(metrics.energy); const peak = useSharedValue(metrics.peak); const stateWeights = useSharedValue( getStateWeights(state), ); const variation = useSharedValue(metrics.variation); useEffect(() => { stateWeights.set( withTiming(getStateWeights(state), { duration: SIGNAL_ORB_CONFIG.transitionDuration, easing: Easing.inOut(Easing.cubic), }), ); }, [state, stateWeights]); useEffect(() => { const timing = { duration: SIGNAL_ORB_CONFIG.levelDuration, easing: Easing.out(Easing.quad), }; energy.set(withTiming(metrics.energy, timing)); peak.set(withTiming(metrics.peak, timing)); variation.set(withTiming(metrics.variation, timing)); }, [energy, metrics.energy, metrics.peak, metrics.variation, peak, variation]); return { energy, peak, stateWeights, variation, };}SignalOrb
Minimal shader orb for continuous AI assistant states.
