Skip to content
All Libraries

Skeletal

Automatic loading skeletons for Compose Multiplatform, wrapping your existing composables with no parallel skeleton UI to build or maintain.

GitHub
version <version> Compose Multiplatform Apache 2.0 Maven Central

Read the story behind it: Lights Out: Automatic Skeleton Loading in Compose Multiplatform →

On this page

Overview

Skeletal adds shimmering loading placeholders to Compose Multiplatform without a parallel skeleton UI to build or maintain. Wrap existing composables in a SkeletonContainer and mark individual elements with .skeleton(). Each one draws a placeholder shaped to its own measured bounds while loading is true, then crossfades into the real content once it arrives.

Every .skeleton() element inside the same SkeletonContainer reads one shared shimmer animation, so a screen full of placeholders sweeps in sync instead of each element driving its own animation clock.

Platforms: Android, iOS, Desktop (JVM).


Installation

1. Add the dependency

commonMain.dependencies {
    implementation("io.github.kmpbits:skeletal:<version>")
}

Replace <version> with the latest release on GitHub or Maven Central.

2. Sync

Skeletal is a plain library, not a Gradle plugin, so there’s no plugins {} block, no generated sources, and no extra sync step beyond adding the dependency and syncing Gradle as usual.


Usage

SkeletonContainer(loading = post == null) {
    Card {
        Row {
            Image(
                painter = rememberImagePainter(post?.avatarUrl),
                contentDescription = null,
                modifier = Modifier
                    .size(40.dp)
                    .clip(CircleShape)
                    .skeleton(shape = SkeletonShape.Circle)
            )

            Column {
                Text(
                    text = post?.title ?: "",
                    modifier = Modifier.fillMaxWidth(0.6f).skeleton()
                )
                Text(
                    text = post?.subtitle ?: "",
                    modifier = Modifier.fillMaxWidth(0.4f).skeleton()
                )
            }
        }
    }
}

While loading is true, every .skeleton() element inside the container draws a shimmering placeholder sized to its own measured bounds instead of its real content. When loading flips to false, each element crossfades into the real content independently.

Modifier.skeleton() with no ancestor SkeletonContainer above it is a no-op: it draws the real content and stops there, at no extra cost. It’s safe to leave .skeleton() on an element permanently, whether or not it’s currently inside a loading context.


API

SkeletonContainer

@Composable
fun SkeletonContainer(
    loading: Boolean,
    modifier: Modifier = Modifier,
    shimmerColors: List<Color> = SkeletonDefaults.shimmerColors,
    cornerRadius: Dp = SkeletonDefaults.cornerRadius,
    content: @Composable () -> Unit,
)

Hosts one shared shimmer animation and makes it available, along with loading, to every .skeleton() modifier inside content via a CompositionLocal. The animation only exists while loading is true: flipping it to false removes the underlying infinite transition from composition entirely, so the clock actually stops instead of continuing to run in the background.

SkeletonContainer (state-driven)

@Composable
fun <S, T> SkeletonContainer(
    state: S,
    dataOrNull: (S) -> T?,
    isFailure: (S) -> Boolean,
    modifier: Modifier = Modifier,
    shimmerColors: List<Color> = SkeletonDefaults.shimmerColors,
    cornerRadius: Dp = SkeletonDefaults.cornerRadius,
    onFailure: @Composable (S) -> Unit,
    content: @Composable (T?) -> Unit,
)

For state modeled as a sealed class instead of a plain Boolean. isFailure is checked first: if true, only onFailure is composed (passed state itself) and content is skipped entirely, with no shimmer shown either. Otherwise dataOrNull extracts the success payload: loading is derived as data == null, and the typed payload flows into content.

sealed interface Loadable<out T> {
    data object Loading : Loadable<Nothing>
    data class Loaded<T>(val value: T) : Loadable<T>
    data class Failed(val error: Throwable) : Loadable<Nothing>
}

