Shimmer placeholder that respects reduced motion.
components/ui/skeleton/skeleton.tsx@lumioui/core@latestreact-native-reanimated@*Live preview — the real component, driven by the props below.
import { Skeleton } from "@/components/ui/skeleton/skeleton";
export function Example() {
return (
<Skeleton />
);
} import * as React from "react";
import { forwardRef, useEffect } from "react";
import { View, type ViewProps } from "react-native";
import Animated, {
useSharedValue,
useAnimatedStyle,
withRepeat,
withTiming,
cancelAnimation,
interpolate,
Easing,
} from "react-native-reanimated";
import { cn, useReducedMotion, useTheme } from "@lumioui/core";
/** Pulse breathing — ambient loop, intentionally outside the UI-motion band. */
const PULSE_CYCLE = { duration: 800, easing: Easing.inOut(Easing.ease) };
/** Shimmer sweep — one full traversal of the block. */
const SHIMMER_CYCLE = { duration: 1400, easing: Easing.inOut(Easing.quad) };
export interface SkeletonProps extends Omit<ViewProps, "className"> {
className?: string;
/**
* "pulse" (default): gentle opacity breathing.
* "shimmer": a light sweep travels across — the crafted loading look.
*/
variant?: "pulse" | "shimmer";
}
/**
* Skeleton — loading placeholder. Pulse breathes; shimmer sweeps a light
* band left→right on repeat. Reduced-motion falls back to a static block.
*/
export const Skeleton = forwardRef<React.ElementRef<typeof View>, SkeletonProps>(
function Skeleton({ className, variant = "pulse", style, onLayout, ...props }, ref) {
const { dark, colors } = useTheme();
const reduceMotion = useReducedMotion();
const opacity = useSharedValue(1);
const sweep = useSharedValue(0);
const width = useSharedValue(0);
useEffect(() => {
// Reduced motion: static block — cancel any running loop first so a
// variant switch can't leave a stale repeat alive.
if (reduceMotion) {
cancelAnimation(opacity);
cancelAnimation(sweep);
opacity.value = 1;
sweep.value = 0;
return;
}
if (variant === "pulse") {
cancelAnimation(sweep);
sweep.value = 0;
opacity.value = withRepeat(withTiming(0.5, PULSE_CYCLE), -1, true);
} else {
cancelAnimation(opacity);
opacity.value = 1;
sweep.value = withRepeat(withTiming(1, SHIMMER_CYCLE), -1, false);
}
}, [opacity, sweep, variant, reduceMotion]);
const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value }));
const sweepStyle = useAnimatedStyle(() => ({
transform: [{ translateX: -80 + sweep.value * (width.value + 160) }],
// Fade the band in/out across the first/last 8% of the sweep so the
// loop restart is never visible, even if a layout lands mid-cycle.
opacity: interpolate(sweep.value, [0, 0.08, 0.92, 1], [0, 1, 1, 0], "clamp"),
}));
return (
<Animated.View
ref={ref}
className={cn("overflow-hidden rounded-lg bg-muted", className)}
style={[animatedStyle, style]}
onLayout={(e) => {
width.value = e.nativeEvent.layout.width;
onLayout?.(e);
}}
accessible={false}
// Placeholder blocks announce nothing — hide them from the a11y tree;
// the loading state is communicated by the surrounding context.
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
aria-hidden
{...props}
>
{variant === "shimmer" && !reduceMotion ? (
<Animated.View
style={[
{
position: "absolute",
top: 0,
bottom: 0,
width: 80,
// Sheen band — theme foreground at a low alpha: a light sweep
// in dark mode, a soft shadow sweep in light mode, so the
// shimmer survives both schemes without a hardcoded white.
// Hex alpha suffix — theme colors are 6-digit hex (loader
// ring-track pattern).
backgroundColor: `${colors.foreground}${dark ? "26" : "14"}`,
transform: [{ skewX: "-12deg" }],
},
sweepStyle,
]}
/>
) : null}
</Animated.View>
);
});
Skeleton.displayName = "Skeleton";
| className? | string | ||
| variant? | "pulse" | "shimmer" | "pulse" (default): gentle opacity breathing. "shimmer": a light sweep travels across — the crafted loading look. |
Shimmer placeholder that respects reduced motion.
import { SkeletonPulse } from "@/components/ui/skeleton/variants/pulse";
export function Example() {
return (
<SkeletonPulse />
);
} import * as React from "react";
import { View } from "react-native";
import { Skeleton } from "../skeleton";
import { Text } from "../../text/text";
import { Glass } from "../../glass/glass";
function FeedRow() {
return (
<View className="flex-row items-center gap-3">
<Skeleton className="h-10 w-10 rounded-full" />
<View className="flex-1 gap-1.5">
<Skeleton className="h-3 w-1/2 rounded" />
<Skeleton className="h-3 w-2/3 rounded" />
</View>
<Skeleton className="h-3 w-8 rounded" />
</View>
);
}
/** SkeletonPulse — orders feed loading: profile hero plus a list of pulsing rows. */
export function SkeletonPulse() {
return (
<View className="w-full gap-3">
<Glass interactive curvature="edge" className="w-full gap-4 rounded-2xl p-4">
<View className="flex-row items-center justify-between">
<Text variant="small" className="font-semibold">Lumo Wallet</Text>
<Skeleton className="h-5 w-14 rounded-full" />
</View>
{/* Balance hero */}
<Glass interactive curvature="edge" className="gap-2 rounded-xl p-3">
<Skeleton className="h-2.5 w-20 rounded" />
<Skeleton className="h-7 w-32 rounded-md" />
</Glass>
<View className="gap-3">
<FeedRow />
<FeedRow />
<FeedRow />
</View>
<Text variant="muted" className="text-center text-xs">Syncing your activity…</Text>
</Glass>
</View>
);
}
Shimmer placeholder that respects reduced motion.
import { SkeletonShimmer } from "@/components/ui/skeleton/variants/shimmer";
export function Example() {
return (
<SkeletonShimmer />
);
} import * as React from "react";
import { View } from "react-native";
import { Skeleton } from "../skeleton";
import { Text } from "../../text/text";
import { Glass } from "../../glass/glass";
/** SkeletonShimmer — feed card loading with shimmer animation. */
export function SkeletonShimmer() {
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">Feed</Text>
<Skeleton variant="shimmer" className="h-20 w-full rounded-lg" />
<Skeleton variant="shimmer" className="h-3 w-3/4 rounded" />
<Skeleton variant="shimmer" className="h-3 w-1/2 rounded" />
</Glass>
</View>
);
}