# Skeleton

A pulsing placeholder block that stands in for content while it loads.

Skeleton is an open-source component from suiss/ui for React, React Native, SwiftUI and Jetpack Compose. The @esuiss/ui CLI copies its source into your project, so you own the code.

Source: https://ui.suiss.app/docs/components/skeleton

**React**

**Example**

```tsx
import { Skeleton } from "@/components/ui/skeleton"

export default function SkeletonDemo() {
  return <Skeleton className="h-4 w-48" />
}
```

`Skeleton` is a plain `div` with a pulsing animation, rounded corners and a `muted` fill. It has no size of its own; set width, height and shape with utility classes so each placeholder matches the content it replaces.

Combine several skeletons to sketch a layout, such as an avatar with two lines of text. For an indeterminate activity indicator, use `Spinner` instead.

## Installation

### CLI

```bash title="Terminal"
npx shadcn@latest add @esuiss/skeleton
```

Works in any project with a `components.json`. See [Installation](https://ui.suiss.app/docs/installation#add-components) for the one-line registry setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add skeleton
```

### Manual

1. Add the [`cn` helper](https://ui.suiss.app/docs/installation#the-cn-helper).

2. Copy the source into `components/ui/skeleton.tsx`:

```tsx title="components/ui/skeleton.tsx"
import { cn } from "@/lib/utils"

function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="skeleton"
      className={cn("animate-pulse rounded-md bg-muted", className)}
      {...props}
    />
  )
}

export { Skeleton }
```

3. Update the import paths to match your project.

## Usage

```tsx
import { Skeleton } from "@/components/ui/skeleton"
```

```tsx
<Skeleton className="h-4 w-48" />
```

Size every skeleton with classes such as `h-4 w-48`. Use `rounded-full` for avatars and other rounded shapes to override the default `rounded-md`.

## Examples

### Profile

Sketches an avatar beside two lines of text.

**Example**

```tsx
import { Skeleton } from "@/components/ui/skeleton"

export default function SkeletonProfile() {
  return (
    <div className="flex items-center gap-4">
      <Skeleton className="size-12 rounded-full" />
      <div className="flex flex-col gap-2">
        <Skeleton className="h-4 w-48" />
        <Skeleton className="h-4 w-32" />
      </div>
    </div>
  )
}
```

### Card

Sketches a media card with an image and two lines of text.

**Example**

```tsx
import { Skeleton } from "@/components/ui/skeleton"

export default function SkeletonCard() {
  return (
    <div className="flex w-72 flex-col gap-3">
      <Skeleton className="aspect-video w-full" />
      <Skeleton className="h-4 w-3/4" />
      <Skeleton className="h-4 w-1/2" />
    </div>
  )
}
```

### List

Sketches a list of rows, each with an icon and two lines of text.

**Example**

```tsx
import { Skeleton } from "@/components/ui/skeleton"

export default function SkeletonList() {
  return (
    <div className="flex w-80 flex-col gap-4">
      {Array.from({ length: 4 }, (_, index) => (
        <div key={index} className="flex items-center gap-3">
          <Skeleton className="size-9 rounded-xl" />
          <div className="flex flex-1 flex-col gap-2">
            <Skeleton className="h-3.5 w-2/3" />
            <Skeleton className="h-3 w-1/3" />
          </div>
        </div>
      ))}
    </div>
  )
}
```

## API Reference

### Skeleton

A pulsing placeholder block.

Also accepts the props of `React.ComponentProps<"div">`.

## Accessibility

- `Skeleton` renders an empty `div` with no role or text, so it isn't announced. Let assistive technologies know content is loading another way, for example with `aria-busy` on the region that's loading.

## Notes

- Uses `data-slot="skeleton"`, the `animate-pulse` animation and the `bg-muted` token.

**React Native, SwiftUI, Jetpack Compose**

![Preview](https://ui.suiss.app/previews/components-skeleton--default.light.webp)

```tsx
import { Skeleton } from "@/components/ui/skeleton"

