Skeleton
A placeholder shape shown while content loads; pulses unless Reduce Motion is on, hidden from screen readers.
Componentfeedback
Style
Light
Dark
Your app is ready.
Inter body text, Inter headings.
smmdlgxl
you@example.com
Continue
a0: Vega, neutral, neutral accent, default radius, Inter.Change PresetInstallation
$
npx nativecn-cli@latest add skeletonAlso installs: theme, use-motion.
Packages: react-native-reanimated, react-native-worklets.
Usage
Skeleton demo
A list row while it loads: an avatar circle and two lines.
import { View } from "react-native";
import { Skeleton } from "@/components/skeleton";
import { createStyles } from "@/theme";
const useStyles = createStyles((t) => ({
row: { flexDirection: "row", alignItems: "center", gap: t.spacing[4] },
lines: { flex: 1, gap: t.spacing[2] },
}));
/** A list row while it loads: an avatar circle and two lines of text, announced busy once. */
export default function SkeletonDemo() {
const styles = useStyles();
return (
<View style={styles.row} accessible aria-busy aria-label="Loading profile">
<Skeleton circle height={48} />
<View style={styles.lines}>
<Skeleton width="70%" height={16} />
<Skeleton width="45%" height={16} />
</View>
</View>
);
}Skeleton demo
A list row while it loads: an avatar circle and two lines.
import { View } from "react-native";
import { Skeleton } from "@/components/skeleton";
import { createStyles } from "@/theme";
const useStyles = createStyles((t) => ({
row: { flexDirection: "row", alignItems: "center", gap: t.spacing[4] },
lines: { flex: 1, gap: t.spacing[2] },
}));
/** A list row while it loads: an avatar circle and two lines of text, announced busy once. */
export default function SkeletonDemo() {
const styles = useStyles();
return (
<View style={styles.row} accessible aria-busy aria-label="Loading profile">
<Skeleton circle height={48} />
<View style={styles.lines}>
<Skeleton width="70%" height={16} />
<Skeleton width="45%" height={16} />
</View>
</View>
);
}Props
| Prop | Type and description |
|---|---|
width | number | percentage string |
height | number | percentage string; with circle and no width, also the width |
circle | boolean (default false): fully rounded |
Accessibility
- Always hidden from screen readers: put aria-busy and an aria-label on the loading container instead.
- Still under Reduce Motion: it does not pulse.
Notes
Pulses with the slow motion Token. The corner radius comes from the skeleton.root Style Slot.
Source
The Vega Style's code, exactly as add copies it into your app.
{components}/skeleton.tsx
import { useEffect, type Ref } from "react";
import type { DimensionValue, View, ViewProps } from "react-native";
import Animated, {
cancelAnimation,
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
} from "react-native-reanimated";
import { useMotion } from "@/hooks/use-motion";
import { createStyles } from "@/theme";
/** The lowest opacity of the pulse; it eases back to 1 and repeats. */
const PULSE_MIN_OPACITY = 0.5;
export type SkeletonProps = Omit<ViewProps, "children"> & {
/** A number or a percentage, e.g. `120` or `"60%"`. */
width?: DimensionValue;
/** A number or a percentage. With `circle` and no `width`, it is also the width. */
height?: DimensionValue;
/** A round placeholder (an avatar): fully rounded, square unless both sides are given. */
circle?: boolean;
ref?: Ref<View>;
};
const useStyles = createStyles((t) => ({
root: { ...{ borderRadius: t.radius.md }, backgroundColor: t.colors.muted },
circle: { borderRadius: t.radius.full },
}));
/**
* A placeholder shape shown while content loads. It pulses gently, and stays still under Reduce
* Motion. Always hidden from screen readers: announce loading on the container (e.g.
* `aria-busy`) instead. `style` is merged last.
*/
export function Skeleton({ width, height, circle = false, style, ...props }: SkeletonProps) {
const styles = useStyles();
const motion = useMotion();
const opacity = useSharedValue(1);
useEffect(() => {
if (motion.reduced) {
cancelAnimation(opacity);
opacity.set(1);
return;
}
opacity.set(withRepeat(withTiming(PULSE_MIN_OPACITY, motion.timing("slow")), -1, true));
return () => cancelAnimation(opacity);
}, [motion, opacity]);
const pulse = useAnimatedStyle(() => ({ opacity: opacity.get() }));
// Only the given sides, so an unset one never overrides `style`.
const w = circle ? (width ?? height) : width;
const h = circle ? (height ?? width) : height;
const size = { ...(w != null ? { width: w } : {}), ...(h != null ? { height: h } : {}) };
return (
<Animated.View
aria-hidden
accessible={false}
importantForAccessibility="no-hide-descendants"
style={[styles.root, circle ? styles.circle : null, size, pulse, style]}
{...props}
/>
);
}{components}/skeleton.tsx
import { useEffect, type Ref } from "react";
import type { DimensionValue, View, ViewProps } from "react-native";
import Animated, {
cancelAnimation,
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
} from "react-native-reanimated";
import { useMotion } from "@/hooks/use-motion";
import { createStyles } from "@/theme";
/** The lowest opacity of the pulse; it eases back to 1 and repeats. */
const PULSE_MIN_OPACITY = 0.5;
export type SkeletonProps = Omit<ViewProps, "children"> & {
/** A number or a percentage, e.g. `120` or `"60%"`. */
width?: DimensionValue;
/** A number or a percentage. With `circle` and no `width`, it is also the width. */
height?: DimensionValue;
/** A round placeholder (an avatar): fully rounded, square unless both sides are given. */
circle?: boolean;
ref?: Ref<View>;
};
const useStyles = createStyles((t) => ({
root: { ...{ borderRadius: t.radius.sm }, backgroundColor: t.colors.muted },
circle: { borderRadius: t.radius.full },
}));
/**
* A placeholder shape shown while content loads. It pulses gently, and stays still under Reduce
* Motion. Always hidden from screen readers: announce loading on the container (e.g.
* `aria-busy`) instead. `style` is merged last.
*/
export function Skeleton({ width, height, circle = false, style, ...props }: SkeletonProps) {
const styles = useStyles();
const motion = useMotion();
const opacity = useSharedValue(1);
useEffect(() => {
if (motion.reduced) {
cancelAnimation(opacity);
opacity.set(1);
return;
}
opacity.set(withRepeat(withTiming(PULSE_MIN_OPACITY, motion.timing("slow")), -1, true));
return () => cancelAnimation(opacity);
}, [motion, opacity]);
const pulse = useAnimatedStyle(() => ({ opacity: opacity.get() }));
// Only the given sides, so an unset one never overrides `style`.
const w = circle ? (width ?? height) : width;
const h = circle ? (height ?? width) : height;
const size = { ...(w != null ? { width: w } : {}), ...(h != null ? { height: h } : {}) };
return (
<Animated.View
aria-hidden
accessible={false}
importantForAccessibility="no-hide-descendants"
style={[styles.root, circle ? styles.circle : null, size, pulse, style]}
{...props}
/>
);
}
