Loader
Animated loading indicator with circle and dots variants.
Circle
Dots
Web preview — components render natively on iOS & Android
import { Loader } from "@/components/ui/loader";
<Loader variant="dots" size="md" />Installation#
npx @aniui/cli add loaderUsage#
import { Loader } from "@/components/ui/loader";
<Loader variant="dots" size="md" />Source#
components/ui/loader.tsx
import React, { useEffect } from "react";
import { View } from "react-native";
import { cva, type VariantProps } from "class-variance-authority";
import Svg, { Circle } from "react-native-svg";
import Animated, {
cancelAnimation,
useAnimatedStyle,
useSharedValue,
withDelay,
withRepeat,
withTiming,
Easing,
} from "react-native-reanimated";
import { useReducedMotion } from "@/components/ui/animate";
import { cn } from "@/lib/utils";
import { useThemeColors } from "@/components/ui/theme-provider";
const loaderVariants = cva("items-center justify-center", {
variants: {
variant: {
circle: "",
dots: "flex-row gap-1.5",
},
size: { sm: "", md: "", lg: "" },
},
defaultVariants: { variant: "circle", size: "md" },
});
const sizes = { sm: 20, md: 32, lg: 44 } as const;
const strokeWidths = { sm: 2, md: 2.5, lg: 3 } as const;
// Segmented "comet tail" ring — strokeDasharray/strokeDashoffset per segment
// plus exponential opacity falloff, rather than a plain spinning border.
const SEGMENTS = 16;
const ACTIVE_PCT = 0.75;
const GRADIENT_PCT = 0.25;
export interface LoaderProps
extends React.ComponentPropsWithoutRef<typeof View>,
VariantProps<typeof loaderVariants> {
className?: string;
}
export function Loader({ variant = "circle", size = "md", className, ...props }: LoaderProps) {
const reducedMotion = useReducedMotion();
const spin = useSharedValue(0);
const s = sizes[size ?? "md"];
const colors = useThemeColors();
const color = colors.foreground;
useEffect(() => {
if (reducedMotion) return;
spin.value = withRepeat(withTiming(1, { duration: 900, easing: Easing.linear }), -1, false);
return () => cancelAnimation(spin);
}, [spin, reducedMotion]);
const spinStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${spin.value * 360}deg` }],
}));
if (variant === "dots") {
return (
<View className={cn(loaderVariants({ variant, size }), className)} accessibilityRole="progressbar" {...props}>
{[0, 1, 2].map((i) => (
<Dot key={i} index={i} size={s / 4} reducedMotion={reducedMotion} />
))}
</View>
);
}
const strokeWidth = strokeWidths[size ?? "md"];
const radius = (s - strokeWidth) / 2;
const cx = s / 2;
const cy = s / 2;
const circumference = 2 * Math.PI * radius;
const solidPct = ACTIVE_PCT - GRADIENT_PCT;
const segmentLength = (circumference * GRADIENT_PCT) / SEGMENTS;
const activeLength = circumference * solidPct;
return (
<View className={cn(loaderVariants({ variant, size }), className)} accessibilityRole="progressbar" {...props}>
<Animated.View style={[{ width: s, height: s }, !reducedMotion && spinStyle]}>
<Svg width={s} height={s} viewBox={`0 0 ${s} ${s}`}>
<Circle
cx={cx}
cy={cy}
r={radius}
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
fill="none"
strokeDasharray={`${activeLength} ${circumference}`}
opacity={reducedMotion ? 0.6 : 1}
/>
{Array.from({ length: SEGMENTS }).map((_, i) => {
const progress = i / SEGMENTS;
const opacity = 1 - Math.pow(progress, 1.5);
if (opacity < 0.03) return null;
return (
<Circle
key={i}
cx={cx}
cy={cy}
r={radius}
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="butt"
fill="none"
opacity={reducedMotion ? 0 : opacity}
strokeDasharray={`${segmentLength} ${circumference}`}
strokeDashoffset={-(activeLength + segmentLength * i)}
/>
);
})}
</Svg>
</Animated.View>
</View>
);
}
function Dot({ index, size, reducedMotion }: { index: number; size: number; reducedMotion: boolean }) {
// Vertical bounce (translateY), staggered per dot — a cy jump of
// dotRadius * 0.85, staggered by duration / 3 across the dots.
const progress = useSharedValue(0);
const jump = size * 0.85;
useEffect(() => {
if (reducedMotion) return;
progress.value = withDelay(
index * 167,
withRepeat(withTiming(1, { duration: 500, easing: Easing.inOut(Easing.ease) }), -1, true)
);
return () => cancelAnimation(progress);
}, [progress, index, reducedMotion]);
const style = useAnimatedStyle(() => ({
transform: [{ translateY: -progress.value * jump }],
}));
return (
<Animated.View
className="rounded-full bg-primary"
style={[{ width: size, height: size, opacity: reducedMotion ? 0.6 : 1 }, !reducedMotion && style]}
/>
);
}