export function SkeletonExample() {
  return (
    <Skeleton className="h-4 w-48" />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct SkeletonExample: View {
  var body: some View {
    Skeleton()
      .frame(width: 192, height: 16)
  }
}
```

```kotlin
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Skeleton

@Composable
fun SkeletonExample() {
    Skeleton(Modifier.width(192.dp).height(16.dp))
}
```

`Skeleton` is a Reanimated `View` with a rounded background whose opacity pulses between 1 and 0.5 on a loop. It has no fixed size; you shape it with classes to match the content it stands in for.

## Installation

**React Native**

```bash title="Terminal"
npx shadcn@latest add @esuiss-rn/skeleton
```

See [Installation](https://ui.suiss.app/docs/installation) for the `components.json` setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add skeleton
```

**SwiftUI**

```bash title="Terminal"
npx @esuiss/ui@latest add skeleton --platform swift
```

**Jetpack Compose**

```bash title="Terminal"
npx @esuiss/ui@latest add skeleton --platform kotlin
```

### Manual

**React Native**

**Uniwind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn react-native-reanimated react-native-worklets
```

2. Copy the source into `components/ui/skeleton.tsx`:

```tsx title="uniwind/components/ui/skeleton.tsx"
import { cn } from "cn"
import * as React from "react"
import type { View } from "react-native"
import Animated, {
  useAnimatedStyle,
  useSharedValue,
  withRepeat,
  withTiming,
} from "react-native-reanimated"

const duration = 1000

function Skeleton({
  className,
  style,
  ...props
}: React.ComponentProps<typeof View> &
  React.RefAttributes<React.ComponentRef<typeof View>>) {
  const sv = useSharedValue(1)

  React.useEffect(() => {
    sv.value = withRepeat(withTiming(0.5, { duration }), -1, true)
  }, [sv])

  const animatedStyle = useAnimatedStyle(() => ({
    opacity: sv.value,
  }))
  return (
    <Animated.View
      {...props}
      style={[style, animatedStyle]}
      className={cn("rounded-md bg-muted", className)}
    />
  )
}

export { Skeleton }
```

3. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn react-native-reanimated react-native-worklets
```

2. Copy the source into `components/ui/skeleton.tsx`:

```tsx title="nativewind/components/ui/skeleton.tsx"
import { cn } from "cn"
import * as React from "react"
import type { View } from "react-native"
import Animated, {
  useAnimatedStyle,
  useSharedValue,
  withRepeat,
  withTiming,
} from "react-native-reanimated"

const duration = 1000

function Skeleton({
  className,
  style,
  ...props
}: React.ComponentProps<typeof View> &
  React.RefAttributes<React.ComponentRef<typeof View>>) {
  const sv = useSharedValue(1)

  React.useEffect(() => {
    sv.value = withRepeat(withTiming(0.5, { duration }), -1, true)
  }, [sv])

  const animatedStyle = useAnimatedStyle(() => ({
    opacity: sv.value,
  }))
  return (
    <Animated.View
      {...props}
      style={[style, animatedStyle]}
      className={cn("rounded-md bg-muted", className)}
    />
  )
}

export { Skeleton }
```

3. Update the import paths to match your project.

**StyleSheet**

1. Install the dependencies:

```bash title="Terminal"
npx expo install react-native-reanimated react-native-worklets
```

2. Copy the source into `components/ui/skeleton.tsx`:

```tsx title="stylesheet/components/ui/skeleton.tsx"
import * as React from "react"
import { StyleSheet, type View } from "react-native"
import Animated, {
  useAnimatedStyle,
  useSharedValue,
  withRepeat,
  withTiming,
} from "react-native-reanimated"
import { radius, useTheme } from "@/components/ui/theme"

const duration = 1000

const styles = StyleSheet.create({
  root: { borderRadius: radius.md },
})

function Skeleton({
  style,
  ...props
}: React.ComponentProps<typeof View> &
  React.RefAttributes<React.ComponentRef<typeof View>>) {
  const { colors } = useTheme()
  const sv = useSharedValue(1)

  React.useEffect(() => {
    sv.value = withRepeat(withTiming(0.5, { duration }), -1, true)
  }, [sv])

  const animatedStyle = useAnimatedStyle(() => ({
    opacity: sv.value,
  }))
  return (
    <Animated.View
      style={[
        styles.root,
        { backgroundColor: colors.muted },
        style,
        animatedStyle,
      ]}
      {...props}
    />
  )
}

export { Skeleton }
```

3. Update the import paths to match your project.

**SwiftUI**

1. Target iOS 17 or later.

2. Copy the theme files every view uses:

- `swift/Theme/Theme.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Theme.swift)

- `swift/Theme/Tokens.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Tokens.swift)

3. Copy the source:

```swift title="swift/Components/Skeleton.swift"
import SwiftUI

public struct Skeleton: View {
  let cornerRadius: CGFloat

  @Environment(\.accessibilityReduceMotion) private var reduceMotion
  @State private var dimmed = false

  public init(cornerRadius: CGFloat = Radius.md) {
    self.cornerRadius = cornerRadius
  }

  public var body: some View {
    RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
      .fill(EsuissColor.muted)
      .opacity(dimmed ? 0.5 : 1)
      .onAppear {
        guard !reduceMotion else { return }
        withAnimation(.easeInOut(duration: 1).repeatForever(autoreverses: true)) {
          dimmed = true
        }
      }
      .onChange(of: reduceMotion) { _, reduced in
        if reduced {
          withAnimation(nil) { dimmed = false }
        }
      }
      .accessibilityHidden(true)
  }
}
```

4. Add the files to your app target.

**Jetpack Compose**

1. Set `minSdk` to 24 or higher and add the dependencies:

```kotlin title="build.gradle.kts"
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
```

2. Copy the theme files every view uses:

- `kotlin/theme/Theme.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Theme.kt)

- `kotlin/theme/Tokens.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Tokens.kt)

