loading
Matrix
A compact 5×5 LED matrix that gives asynchronous and AI states their own visible motion language.
iOSAndroidExpo Go
skiareanimated
Installation
Install the dependencies, then copy the files for your preferred renderer.
pnpm add @shopify/react-native-skia react-native-reanimatedBehavior
Use state to select the status pattern, size for its footprint, and colors
to override visual roles. Pass paused to stop motion while preserving the
current state.
Install tailwind variants
pnpm add tailwind-variantsUsage
SyncStatus.tsximport { Matrix } from "@animations/ui/components/loading/status-matrix/uniwind";const statusColors = { error: "#b42318", neutral: "#344054", success: "#067647",};export function SyncStatus() { return ( <Matrix className="self-start" colors={statusColors} size={40} state="syncing" /> );}status-matrix/config.tsimport { STATUS_MATRIX_STATES, type MatrixColors, type MatrixState,} from "./types";export const STATUS_MATRIX_CONFIG = { size: 36, transitionDuration: 420,} as const;export const STATUS_MATRIX_COLORS = { error: "#ff2e3a", info: "#2e80ff", neutral: "#e8ebed", offline: "#737880", recording: "#ff3340", success: "#14e088", warning: "#ff9e0a",} satisfies MatrixColors;export const STATUS_MATRIX_STATE_INDEX = Object.fromEntries( STATUS_MATRIX_STATES.map((state, index) => [state, index]),) as Record<MatrixState, number>;status-matrix/matrix-artwork.tsximport { Canvas, Fill, Shader, Skia, useCanvasSize, useClock,} from "@shopify/react-native-skia";import { StyleSheet } from "react-native";import { useDerivedValue, useReducedMotion, type SharedValue,} from "react-native-reanimated";import { STATUS_MATRIX_COLORS, STATUS_MATRIX_STATE_INDEX,} from "./config";import type { MatrixColors } from "./types";const matrixEffect = Skia.RuntimeEffect.Make(` uniform float2 resolution; uniform float time; uniform float currentState; uniform float previousState; uniform float transition; uniform float4 neutralColor; uniform float4 recordingColor; uniform float4 successColor; uniform float4 errorColor; uniform float4 warningColor; uniform float4 infoColor; uniform float4 offlineColor; const float TAU = 6.28318530718; float clamp01(float value) { return clamp(value, 0.0, 1.0); } float point(float2 cell, float x, float y) { return (1.0 - step(0.1, abs(cell.x - x))) * (1.0 - step(0.1, abs(cell.y - y))); } float within(float value, float minimum, float maximum) { return step(minimum - 0.1, value) * step(value, maximum + 0.1); } float noiseHash(float2 point) { return fract(sin(dot(point, float2(127.1, 311.7))) * 43758.5453123); } float softNoise(float2 cell, float phase) { float base = noiseHash(cell + floor(phase) * 17.0); float next = noiseHash(cell + (floor(phase) + 1.0) * 17.0); float mixAmount = smoothstep(0.0, 1.0, fract(phase)); return mix(base, next, mixAmount); } float cycleWave(float elapsed, float duration, float phase) { return 0.5 + 0.5 * sin(elapsed * TAU / duration + phase); } float streamColumnPhase(float x) { if (x < -1.5) { return 1.44; } if (x < -0.5) { return 1.57; } if (x < 0.5) { return 0.92; } if (x < 1.5) { return 1.93; } return 0.22; } float recordingCross(float2 cell) { float vertical = point(cell, 0.0, -1.0) + point(cell, 0.0, 0.0) + point(cell, 0.0, 1.0); float horizontal = point(cell, -1.0, 0.0) + point(cell, 1.0, 0.0); return clamp01(vertical + horizontal); } float check(float2 cell) { return clamp01( point(cell, -2.0, 1.0) + point(cell, -1.0, 2.0) + point(cell, 0.0, 1.0) + point(cell, 1.0, 0.0) + point(cell, 2.0, -1.0) ); } float errorX(float2 cell) { return clamp01( point(cell, -2.0, -2.0) + point(cell, 2.0, -2.0) + point(cell, -1.0, -1.0) + point(cell, 1.0, -1.0) + point(cell, 0.0, 0.0) + point(cell, -1.0, 1.0) + point(cell, 1.0, 1.0) + point(cell, -2.0, 2.0) + point(cell, 2.0, 2.0) ); } float warning(float2 cell) { return clamp01( point(cell, 0.0, -2.0) + point(cell, 0.0, -1.0) + point(cell, 0.0, 0.0) + point(cell, 0.0, 2.0) ); } float info(float2 cell) { return clamp01( point(cell, 0.0, -2.0) + point(cell, 0.0, 0.0) + point(cell, 0.0, 1.0) + point(cell, 0.0, 2.0) ); } float pauseBars(float2 cell) { return within(cell.y, -1.0, 1.0) * (point(cell, -1.0, cell.y) + point(cell, 1.0, cell.y)); } float stopBlock(float2 cell) { return within(cell.x, -1.0, 1.0) * within(cell.y, -1.0, 1.0); } float4 stateSignal(float state, float2 cell, float elapsed) { float3 color = neutralColor.rgb; float level = 0.15; if (state == ${STATUS_MATRIX_STATE_INDEX.idle}) { level = 0.145; } else if (state == ${STATUS_MATRIX_STATE_INDEX.loading}) { float nearGrain = softNoise(cell, elapsed * 4.1); float farGrain = softNoise( cell + float2(8.7, 3.1), elapsed * 1.7 + 0.4 ); level = 0.19 + mix(nearGrain, farGrain, 0.32) * 0.62; } else if (state == ${STATUS_MATRIX_STATE_INDEX.thinking}) { float diagonal = cycleWave( elapsed, 1.2, (cell.x + cell.y) * 0.45 - 1.43 ); level = 0.18 + diagonal * 0.67; } else if (state == ${STATUS_MATRIX_STATE_INDEX.streaming}) { float stream = cycleWave( elapsed, 0.9, cell.y * 0.80 + streamColumnPhase(cell.x) ); level = 0.18 + stream * 0.70; } else if (state == ${STATUS_MATRIX_STATE_INDEX.searching}) { float scan = cycleWave(elapsed, 1.1, cell.x * 0.62 - 0.17); level = 0.16 + scan * 0.70; } else if (state == ${STATUS_MATRIX_STATE_INDEX.syncing}) { float syncPhase = (cell.x + cell.y) * 0.39 - 1.04 + 0.74 * exp(-pow(length(cell) * 0.8, 2.0)); float ripple = cycleWave(elapsed, 1.3, syncPhase); level = 0.18 + ripple * 0.66; } else if (state == ${STATUS_MATRIX_STATE_INDEX.connecting}) { float radialPhase = -0.96 - 1.52 * exp(-pow(length(cell) * 0.85, 2.0)); float connectPulse = cycleWave(elapsed, 1.4, radialPhase); level = 0.17 + connectPulse * 0.75; } else if (state == ${STATUS_MATRIX_STATE_INDEX.waiting}) { float waitingPoint = -2.25 + fract(elapsed / 1.2) * 4.5; float ping = exp(-pow(abs(cell.x - waitingPoint) * 1.18, 2.0)) * exp(-pow(abs(cell.y) * 2.8, 2.0)); level = 0.14 + ping * 0.66; } else if (state == ${STATUS_MATRIX_STATE_INDEX.uploading}) { float fill = cycleWave(elapsed, 1.0, -cell.y * 0.72 - 3.93); level = 0.25 + fill * 0.63; } else if (state == ${STATUS_MATRIX_STATE_INDEX.downloading}) { float fill = cycleWave(elapsed, 1.0, cell.y * 0.72 + 2.26); level = 0.25 + fill * 0.63; } else if (state == ${STATUS_MATRIX_STATE_INDEX.listening}) { float texture = 0.5 + 0.5 * cos( cell.y * 0.90 + elapsed * TAU ); float flutter = softNoise(cell, elapsed * 3.5); level = 0.20 + texture * 0.56 + flutter * 0.10; } else if (state == ${STATUS_MATRIX_STATE_INDEX.speaking}) { float syllable = 0.5 + 0.5 * cos( cell.y * 0.95 + 2.0 - elapsed * TAU / 0.575 ); float accent = softNoise(cell, elapsed * 5.5); level = 0.17 + syllable * 0.60 + accent * 0.12; } else if (state == ${STATUS_MATRIX_STATE_INDEX.recording}) { float recordPulse = cycleWave(elapsed, 1.4, -2.58); color = recordingColor.rgb; level = 0.11 + recordingCross(cell) * (0.14 + recordPulse * 0.50); } else if (state == ${STATUS_MATRIX_STATE_INDEX.success}) { color = successColor.rgb; level = 0.13 + check(cell) * 0.87; } else if (state == ${STATUS_MATRIX_STATE_INDEX.error}) { float errorPulse = cycleWave(elapsed, 1.1, -1.78); color = errorColor.rgb; level = 0.11 + errorX(cell) * (0.11 + errorPulse * 0.67); } else if (state == ${STATUS_MATRIX_STATE_INDEX.warning}) { float warningPulse = cycleWave(elapsed, 1.6, -0.73); color = warningColor.rgb; level = 0.12 + warning(cell) * warningPulse * 0.84; } else if (state == ${STATUS_MATRIX_STATE_INDEX.info}) { color = infoColor.rgb; level = 0.12 + info(cell) * 0.80; } else if (state == ${STATUS_MATRIX_STATE_INDEX.paused}) { level = 0.13 + pauseBars(cell) * 0.75; } else if (state == ${STATUS_MATRIX_STATE_INDEX.stopped}) { level = 0.12 + stopBlock(cell) * 0.77; } else { color = offlineColor.rgb; level = 0.08; } return float4(color, level); } half4 main(float2 position) { float minimumSize = max(1.0, min(resolution.x, resolution.y)); float spacing = minimumSize * 0.175; float radius = minimumSize * 0.061; float antialias = max(0.55, minimumSize * 0.015); float2 center = resolution * 0.5; float2 normalizedCell = (position - center) / spacing; float2 cell = float2( floor(normalizedCell.x + 0.5), floor(normalizedCell.y + 0.5) ); if (abs(cell.x) > 2.0 || abs(cell.y) > 2.0) { return half4(0.0, 0.0, 0.0, 0.0); } float2 dotCenter = center + cell * spacing; float dotCoverage = 1.0 - smoothstep( radius - antialias, radius + antialias, length(position - dotCenter) ); float easedTransition = smoothstep(0.0, 1.0, transition); float4 prior = stateSignal(previousState, cell, time); float4 current = stateSignal(currentState, cell, time); float4 signalValue = mix(prior, current, easedTransition); float alpha = dotCoverage * signalValue.a; return half4(signalValue.rgb * alpha, alpha); }`);if (!matrixEffect) { throw new Error("Matrix shader failed to compile.");}const matrixShader = matrixEffect;export function MatrixArtwork({ colors: colorOverrides, currentState, paused = false, previousState, transition,}: { colors?: Partial<MatrixColors>; currentState: SharedValue<number>; paused?: boolean; previousState: SharedValue<number>; transition: SharedValue<number>;}) { const { ref, size: { height, width }, } = useCanvasSize(); const clock = useClock(); const reducedMotion = useReducedMotion(); const colors = { ...STATUS_MATRIX_COLORS, ...colorOverrides, }; const neutralColor = Skia.Color(colors.neutral); const recordingColor = Skia.Color(colors.recording); const successColor = Skia.Color(colors.success); const errorColor = Skia.Color(colors.error); const warningColor = Skia.Color(colors.warning); const infoColor = Skia.Color(colors.info); const offlineColor = Skia.Color(colors.offline); const uniforms = useDerivedValue(() => ({ currentState: currentState.get(), errorColor, infoColor, neutralColor, offlineColor, previousState: previousState.get(), recordingColor, resolution: [width, height], successColor, time: paused || reducedMotion ? 0 : clock.get() / 1_000, transition: transition.get(), warningColor, })); return ( <Canvas pointerEvents="none" ref={ref} style={StyleSheet.absoluteFill} > <Fill> <Shader source={matrixShader} uniforms={uniforms} /> </Fill> </Canvas> );}status-matrix/types.tsexport const STATUS_MATRIX_STATES = [ "idle", "loading", "thinking", "streaming", "searching", "syncing", "connecting", "waiting", "uploading", "downloading", "listening", "speaking", "recording", "success", "error", "warning", "info", "paused", "stopped", "offline",] as const;export type MatrixState = (typeof STATUS_MATRIX_STATES)[number];export type MatrixColors = { /** Color shared by neutral activity and static states. */ neutral: string; /** Color used for the recording cross. */ recording: string; /** Color used for the success check. */ success: string; /** Color used for the error X. */ error: string; /** Color used for the warning exclamation. */ warning: string; /** Color used for the info glyph. */ info: string; /** Color used while offline. */ offline: string;};export type MatrixBaseProps = { /** Accessible description announced with the current state. */ accessibilityLabel?: string; /** Color tokens resolved by the consuming app's theme. */ colors?: Partial<MatrixColors>; /** Stops the status motion while retaining the selected state. */ paused?: boolean; /** Height and width of the square matrix surface. */ size?: number; /** Semantic status represented by the matrix. */ state: MatrixState;};status-matrix/uniwind/index.tsximport { View } from "react-native";import { STATUS_MATRIX_CONFIG } from "../config";import { MatrixArtwork } from "../matrix-artwork";import type { MatrixBaseProps } from "../types";import { useMatrix } from "../use-status-matrix";import { cn } from "#shared/utils/cn";export type MatrixProps = MatrixBaseProps & { className?: string;};/** A compact 5×5 LED matrix for semantic async and AI states. */export function Matrix({ accessibilityLabel, className, colors, paused, size = STATUS_MATRIX_CONFIG.size, state,}: MatrixProps) { const { currentState, previousState, transition } = useMatrix({ paused, state, }); return ( <View accessibilityLabel={accessibilityLabel ?? `${state} status`} accessibilityRole="image" accessible className={cn( "relative items-center justify-center", className, )} style={{ height: size, width: size }} > <MatrixArtwork colors={colors} currentState={currentState} paused={paused} previousState={previousState} transition={transition} /> </View> );}status-matrix/use-status-matrix.tsimport { useEffect } from "react";import { cancelAnimation, Easing, useReducedMotion, useSharedValue, withTiming,} from "react-native-reanimated";import { STATUS_MATRIX_CONFIG, STATUS_MATRIX_STATE_INDEX,} from "./config";import type { MatrixState } from "./types";export function useMatrix({ paused = false, state,}: { paused?: boolean; state: MatrixState;}) { const reducedMotion = useReducedMotion(); const currentState = useSharedValue( STATUS_MATRIX_STATE_INDEX[state], ); const previousState = useSharedValue( STATUS_MATRIX_STATE_INDEX[state], ); const transition = useSharedValue(1); useEffect(() => { const nextState = STATUS_MATRIX_STATE_INDEX[state]; if (currentState.get() === nextState) { return; } cancelAnimation(transition); previousState.set(currentState.get()); currentState.set(nextState); if (paused || reducedMotion) { transition.set(1); return; } transition.set(0); transition.set( withTiming(1, { duration: STATUS_MATRIX_CONFIG.transitionDuration, easing: Easing.linear, }), ); }, [currentState, paused, reducedMotion, state, transition, previousState]); useEffect( () => () => { cancelAnimation(transition); }, [transition], ); return { currentState, previousState, transition, };}Matrix
A compact 5×5 LED matrix for semantic async and AI states.