SkeletonContainer(
    state = state, // Loadable<Post>
    dataOrNull = { (it as? Loadable.Loaded)?.value },
    isFailure = { it is Loadable.Failed },
    onFailure = { Text((it as Loadable.Failed).error.message ?: "Something went wrong") },
) { post ->
    Card {
        Text(
            text = post?.title ?: "",
            modifier = Modifier.fillMaxWidth(0.6f).skeleton()
        )
    }
}

onFailure has no default, since reaching for this overload means there’s already a failure case to handle. Callers without one should use the plain loading: Boolean overload above.

One gap: state’s type is fully generic here, so onFailure still has to cast it down to your failure variant. The compiler has no way to know that isFailure returning true implies a specific subtype. If that cast bothers you, use the LoadState-driven overload below instead.

SkeletonContainer (LoadState-driven)

sealed interface LoadState<out T, out F> {
    data object Loading : LoadState<Nothing, Nothing>
    data class Success<T>(val data: T) : LoadState<T, Nothing>
    data class Failure<F>(val reason: F) : LoadState<Nothing, F>
}

@Composable
fun <T, F> SkeletonContainer(
    state: LoadState<T, F>,
    modifier: Modifier = Modifier,
    shimmerColors: List<Color> = SkeletonDefaults.shimmerColors,
    cornerRadius: Dp = SkeletonDefaults.cornerRadius,
    onFailure: @Composable (F) -> Unit,
    content: @Composable (T?) -> Unit,
)

A ready-made Loading/Success/Failure state for callers without their own sealed state type. state’s shape is fixed to LoadState (that’s the trade), but in return onFailure receives a concretely typed failure payload (F) with no cast required:

SkeletonContainer(
    state = state, // LoadState<Post, String>
    onFailure = { reason -> Text(reason) }, // reason: String
) { post ->
    Card {
        Text(
            text = post?.title ?: "",
            modifier = Modifier.fillMaxWidth(0.6f).skeleton()
        )
    }
}

LoadState.Loading isn’t special-cased by the overload. It’s just whatever state isn’t Success or Failure, so the shimmer shows automatically without any extra branching.

Modifier.skeleton()

fun Modifier.skeleton(
    shape: SkeletonShape = SkeletonShape.Auto,
): Modifier

Draws a placeholder over the element while the enclosing SkeletonContainer’s loading is true, then crossfades into the real content. With no ancestor SkeletonContainer, this is a no-op.

SkeletonShape

ValueDescription
SkeletonShape.AutoRounded rectangle matching the element’s own measured bounds, using the container’s corner radius. Default.
SkeletonShape.CircleCircle inscribed in the element’s own measured bounds.
SkeletonShape.RoundedCorner(radius)Rounded rectangle matching the element’s own measured bounds, with a custom corner radius.

SkeletonDefaults

PropertyDefaultNotes
cornerRadius4.dpUsed by SkeletonShape.Auto and as the container-wide default.
shimmerColors[surfaceVariant, surface, surfaceVariant]Three-stop gradient derived from the current MaterialTheme.colorScheme.
crossfadeSpectween(durationMillis = 180)Animation used to crossfade between the shimmer and the revealed content.

All three are overridable per SkeletonContainer.


Sample

An Android sample app lives in the repo’s sample/ directory: a scrollable feed of cards exercising all three SkeletonShape variants, plus one card per state-driven overload, both alternating between success and failure on each reload.


Known Limitations

  • Compose-only: Skeletal relies on Compose’s own measurement pass to size placeholders. It has no equivalent for non-Compose UI (classic Android Views, SwiftUI).
  • No layout-tree walking: Skeletal never guesses which composables need a placeholder. .skeleton() is applied explicitly, element by element, so there’s no “wrap a screen and get skeletons for free” mode.

Read the writeup on the design decisions behind the sync fix and the modifier-ordering bug caught before it shipped: Lights Out: Automatic Skeleton Loading in Compose Multiplatform.

Maintained by KMP Bits View source on GitHub →