diff --git a/README.md b/README.md
index dd05692..4fae49f 100644
--- a/README.md
+++ b/README.md
@@ -22,6 +22,7 @@ This library provides ready-to-use UI components for multi-factor authentication
- 💬 **SMS OTP** - Phone number verification via one-time codes
- 📧 **Email OTP** - Email-based verification
- 🔑 **Recovery Codes** - Backup authentication codes for account recovery
+- ✨ **Skeleton shimmer loading** - Card screens show animated, theme-aware skeleton placeholders while data loads (light/dark mode, honours the system animation setting)
All components are built on top of the [Auth0 Android SDK](https://github.com/auth0/Auth0.Android) and integrate with Auth0's My Account APIs.
diff --git a/universal_components/src/main/java/com/auth0/universalcomponents/presentation/ui/components/skeleton/Shimmer.kt b/universal_components/src/main/java/com/auth0/universalcomponents/presentation/ui/components/skeleton/Shimmer.kt
new file mode 100644
index 0000000..d291786
--- /dev/null
+++ b/universal_components/src/main/java/com/auth0/universalcomponents/presentation/ui/components/skeleton/Shimmer.kt
@@ -0,0 +1,126 @@
+package com.auth0.universalcomponents.presentation.ui.components.skeleton
+
+import android.provider.Settings
+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.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawWithContent
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.BlendMode
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.CompositingStrategy
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.graphics.luminance
+import androidx.compose.ui.platform.LocalContext
+import com.auth0.universalcomponents.theme.Auth0Theme
+
+private const val SHIMMER_DURATION_MS = 1300
+private const val BAND_WIDTH_MULTIPLIER = 2f
+private const val BAND_TRAVEL_MULTIPLIER = 3f
+private const val LUMINANCE_DARK_THRESHOLD = 0.5f
+
+// Purpose-built skeleton greys. Declared as properties (not inline literals) so the colour
+// hex values are exempt from detekt's MagicNumber rule, mirroring how the theme tokens are
+// handled.
+private val DarkSkeletonBase = Color(0xFF383838)
+private val DarkSkeletonHighlight = Color(0xFF575757)
+private val LightSkeletonBase = Color(0xFFE0E0E0)
+private val LightSkeletonHighlight = Color(0xFFF7F7F7)
+
+private val DarkSkeletonPalette = SkeletonPalette(base = DarkSkeletonBase, highlight = DarkSkeletonHighlight)
+private val LightSkeletonPalette = SkeletonPalette(base = LightSkeletonBase, highlight = LightSkeletonHighlight)
+
+/**
+ * Light/dark greys for skeletons. The SDK's layer tokens are near-white-on-white in
+ * light mode (an invisible sweep), so we use purpose-built greys that adapt to the
+ * colour scheme.
+ */
+internal data class SkeletonPalette(val base: Color, val highlight: Color) {
+ companion object {
+ fun forScheme(dark: Boolean): SkeletonPalette =
+ if (dark) DarkSkeletonPalette else LightSkeletonPalette
+ }
+}
+
+/**
+ * Resolves the active skeleton palette from the current [Auth0Theme].
+ *
+ * Uses the resolved theme background's luminance rather than [androidx.compose.foundation.isSystemInDarkTheme]
+ * so that a consumer who forces `Auth0Theme(darkTheme = …)` (independent of the system
+ * setting) still gets greys that match the rendered surface.
+ */
+@Composable
+internal fun rememberSkeletonPalette(): SkeletonPalette {
+ val dark = Auth0Theme.colors.backgroundLayerBase.luminance() < LUMINANCE_DARK_THRESHOLD
+ return remember(dark) { SkeletonPalette.forScheme(dark) }
+}
+
+/**
+ * Sweeps an animated shimmer highlight across this composable to indicate loading.
+ * Apply to ONE container (a list/Column) so every child animates in a single
+ * synchronized sweep — the highlight is clipped to the content that is actually drawn.
+ *
+ * When [active] is false, or the system "remove animations" setting is on, this is a
+ * no-op and the placeholders render as static base-grey blocks (the reduce-motion
+ * fallback).
+ */
+@Composable
+internal fun Modifier.shimmer(active: Boolean = true): Modifier {
+ if (!active || !animationsEnabled()) return this
+
+ val palette = rememberSkeletonPalette()
+ val transition = rememberInfiniteTransition(label = "shimmer")
+ val phase by transition.animateFloat(
+ initialValue = 0f,
+ targetValue = 1f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(durationMillis = SHIMMER_DURATION_MS, easing = LinearEasing),
+ repeatMode = RepeatMode.Restart, // sweep travels in one direction only
+ ),
+ label = "shimmer-phase",
+ )
+
+ // SrcAtop masks to the content only when that content is drawn into an isolated
+ // buffer; without an offscreen layer the band would paint across the whole bounding
+ // rect (including the gaps between cards), instead of just the drawn placeholders.
+ return this
+ .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen }
+ // `phase` is read inside the draw lambda → only the draw phase re-runs per frame,
+ // not recomposition.
+ .drawWithContent {
+ drawContent() // 1) draw the real placeholders first
+ val w = size.width
+ // base → highlight → base band, 2× width, translated by (phase*3 - 2)*w.
+ val startX = (phase * BAND_TRAVEL_MULTIPLIER - BAND_WIDTH_MULTIPLIER) * w
+ val brush = Brush.linearGradient(
+ colors = listOf(palette.base, palette.highlight, palette.base),
+ start = Offset(startX, 0f),
+ end = Offset(startX + w * BAND_WIDTH_MULTIPLIER, 0f),
+ )
+ // 2) paint the band ONLY over already-drawn pixels → clips the sweep to the content.
+ drawRect(brush = brush, blendMode = BlendMode.SrcAtop)
+ }
+}
+
+/**
+ * Compose has no direct `accessibilityReduceMotion`. Honour the system "remove animations"
+ * setting instead: when animations are scaled to 0, skip the sweep (static placeholders).
+ */
+@Composable
+internal fun animationsEnabled(): Boolean {
+ val context = LocalContext.current
+ val scale = Settings.Global.getFloat(
+ context.contentResolver,
+ Settings.Global.ANIMATOR_DURATION_SCALE,
+ 1f,
+ )
+ return scale != 0f
+}
diff --git a/universal_components/src/main/java/com/auth0/universalcomponents/presentation/ui/components/skeleton/SkeletonCards.kt b/universal_components/src/main/java/com/auth0/universalcomponents/presentation/ui/components/skeleton/SkeletonCards.kt
new file mode 100644
index 0000000..91660ad
--- /dev/null
+++ b/universal_components/src/main/java/com/auth0/universalcomponents/presentation/ui/components/skeleton/SkeletonCards.kt
@@ -0,0 +1,80 @@
+package com.auth0.universalcomponents.presentation.ui.components.skeleton
+
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import com.auth0.universalcomponents.theme.Auth0Theme
+
+private val AuthMethodCardHeight = 70.dp
+private val AuthMethodTitleWidth = 140.dp
+
+/**
+ * Repeats a skeleton [row] [count] times. Intentionally does NOT shimmer itself — apply
+ * Modifier.shimmer() ONCE on the screen's loading container (see the screens) so the header
+ * line and every card animate in a single synchronized sweep.
+ */
+@Composable
+internal fun SkeletonList(
+ count: Int = 5,
+ modifier: Modifier = Modifier,
+ spacing: Dp = Auth0Theme.dimensions.spacingMd,
+ row: @Composable () -> Unit,
+) {
+ Column(
+ modifier = modifier,
+ verticalArrangement = Arrangement.spacedBy(spacing),
+ ) {
+ repeat(count) { row() }
+ }
+}
+
+/**
+ * Border-only card shell matching the real cards' size, border, and radius (NO fill —
+ * a grey sweep over the near-white `backgroundLayerMedium` fill would read as a dark band
+ * crossing a white card). Size, border, and radius still match the real card so there's no
+ * layout shift when data arrives. Parametrised because the two real cards differ in
+ * height/padding.
+ */
+@Composable
+internal fun SkeletonCard(
+ height: Dp,
+ contentPadding: Dp,
+ verticalAlignment: Alignment.Vertical = Alignment.CenterVertically,
+ content: @Composable RowScope.() -> Unit,
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(height)
+ .border(1.dp, Auth0Theme.colors.borderDefault, Auth0Theme.shapes.large)
+ .padding(contentPadding),
+ verticalAlignment = verticalAlignment,
+ content = content,
+ )
+}
+
+/** Mirrors AuthenticatorItem: leading icon, title line, trailing chevron. */
+@Composable
+internal fun AuthMethodCardSkeleton() = SkeletonCard(
+ height = AuthMethodCardHeight,
+ contentPadding = Auth0Theme.sizes.padding, // 16.dp
+) {
+ SkeletonBox(Modifier.size(Auth0Theme.sizes.iconMedium), shape = Auth0Theme.shapes.small)
+ Spacer(Modifier.width(Auth0Theme.dimensions.spacingMd))
+ SkeletonLine(width = AuthMethodTitleWidth)
+ Spacer(Modifier.weight(1f))
+ SkeletonBox(Modifier.size(Auth0Theme.sizes.iconMedium), shape = Auth0Theme.shapes.small)
+}
diff --git a/universal_components/src/main/java/com/auth0/universalcomponents/presentation/ui/components/skeleton/SkeletonShape.kt b/universal_components/src/main/java/com/auth0/universalcomponents/presentation/ui/components/skeleton/SkeletonShape.kt
new file mode 100644
index 0000000..3180186
--- /dev/null
+++ b/universal_components/src/main/java/com/auth0/universalcomponents/presentation/ui/components/skeleton/SkeletonShape.kt
@@ -0,0 +1,35 @@
+package com.auth0.universalcomponents.presentation.ui.components.skeleton
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+/** Default height of a single text-line placeholder. */
+private val SkeletonLineHeight = 14.dp
+
+/**
+ * A single themed placeholder block — the atom of the skeleton system. Compose several
+ * inside Rows/Columns to mirror a real layout, then wrap the parent in Modifier.shimmer().
+ * Renders the resting base grey; the sweep is layered on by the parent's shimmer().
+ */
+@Composable
+internal fun SkeletonBox(
+ modifier: Modifier = Modifier,
+ shape: Shape = RoundedCornerShape(8.dp),
+) {
+ val base = rememberSkeletonPalette().base
+ Box(modifier.background(color = base, shape = shape))
+}
+
+/** Convenience: a single text-line placeholder (default 14.dp tall). */
+@Composable
+internal fun SkeletonLine(width: Dp, modifier: Modifier = Modifier) {
+ SkeletonBox(modifier.width(width).height(SkeletonLineHeight))
+}
diff --git a/universal_components/src/main/java/com/auth0/universalcomponents/presentation/ui/mfa/AuthenticatorMethodsScreen.kt b/universal_components/src/main/java/com/auth0/universalcomponents/presentation/ui/mfa/AuthenticatorMethodsScreen.kt
index 10eda46..ab121e5 100644
--- a/universal_components/src/main/java/com/auth0/universalcomponents/presentation/ui/mfa/AuthenticatorMethodsScreen.kt
+++ b/universal_components/src/main/java/com/auth0/universalcomponents/presentation/ui/mfa/AuthenticatorMethodsScreen.kt
@@ -3,7 +3,9 @@ package com.auth0.universalcomponents.presentation.ui.mfa
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
@@ -15,6 +17,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.auth0.universalcomponents.R
@@ -22,6 +27,10 @@ import com.auth0.universalcomponents.di.UniversalComponentsModule
import com.auth0.universalcomponents.presentation.ui.components.CircularLoader
import com.auth0.universalcomponents.presentation.ui.components.ErrorHandler
import com.auth0.universalcomponents.presentation.ui.components.TopBar
+import com.auth0.universalcomponents.presentation.ui.components.skeleton.AuthMethodCardSkeleton
+import com.auth0.universalcomponents.presentation.ui.components.skeleton.SkeletonLine
+import com.auth0.universalcomponents.presentation.ui.components.skeleton.SkeletonList
+import com.auth0.universalcomponents.presentation.ui.components.skeleton.shimmer
import com.auth0.universalcomponents.presentation.ui.mfa.authenticatormethods.PrimaryAuthenticatorListScreen
import com.auth0.universalcomponents.presentation.ui.mfa.authenticatormethods.SecondaryAuthenticatorListScreen
import com.auth0.universalcomponents.presentation.ui.passkeys.PasskeyEvent
@@ -106,11 +115,19 @@ internal fun AuthenticatorMethodsScreen(
}
AuthenticatorUiState.Loading -> {
- Box(
- Modifier.fillMaxSize(),
- contentAlignment = Alignment.Center
+ // Skeleton mirrors the loaded layout → no layout shift when data arrives.
+ // One shimmer on the container = one synchronized sweep over header + cards.
+ val loadingDescription = stringResource(R.string.loading)
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(vertical = dimensions.spacingMd)
+ .shimmer()
+ .semantics { contentDescription = loadingDescription },
) {
- CircularLoader()
+ SkeletonLine(width = 180.dp, modifier = Modifier.height(22.dp))
+ Spacer(Modifier.height(dimensions.spacingMd))
+ SkeletonList(count = 5) { AuthMethodCardSkeleton() }
}
}
diff --git a/universal_components/src/main/res/values/strings.xml b/universal_components/src/main/res/values/strings.xml
index 8d017c5..f43ca8a 100644
--- a/universal_components/src/main/res/values/strings.xml
+++ b/universal_components/src/main/res/values/strings.xml
@@ -6,6 +6,7 @@
Copy as Code
Active
Remove
+ Loading
Add Phone for SMS OTP