Spinner, dots, bars, pulse, ring and metaball loaders that respect reduced motion.
components/ui/loader/loader.tsx@lumioui/core@latestreact-native-reanimated@*Live preview — the real component, driven by the props below.
import { Loader } from "@/components/ui/loader/loader";
export function Example() {
return (
<Loader />
);
} import * as React from "react";
import { forwardRef } from "react";
import { View, type ViewProps } from "react-native";
import Animated, {
useSharedValue,
useAnimatedStyle,
withDelay,
withRepeat,
withTiming,
Easing,
interpolate,
} from "react-native-reanimated";
import { cn, ease, timing, useReducedMotion, useTheme } from "@lumioui/core";
/**
* Loader — animated loading indicator with multiple variants.
* <Loader variant="spinner" size={32} />
* <Loader variant="dots" size={32} label="Loading data" />
* <Loader variant="bars" size={32} />
* <Loader variant="metaballs" size={48} />
* Reduced motion keeps a calm opacity pulse and drops every transform.
*/
/**
* Loop cycle configs — indefinite loader loops are scaled by the `speed`
* prop, so the static timing.* tokens can't apply. Each variant's cycle
* length is part of its choreography (sanctioned per AGENTS.md motion rules).
*/
const cycle = (ms: number) => ({ duration: ms, easing: Easing.bezier(...ease.inOut) });
// Rotors spin at constant speed — no ease token for linear motion.
const spinCycle = (ms: number) => ({ duration: ms, easing: Easing.linear });
export type LoaderVariant = "spinner" | "dots" | "bars" | "pulse" | "metaballs" | "ring";
export interface LoaderProps extends ViewProps {
/** Which animation to render. Default "spinner". */
variant?: LoaderVariant;
/** Base square size in px. Everything scales from this. Default 32. */
size?: number;
/** Seconds per animation cycle. Default 1. */
speed?: number;
/** Accessible label announced to screen readers. Default "Loading". */
label?: string;
/** Color token to use. Default "primary". */
color?: "primary" | "foreground" | "mutedForeground" | "destructive";
className?: string;
}
export const Loader = forwardRef<React.ElementRef<typeof View>, LoaderProps>(
function Loader({ variant = "spinner", size = 32, speed = 1, label = "Loading", color = "primary", className, ...props }, ref) {
const { colors } = useTheme();
const reduce = useReducedMotion();
const colorValue = colors[color];
// Mount fade — a loader that appears mid-layout shouldn't pop in hard;
// a fast opacity ramp reads as materializing (opacity-only = safe under
// reduced motion, which just gets a slightly quicker version).
const enter = useSharedValue(reduce ? 0.6 : 0);
React.useEffect(() => {
enter.value = withTiming(1, timing.fast);
}, [enter]);
const enterStyle = useAnimatedStyle(() => ({ opacity: enter.value }));
return (
<Animated.View
ref={ref}
style={enterStyle}
accessibilityRole="progressbar"
accessibilityLabel={label}
accessibilityState={{ busy: true }}
// RNW drops the accessibilityState object — aria-busy directly.
aria-busy={true}
className={cn("items-center justify-center", className)}
{...props}
>
{variant === "spinner" && <Spinner size={size} speed={speed} color={colorValue} reduce={reduce} />}
{variant === "dots" && <Dots size={size} speed={speed} color={colorValue} reduce={reduce} />}
{variant === "bars" && <Bars size={size} speed={speed} color={colorValue} reduce={reduce} />}
{variant === "pulse" && <Pulse size={size} speed={speed} color={colorValue} reduce={reduce} />}
{variant === "metaballs" && <Metaballs size={size} speed={speed} color={colorValue} reduce={reduce} />}
{variant === "ring" && <Ring size={size} speed={speed} color={colorValue} reduce={reduce} />}
</Animated.View>
);
}
);
Loader.displayName = "Loader";
interface PartProps {
size: number;
speed: number;
color: string;
reduce: boolean | null;
}
function Spinner({ size, speed, color, reduce }: PartProps) {
const rotate = useSharedValue(0);
const opacity = useSharedValue(1);
React.useEffect(() => {
if (reduce) {
// Park the rotor — the old spin loop must not survive the switch,
// or the transform keeps running under reduced motion.
rotate.value = 0;
opacity.value = withRepeat(withTiming(0.4, cycle(speed * 1400)), -1, true);
return;
}
rotate.value = 0;
rotate.value = withRepeat(withTiming(360, spinCycle(speed * 1000)), -1, false);
}, [reduce, speed, rotate, opacity]);
const style = useAnimatedStyle(() => ({
transform: [{ rotate: `${rotate.value}deg` }],
opacity: opacity.value,
}));
const stroke = Math.max(2, size * 0.09);
// Track ring: theme hex + alpha suffix keeps one color token for both
// modes (opaque arc over a translucent track). Theme colors are 6-digit
// hex, so the suffix is a valid 8-digit alpha — never a hardcoded hue.
return (
<Animated.View style={style}>
<Animated.View
style={{
width: size,
height: size,
borderRadius: size / 2,
borderWidth: stroke,
borderColor: `${color}33`,
borderTopColor: color,
}}
/>
</Animated.View>
);
}
function Dots({ size, speed, color, reduce }: PartProps) {
const dot = size * 0.24;
const gap = size * 0.14;
return (
<View style={{ flexDirection: "row", alignItems: "center", gap }}>
{[0, 1, 2].map((i) => (
<Dot key={i} index={i} dot={dot} speed={speed} color={color} reduce={reduce} />
))}
</View>
);
}
function Dot({ index, dot, speed, color, reduce }: { index: number; dot: number; speed: number; color: string; reduce: boolean | null }) {
const y = useSharedValue(0);
const opacity = useSharedValue(0.5);
React.useEffect(() => {
if (reduce) {
// Park the hop — opacity pulse only, no vertical travel.
y.value = 0;
opacity.value = withRepeat(withTiming(1, cycle(speed * 1000)), -1, true);
return;
}
// Phase offset per dot — the wave reads as travel, not a single blink.
// Delay scales with speed so the stagger survives custom cycle lengths.
y.value = withDelay(index * 150 * speed, withRepeat(withTiming(-dot * 1.2, cycle(speed * 1000)), -1, true));
opacity.value = withDelay(index * 150 * speed, withRepeat(withTiming(1, cycle(speed * 1000)), -1, true));
}, [reduce, speed, dot, index, y, opacity]);
const style = useAnimatedStyle(() => ({
transform: [{ translateY: y.value }],
opacity: interpolate(opacity.value, [0, 1], [0.5, 1]),
}));
return (
<Animated.View
style={[{ width: dot, height: dot, borderRadius: dot / 2, backgroundColor: color }, style]}
/>
);
}
function Bars({ size, speed, color, reduce }: PartProps) {
const bar = size * 0.16;
const gap = size * 0.1;
return (
<View style={{ flexDirection: "row", alignItems: "flex-end", gap, height: size }}>
{[0, 1, 2, 3].map((i) => (
<Bar key={i} index={i} bar={bar} height={size} speed={speed} color={color} reduce={reduce} />
))}
</View>
);
}
function Bar({ index, bar, height, speed, color, reduce }: { index: number; bar: number; height: number; speed: number; color: string; reduce: boolean | null }) {
const scaleY = useSharedValue(0.3);
const opacity = useSharedValue(0.5);
React.useEffect(() => {
if (reduce) {
// Park the scale — bars hold full height, opacity pulse only.
scaleY.value = 1;
opacity.value = withRepeat(withTiming(1, cycle(speed * 1000)), -1, true);
return;
}
// Phase offset per bar — the sweep reads left-to-right, not in sync.
scaleY.value = withDelay(index * 120 * speed, withRepeat(withTiming(1, cycle(speed * 1000)), -1, true));
}, [reduce, speed, index, scaleY, opacity]);
const style = useAnimatedStyle(() => ({
// scaleY.value is parked at 1 under reduced motion, so reading the
// shared value directly covers both branches (the old ternary wrapped
// an identity interpolate anyway).
transform: [{ scaleY: scaleY.value }],
opacity: interpolate(opacity.value, [0, 1], [0.5, 1]),
}));
return (
<Animated.View
style={[{ width: bar, height, borderRadius: bar / 2, backgroundColor: color }, style]}
/>
);
}
function Pulse({ size, speed, color, reduce }: PartProps) {
const scale = useSharedValue(0.8);
const opacity = useSharedValue(1);
React.useEffect(() => {
if (reduce) {
scale.value = 1;
opacity.value = withRepeat(withTiming(0.4, cycle(speed * 800)), -1, true);
return;
}
scale.value = withRepeat(withTiming(1.2, cycle(speed * 800)), -1, true);
opacity.value = withRepeat(withTiming(0.3, cycle(speed * 800)), -1, true);
}, [reduce, speed, scale, opacity]);
const style = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
opacity: opacity.value,
}));
return (
<Animated.View
style={[{ width: size, height: size, borderRadius: size / 2, backgroundColor: color }, style]}
/>
);
}
function Metaballs({ size, speed, color, reduce }: PartProps) {
const cx1 = useSharedValue(size * 0.3);
const cx2 = useSharedValue(size * 0.7);
const opacity = useSharedValue(1);
React.useEffect(() => {
if (reduce) {
opacity.value = withRepeat(withTiming(0.4, cycle(speed * 1600)), -1, true);
cx1.value = size * 0.3;
cx2.value = size * 0.7;
return;
}
cx1.value = withRepeat(withTiming(size * 0.7, cycle(speed * 1600)), -1, true);
cx2.value = withRepeat(withTiming(size * 0.3, cycle(speed * 1600)), -1, true);
}, [reduce, speed, size, cx1, cx2, opacity]);
// react-native-svg has no feGaussianBlur, so the goo merge is faked:
// as the gap closes, each blob inflates (surface-tension swell) and a
// small bridge blob grows at the midpoint — at the crossover the mass
// reads as one fused body before the pair splits apart again.
// merge: 1 = fully overlapped, 0 = rest gap (size * 0.4).
const style1 = useAnimatedStyle(() => {
const merge = interpolate(Math.abs(cx2.value - cx1.value), [0, size * 0.4], [1, 0], "clamp");
return {
transform: [{ translateX: cx1.value - size * 0.5 }, { scale: 1 + merge * 0.22 }],
opacity: opacity.value,
};
});
const style2 = useAnimatedStyle(() => {
const merge = interpolate(Math.abs(cx2.value - cx1.value), [0, size * 0.4], [1, 0], "clamp");
return {
transform: [{ translateX: cx2.value - size * 0.5 }, { scale: 1 + merge * 0.22 }],
opacity: opacity.value,
};
});
const bridgeStyle = useAnimatedStyle(() => {
const merge = interpolate(Math.abs(cx2.value - cx1.value), [0, size * 0.4], [1, 0], "clamp");
return {
transform: [
{ translateX: (cx1.value + cx2.value) / 2 - size * 0.5 },
{ scale: merge },
],
opacity: opacity.value * merge,
};
});
const r = size * 0.3;
return (
<View style={{ width: size, height: size, alignItems: "center", justifyContent: "center" }}>
{/* Bridge blob — invisible at rest (merge 0), swells as the pair overlaps. */}
<Animated.View
style={[{ position: "absolute", width: r * 1.2, height: r * 1.2, borderRadius: r, backgroundColor: color }, bridgeStyle]}
/>
<Animated.View
style={[{ position: "absolute", width: r * 2, height: r * 2, borderRadius: r, backgroundColor: color }, style1]}
/>
<Animated.View
style={[{ position: "absolute", width: r * 2, height: r * 2, borderRadius: r, backgroundColor: color }, style2]}
/>
</View>
);
}
function Ring({ size, speed, color, reduce }: PartProps) {
const rotate = useSharedValue(0);
const opacity = useSharedValue(1);
React.useEffect(() => {
if (reduce) {
// Park the rotor — the old spin loop must not survive the switch.
rotate.value = 0;
opacity.value = withRepeat(withTiming(0.4, cycle(speed * 1400)), -1, true);
return;
}
rotate.value = 0;
rotate.value = withRepeat(withTiming(360, spinCycle(speed * 1200)), -1, false);
}, [reduce, speed, rotate, opacity]);
const style = useAnimatedStyle(() => ({
transform: [{ rotate: `${rotate.value}deg` }],
opacity: opacity.value,
}));
const stroke = Math.max(2, size * 0.08);
return (
<Animated.View style={[{ width: size, height: size }, style]}>
<Animated.View
style={{
width: size,
height: size,
borderRadius: size / 2,
borderWidth: stroke,
// Same theme-hex + alpha track treatment as Spinner above.
borderColor: `${color}22`,
borderTopColor: color,
borderRightColor: color,
borderBottomColor: "transparent",
borderLeftColor: "transparent",
}}
/>
</Animated.View>
);
}
| variant? | LoaderVariant | Which animation to render. Default "spinner". | |
| size? | number | Base square size in px. Everything scales from this. Default 32. | |
| speed? | number | Seconds per animation cycle. Default 1. | |
| label? | string | Accessible label announced to screen readers. Default "Loading". | |
| color? | "primary" | "foreground" | "mutedForeground" | "destructive" | Color token to use. Default "primary". | |
| className? | string |
Spinner, dots, bars, pulse, ring and metaball loaders that respect reduced motion.
import { LoaderColors } from "@/components/ui/loader/variants/colors";
export function Example() {
return (
<LoaderColors />
);
} import * as React from "react";
import { View } from "react-native";
import { Loader } from "../loader";
import { Text } from "../../text/text";
import { Glass } from "../../glass/glass";
/** LoaderColors — theme-token color options staged as sync states. */
export function LoaderColors() {
return (
<View className="w-full gap-3">
<Glass interactive curvature="edge" className="w-full gap-3 rounded-2xl p-4">
<Text variant="small" className="font-semibold">Transfer activity</Text>
<View className="flex-row flex-wrap gap-4">
<View className="items-center gap-1"><Loader color="primary" label="Loading" /><Text variant="small">primary</Text></View>
<View className="items-center gap-1"><Loader color="foreground" label="Loading" /><Text variant="small">foreground</Text></View>
<View className="items-center gap-1"><Loader color="mutedForeground" label="Loading" /><Text variant="small">muted</Text></View>
<View className="items-center gap-1"><Loader color="destructive" label="Payment failing" /><Text variant="small">destructive</Text></View>
</View>
<View className="gap-2 border-t border-border pt-3">
<Glass interactive curvature="edge" className="flex-row items-center gap-3 rounded-xl px-3 py-2.5">
<Loader color="primary" label="Uploading guide" />
<View className="flex-1">
<Text variant="small">tram-28-detour.jpg</Text>
<Text variant="muted" className="text-xs">Uploading · 61% left</Text>
</View>
</Glass>
<Glass interactive curvature="edge" className="flex-row items-center gap-3 rounded-xl px-3 py-2.5">
<Loader color="destructive" label="Retrying upload" />
<View className="flex-1">
<Text variant="small">kyoto-cut-final.mp4</Text>
<Text variant="muted" className="text-xs">Connection dropped · retrying</Text>
</View>
</Glass>
</View>
</Glass>
</View>
);
}
Spinner, dots, bars, pulse, ring and metaball loaders that respect reduced motion.
import { LoaderContext } from "@/components/ui/loader/variants/context";
export function Example() {
return (
<LoaderContext />
);
} import * as React from "react";
import { View } from "react-native";
import { Loader } from "../loader";
import { Text } from "../../text/text";
import { Glass } from "../../glass/glass";
/** LoaderContext — loaders embedded in real loading rows. */
export function LoaderContext() {
return (
<View className="w-full gap-3">
<Text variant="small" className="text-white drop-shadow">In context — real loading states</Text>
<Glass interactive curvature="edge" className="w-full gap-3 rounded-xl p-4">
<View className="flex-row items-center gap-3">
<Loader variant="spinner" size={28} label="Placing your order" />
<View className="gap-0.5">
<Text>Placing your order…</Text>
<Text variant="muted">Tonkotsu ×2 • $29.00</Text>
</View>
</View>
<View className="flex-row items-center gap-3">
<Loader variant="dots" size={28} label="Amara is typing" />
<View className="gap-0.5">
<Text>Amara is typing…</Text>
<Text variant="muted">Kyoto at Dusk • 2m ago</Text>
</View>
</View>
<View className="flex-row items-center gap-3">
<Loader variant="bars" size={28} label="Buffering preview" />
<View className="gap-0.5">
<Text>Buffering preview…</Text>
<Text variant="muted">Lisbon album • 164 of 248</Text>
</View>
</View>
</Glass>
</View>
);
}
Spinner, dots, bars, pulse, ring and metaball loaders that respect reduced motion.
import { LoaderSizes } from "@/components/ui/loader/variants/sizes";
export function Example() {
return (
<LoaderSizes />
);
} import * as React from "react";
import { View } from "react-native";
import { Loader } from "../loader";
import { Text } from "../../text/text";
import { Glass } from "../../glass/glass";
/** LoaderSizes — the 16–64px size scale. */
export function LoaderSizes() {
return (
<View className="w-full gap-3">
<Glass interactive curvature="edge" className="w-full gap-3 rounded-2xl p-4">
<Text variant="small" className="font-semibold">Sizes</Text>
<View className="flex-row flex-wrap gap-4 items-center">
<Loader size={16} label="Loading" />
<Loader size={32} label="Loading" />
<Loader size={48} label="Loading" />
<Loader size={64} label="Loading" />
</View>
</Glass>
</View>
);
}
Spinner, dots, bars, pulse, ring and metaball loaders that respect reduced motion.
import { LoaderVariants } from "@/components/ui/loader/variants/variants";
export function Example() {
return (
<LoaderVariants />
);
} import * as React from "react";
import { View } from "react-native";
import { Loader } from "../loader";
import { Text } from "../../text/text";
import { Glass } from "../../glass/glass";
/** LoaderVariants — all six loader animations side by side. */
export function LoaderVariants() {
return (
<View className="w-full gap-3">
<Glass interactive curvature="edge" className="w-full gap-3 rounded-2xl p-4">
<Text variant="small" className="font-semibold">Variants</Text>
<View className="flex-row flex-wrap gap-4">
<View className="items-center gap-1"><Loader variant="spinner" size={32} label="Checkout loading" /><Text variant="small">spinner</Text></View>
<View className="items-center gap-1"><Loader variant="dots" size={32} label="Typing indicator" /><Text variant="small">dots</Text></View>
<View className="items-center gap-1"><Loader variant="bars" size={32} label="Buffering media" /><Text variant="small">bars</Text></View>
<View className="items-center gap-1"><Loader variant="pulse" size={32} label="Syncing wallet" /><Text variant="small">pulse</Text></View>
<View className="items-center gap-1"><Loader variant="ring" size={32} label="Uploading photo" /><Text variant="small">ring</Text></View>
<View className="items-center gap-1"><Loader variant="metaballs" size={48} label="Preparing order" /><Text variant="small">metaballs</Text></View>
</View>
</Glass>
</View>
);
}