Automatic loading skeletons for Compose Multiplatform. Wrap your existing composables — no parallel skeleton UI to build or maintain.
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 draws a shimmering
placeholder sized to its own measured bounds instead of its real content.
When loading flips to false, it crossfades into the real content. One
shared shimmer animation runs per SkeletonContainer, so a whole screen of
placeholders animates in sync instead of paying for one animation driver
per element.
Platforms: Android, iOS, Desktop (JVM).
Available on Maven Central:
// build.gradle.kts
dependencies {
implementation("io.github.kmpbits:skeletal:0.1.0")
}See PUBLISHING.md for how new versions are released.
@Composable
fun SkeletonContainer(
loading: Boolean,
modifier: Modifier = Modifier,
shimmerColors: List<Color> = SkeletonDefaults.shimmerColors, // MaterialTheme-derived by default
cornerRadius: Dp = SkeletonDefaults.cornerRadius, // 4.dp by default
content: @Composable () -> Unit,
)
fun Modifier.skeleton(
shape: SkeletonShape = SkeletonShape.Auto, // own bounds, rounded rect
// also: SkeletonShape.Circle, SkeletonShape.RoundedCorner(radius)
): ModifierModifier.skeleton() with no ancestor SkeletonContainer is a no-op, so
it's safe to leave on an element regardless of whether it's currently
inside a loading context.
For state modeled as a sealed class instead of a plain Boolean, a second
overload takes the state directly plus two small extractor lambdas — the
success payload flows into content already typed, and failures get their
own dedicated slot:
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("Something went wrong") },
) { post ->
Card {
Text(
text = post?.title ?: "",
modifier = Modifier.fillMaxWidth(0.6f).skeleton()
)
}
}onFailure has no default — a caller reaching for this overload already
has a failure case to handle. Callers without one should keep using the
plain loading: Boolean overload above.
An Android sample app lives in sample/ — a scrollable feed of
cards exercising all three SkeletonShape variants, plus a StateDrivenPostCard
demonstrating the state-driven overload. A "Reload" button re-triggers loading
for both, alternating the state-driven card between its Success and
Failure cases on each reload. Run it from Android Studio, or:
./gradlew :sample:assembleDebug./gradlew :skeletal:desktopTest # run the test suite
./gradlew :skeletal:build # build the library for all targets