# Skeleton

> A placeholder shape shown while content loads; pulses unless Reduce Motion is on, hidden from screen readers.

Component · feedback. Registry item `skeleton`, shown in the vega Style. JSON: https://nativecn.dev/r/styles/vega/skeleton.json

## Installation

```sh
npx nativecn-cli@latest add skeleton
```

Also installs: [theme](https://nativecn.dev/docs/theme/theme), [use-motion](https://nativecn.dev/docs/primitives/use-motion).

Packages: `react-native-reanimated`, `react-native-worklets`.

## Usage

### Skeleton demo

A list row while it loads: an avatar circle and two lines.

```tsx
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

### {components}/skeleton.tsx

```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}
    />
  );
}
```
