skia shaders
Aurora Border
A low-frequency luminous border whose palette responds to semantic state.
iOSAndroidExpo Go
skiareanimated
Installation
Install the dependencies, then copy the files for your preferred renderer.
pnpm add @shopify/react-native-skia react-native-reanimatedInstall tailwind variants
pnpm add tailwind-variantsUsage
AuroraBorderExample.tsximport { Text, View } from "react-native";import { AuroraBorder } from "@animations/ui/components/skia-shaders/aurora-border/uniwind";export function AuroraBorderExample() { return ( <AuroraBorder classNames={{ content: "px-6" }} state="success" > <View className="p-6"> <Text className="text-xl font-extrabold">System online</Text> </View> </AuroraBorder> );}aurora-border/aurora-layer.tsximport { BlurMask, Canvas, RoundedRect, SweepGradient, useCanvasSize,} from "@shopify/react-native-skia";import { StyleSheet } from "react-native";import { AURORA_BORDER_CONFIG, AURORA_BORDER_PALETTES,} from "./config";import type { AuroraBorderBaseProps } from "./types";import { useAuroraBorder } from "./use-aurora";type AuroraLayerProps = Pick< AuroraBorderBaseProps, | "borderRadius" | "borderWidth" | "colors" | "duration" | "intensity" | "paused" | "state">;export function AuroraLayer({ borderRadius = AURORA_BORDER_CONFIG.borderRadius, borderWidth = AURORA_BORDER_CONFIG.borderWidth, colors, duration = AURORA_BORDER_CONFIG.duration, intensity = AURORA_BORDER_CONFIG.intensity, paused = false, state = "active",}: AuroraLayerProps) { const { ref, size: { height: canvasHeight, width: canvasWidth }, } = useCanvasSize(); const { center, glowOpacity, height, inset, radius, transform, width, } = useAuroraBorder({ borderRadius, borderWidth, duration, height: canvasHeight, intensity, paused, width: canvasWidth, }); const palette = colors ?? AURORA_BORDER_PALETTES[state]; return ( <Canvas pointerEvents="none" ref={ref} style={StyleSheet.absoluteFill} > <RoundedRect height={height} opacity={glowOpacity * 0.7} r={radius} strokeWidth={borderWidth * 2.5} style="stroke" width={width} x={inset} y={inset} > <SweepGradient c={center} colors={palette} origin={center} transform={transform} /> <BlurMask blur={AURORA_BORDER_CONFIG.glowBlur * intensity} style="solid" /> </RoundedRect> <RoundedRect height={height} r={radius} strokeWidth={borderWidth} style="stroke" width={width} x={inset} y={inset} > <SweepGradient c={center} colors={palette} origin={center} transform={transform} /> </RoundedRect> </Canvas> );}aurora-border/config.tsimport type { AuroraBorderState } from "./types";export const AURORA_BORDER_CONFIG = { borderRadius: 24, borderWidth: 2, duration: 9_000, glowBlur: 9, intensity: 1,} as const;export const AURORA_BORDER_PALETTES = { active: ["#7c3aed", "#22d3ee", "#f472b6", "#a78bfa", "#7c3aed"], idle: ["#5b6475", "#64748b", "#8b5cf6", "#475569", "#5b6475"], success: ["#059669", "#2dd4bf", "#a3e635", "#22d3ee", "#059669"], warning: ["#f59e0b", "#fb7185", "#f97316", "#fde047", "#f59e0b"],} satisfies Record<AuroraBorderState, string[]>;aurora-border/types.tsimport type { ReactNode } from "react";export type AuroraBorderState = | "active" | "idle" | "success" | "warning";export type AuroraBorderBaseProps = { accessibilityLabel?: string; borderRadius?: number; borderWidth?: number; children: ReactNode; colors?: string[]; duration?: number; intensity?: number; paused?: boolean; state?: AuroraBorderState;};aurora-border/uniwind/index.tsximport { View } from "react-native";import type { SlotsToClasses } from "#shared/types/slots";import { AuroraLayer } from "../aurora-layer";import type { AuroraBorderBaseProps } from "../types";import { auroraBorderVariants, type AuroraBorderSlot,} from "./variants";export type AuroraBorderProps = AuroraBorderBaseProps & { className?: string; classNames?: SlotsToClasses<AuroraBorderSlot>;};export function AuroraBorder({ accessibilityLabel, borderRadius, borderWidth, children, className, classNames, colors, duration, intensity, paused, state,}: AuroraBorderProps) { const { container, content } = auroraBorderVariants(); return ( <View accessibilityLabel={accessibilityLabel} accessible={accessibilityLabel !== undefined} className={container({ className: [className, classNames?.container], })} style={borderRadius !== undefined && { borderRadius }} > <View className={content({ className: classNames?.content, })} > {children} </View> <AuroraLayer borderRadius={borderRadius} borderWidth={borderWidth} colors={colors} duration={duration} intensity={intensity} paused={paused} state={state} /> </View> );}aurora-border/uniwind/variants.tsimport { tv } from "tailwind-variants";export const auroraBorderSlots = { container: "overflow-hidden rounded-3xl", content: "",} as const;export type AuroraBorderSlot = keyof typeof auroraBorderSlots;export const auroraBorderVariants = tv({ slots: auroraBorderSlots,});aurora-border/use-aurora.tsimport { useClock } from "@shopify/react-native-skia";import { useDerivedValue, useReducedMotion,} from "react-native-reanimated";type UseAuroraBorderOptions = { borderRadius: number; borderWidth: number; duration: number; height: number; intensity: number; paused: boolean; width: number;};export function useAuroraBorder({ borderRadius, borderWidth, duration, height: canvasHeight, intensity, paused, width: canvasWidth,}: UseAuroraBorderOptions) { const clock = useClock(); const reducedMotion = useReducedMotion(); const inset = borderWidth / 2; const width = Math.max(0, canvasWidth - inset * 2); const height = Math.max(0, canvasHeight - inset * 2); const center = { x: canvasWidth / 2, y: canvasHeight / 2, }; const maximumRadius = Math.min(width / 2, height / 2); const radius = Math.max( 0, Math.min(borderRadius - inset, maximumRadius), ); const rotation = useDerivedValue(() => { if (paused || reducedMotion) { return 0; } return ((clock.get() % duration) / duration) * Math.PI * 2; }); const transform = useDerivedValue(() => [ { rotate: rotation.get() }, ]); const glowOpacity = Math.min(1, Math.max(0, intensity)); return { center, glowOpacity, height, inset, radius, transform, width, };}