3. Copy the source and every file it uses:

```kt title="kotlin/components/Skeleton.kt"
package com.esuiss.ui.components

import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.semantics.clearAndSetSemantics
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Radius

@Composable
fun Skeleton(
    modifier: Modifier = Modifier,
    shape: Shape = RoundedCornerShape(Radius.md),
) {
    val reduced = formReducedMotion()
    val transition = rememberInfiniteTransition(label = "skeleton")
    val pulse by transition.animateFloat(
        initialValue = 1f,
        targetValue = 0.5f,
        animationSpec = infiniteRepeatable(tween(1000, easing = LinearEasing), RepeatMode.Reverse),
        label = "skeleton",
    )
    Box(
        modifier
            .clearAndSetSemantics {}
            .graphicsLayer { alpha = if (reduced) 1f else pulse }
            .background(EsuissTheme.colors.muted, shape),
    )
}
```

- `kotlin/components/FormSupport.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/FormSupport.kt)

4. Update the `com.esuiss.ui` package names to match your project.

## Usage

```tsx
import { Skeleton } from "@/components/ui/skeleton"
```

```tsx
<Skeleton className="h-4 w-48" />
```

Set width, height and radius through `className`, for example `h-4 w-48` for a line of text or `size-12 rounded-full` for an avatar. Compose several skeletons to mirror the layout of the loaded content, then swap them for the real content once it arrives.

## Examples

### Profile

An avatar circle with two lines of text.

![Preview](https://ui.suiss.app/previews/components-skeleton--profile.light.webp)

```tsx
import { View } from "react-native"
import { Skeleton } from "@/components/ui/skeleton"

