diff --git a/composeApp/src/commonMain/kotlin/ch/parano/myicek/ui/components/EmptyState.kt b/composeApp/src/commonMain/kotlin/ch/parano/myicek/ui/components/EmptyState.kt
new file mode 100644
index 0000000..89c5a0c
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/ch/parano/myicek/ui/components/EmptyState.kt
@@ -0,0 +1,67 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// MyIce Kotlin Multiplatform — schedule/convocation viewer
+// Copyright (C) 2026 parano.ch
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+package ch.parano.myicek.ui.components
+
+import androidx.compose.foundation.layout.Arrangement
+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.size
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.dp
+import ch.parano.myicek.ui.theme.Dimens
+
+@Composable
+fun EmptyState(
+ icon: ImageVector,
+ title: String,
+ message: String,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ modifier = Modifier.size(48.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(Modifier.height(Dimens.md))
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ Spacer(Modifier.height(Dimens.sm))
+ Text(
+ text = message,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/ch/parano/myicek/ui/components/ErrorState.kt b/composeApp/src/commonMain/kotlin/ch/parano/myicek/ui/components/ErrorState.kt
new file mode 100644
index 0000000..a67e9ed
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/ch/parano/myicek/ui/components/ErrorState.kt
@@ -0,0 +1,73 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// MyIce Kotlin Multiplatform — schedule/convocation viewer
+// Copyright (C) 2026 parano.ch
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+package ch.parano.myicek.ui.components
+
+import androidx.compose.foundation.layout.Arrangement
+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.size
+import androidx.compose.material3.Button
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.dp
+import ch.parano.myicek.ui.theme.Dimens
+
+@Composable
+fun ErrorState(
+ icon: ImageVector,
+ title: String,
+ message: String,
+ onRetry: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ modifier = Modifier.size(48.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(Modifier.height(Dimens.md))
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ Spacer(Modifier.height(Dimens.sm))
+ Text(
+ text = message,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(Modifier.height(Dimens.lg))
+ Button(onClick = onRetry) {
+ Text("Réessayer")
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/ch/parano/myicek/ui/components/SkeletonCard.kt b/composeApp/src/commonMain/kotlin/ch/parano/myicek/ui/components/SkeletonCard.kt
new file mode 100644
index 0000000..db224a6
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/ch/parano/myicek/ui/components/SkeletonCard.kt
@@ -0,0 +1,99 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// MyIce Kotlin Multiplatform — schedule/convocation viewer
+// Copyright (C) 2026 parano.ch
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+package ch.parano.myicek.ui.components
+
+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.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+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.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import ch.parano.myicek.ui.theme.Dimens
+
+@Composable
+fun SkeletonCard(modifier: Modifier = Modifier) {
+ val transition = rememberInfiniteTransition(label = "shimmer")
+ val alpha by transition.animateFloat(
+ initialValue = 0.3f,
+ targetValue = 0.6f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(800),
+ repeatMode = RepeatMode.Reverse,
+ ),
+ label = "shimmerAlpha",
+ )
+
+ val skeletonColor = MaterialTheme.colorScheme.surfaceVariant
+ val skeletonModifier = Modifier
+ .alpha(alpha)
+ .background(skeletonColor, RoundedCornerShape(4.dp))
+
+ Box(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(horizontal = Dimens.lg, vertical = Dimens.sm)
+ .clip(MaterialTheme.shapes.medium)
+ .background(MaterialTheme.colorScheme.surface)
+ .padding(Dimens.lg),
+ ) {
+ Column(verticalArrangement = Arrangement.spacedBy(Dimens.sm)) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Box(modifier = Modifier
+ .height(20.dp)
+ .width(160.dp)
+ .then(skeletonModifier))
+ Box(modifier = Modifier
+ .size(28.dp)
+ .clip(CircleShape)
+ .then(skeletonModifier))
+ }
+ Box(modifier = Modifier
+ .height(16.dp)
+ .width(200.dp)
+ .then(skeletonModifier))
+ Box(modifier = Modifier
+ .height(16.dp)
+ .width(180.dp)
+ .then(skeletonModifier))
+ }
+ }
+}