export function SkeletonProfileExample() {
  return (
    <View className="flex-row items-center gap-4">
      <Skeleton className="size-12 rounded-full" />
      <View className="gap-2">
        <Skeleton className="h-4 w-48" />
        <Skeleton className="h-4 w-36" />
      </View>
    </View>
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct SkeletonProfileExample: View {
  var body: some View {
    HStack(spacing: 16) {
      Skeleton(cornerRadius: Radius.full)
        .frame(width: 48, height: 48)
      VStack(alignment: .leading, spacing: 8) {
        Skeleton().frame(width: 192, height: 16)
        Skeleton().frame(width: 144, height: 16)
      }
    }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Skeleton

@Composable
fun SkeletonProfile() {
    Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {
        Skeleton(Modifier.size(48.dp), shape = CircleShape)
        Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
            Skeleton(Modifier.width(192.dp).height(16.dp))
            Skeleton(Modifier.width(144.dp).height(16.dp))
        }
    }
}
```

### Card

A media block with two lines of text below it.

![Preview](https://ui.suiss.app/previews/components-skeleton--card.light.webp)

```tsx
import { View } from "react-native"
import { Skeleton } from "@/components/ui/skeleton"

export function SkeletonCardExample() {
  return (
    <View className="gap-3">
      <Skeleton className="h-32 w-full rounded-xl" />
      <View className="gap-2">
        <Skeleton className="h-4 w-3/4" />
        <Skeleton className="h-4 w-1/2" />
      </View>
    </View>
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct SkeletonCardExample: View {
  var body: some View {
    VStack(alignment: .leading, spacing: 12) {
      Skeleton(cornerRadius: Radius.xl)
        .frame(height: 128)
        .frame(maxWidth: .infinity)
      GeometryReader { proxy in
        VStack(alignment: .leading, spacing: 8) {
          Skeleton().frame(width: proxy.size.width * 0.75, height: 16)
          Skeleton().frame(width: proxy.size.width * 0.5, height: 16)
        }
      }
      .frame(height: 40)
    }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Skeleton
import com.esuiss.ui.theme.Radius

@Composable
fun SkeletonCard() {
    Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
        Skeleton(Modifier.fillMaxWidth().height(128.dp), shape = RoundedCornerShape(Radius.xl))
        Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
            Skeleton(Modifier.fillMaxWidth(0.75f).height(16.dp))
            Skeleton(Modifier.fillMaxWidth(0.5f).height(16.dp))
        }
    }
}
```

### List

Rows with a thumbnail and two lines of text.

![Preview](https://ui.suiss.app/previews/components-skeleton--list.light.webp)

```tsx
import { View } from "react-native"
import { Skeleton } from "@/components/ui/skeleton"

export function SkeletonListExample() {
  return (
    <View className="gap-4">
      {["one", "two", "three", "four"].map((key) => (
        <View key={key} className="flex-row items-center gap-3">
          <Skeleton className="size-10 rounded-md" />
          <View className="flex-1 gap-2">
            <Skeleton className="h-4 w-2/3" />
            <Skeleton className="h-3 w-1/3" />
          </View>
        </View>
      ))}
    </View>
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct SkeletonListExample: View {
  var body: some View {
    VStack(alignment: .leading, spacing: 16) {
      ForEach(["one", "two", "three", "four"], id: \.self) { _ in
        HStack(spacing: 12) {
          Skeleton(cornerRadius: Radius.md)
            .frame(width: 40, height: 40)
          GeometryReader { proxy in
            VStack(alignment: .leading, spacing: 8) {
              Skeleton().frame(width: proxy.size.width * 2 / 3, height: 16)
              Skeleton().frame(width: proxy.size.width / 3, height: 12)
            }
            .frame(maxHeight: .infinity)
          }
          .frame(height: 40)
        }
      }
    }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Skeleton
import com.esuiss.ui.theme.Radius

@Composable
fun SkeletonList() {
    Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) {
        repeat(4) {
            Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
                Skeleton(Modifier.size(40.dp), shape = RoundedCornerShape(Radius.md))
                Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
                    Skeleton(Modifier.fillMaxWidth(2f / 3f).height(16.dp))
                    Skeleton(Modifier.fillMaxWidth(1f / 3f).height(12.dp))
                }
            }
        }
    }
}
```

## API Reference

### Skeleton

Also accepts the props of `React.ComponentProps<typeof View>`, `React.RefAttributes<View>`.

## Accessibility

- The skeleton is a plain view with no role or label. If loading state matters to the user, announce it elsewhere, for example with a `Spinner` that has an `accessibilityLabel`.

## Notes

- Uses the `bg-muted` token.
- The pulse runs on the UI thread through Reanimated, with a 1000 ms cycle.
- Light and dark colors follow the system appearance.

