Placeholders animados que aparecen mientras la app espera datos reales — saldos cripto, precios, listas de transacciones. La animación es el shimmer pattern definido en foundations-motion §3d: loop continuo de 1500ms con Curves.linear, fallback de pulso de opacidad si MediaQuery.disableAnimationsOf(context).
Un skeleton es una aproximación geométrica del contenido que va a reemplazar. Si la forma del skeleton no se parece a la del dato real, el crossfade salta visualmente — esto es lo más importante de la spec.
Rectángulo redondeado con relleno --color-neutral-200 en light · --color-neutral-800 (con alpha) en dark. Sobre él, el shimmer del foundation §3d.
--color-neutral-200 · #E4ECEBfoundations-motion §3dCrossfade simultáneo — el skeleton hace fade-out mientras el dato real hace fade-in. Sin overlap de movimiento; solo opacidad. Sin delay.
duration-default · 200msease-standard · Curves.fastOutSlowInAnimatedSwitcher con cambio de key al llegar dataCada variante tiene dimensiones proporcionales al contenido real. No son rectángulos genéricos. Usar siempre la variante que mejor aproxime la forma de lo que reemplaza.
Tres pantallas reales cargando. Frames a 360dp con bottom-nav y top-bar renderizados normales — la navegación nunca es skeleton.
Volumen 24h, etc.) son literales estáticos — no son skeleton. Solo el valor.Shimmer activo es el default. Reduced motion reemplaza el gradiente lateral por un pulso de opacidad. El crossfade es la salida hacia el dato real — sin él, el skeleton hace "pop".
Loop continuo, gradiente recorriendo de izquierda a derecha. 1500ms · Curves.linear. Todos los skeletons en pantalla comparten el mismo AnimationController para estar en sync.
Cuando MediaQuery.disableAnimationsOf(context) es true: el shimmer lateral desaparece y el rectángulo pulsa opacidad 0.6 ↔ 1.0 cada 2000ms · ease-standard.
AnimatedSwitcher con duration: 200ms + ease-standard. Sin slide, sin scale — solo opacidad. La key cambia cuando llega el dato.
El padding, los gaps y los márgenes del skeleton deben ser los mismos que los del contenido real. Cualquier desalineación se convierte en un salto visual durante el crossfade.
padding: 16dp · gap: 8dp · radius: 12dp
padding, gap y radius. Solo cambia el contenido del slot.
Hay UI que siempre se renderiza completa, incluso mientras el resto carga. Si un elemento no depende de la data, no es skeleton.
Las pestañas (Inicio, Operar, Historial, Cuenta) siempre se renderizan. El usuario debe poder navegar incluso si la pantalla está cargando.
"Historial", "Bitcoin", "Mi cuenta" — el título de la pantalla es un literal, no depende de la API. Se muestra completo.
Botón "+" de nueva operación, ícono de notificaciones, ícono de búsqueda. Su existencia no depende de qué devuelve el backend.
"Aún no tienes transacciones" no es skeleton — es un componente distinto. El usuario no está esperando data; no hay data que esperar.
Crear SkeletonBox(width, height, borderRadius) y SkeletonCircle(diameter). Las composiciones se arman apilando estos con Row / Column / Padding — mismos paddings que el widget real.
Todos los skeletons visibles comparten el mismo AnimationController. Provisto por un InheritedWidget arriba en el árbol — no crear un controller por skeleton.
ShaderMask sobre un Container con color de relleno. El LinearGradient se anima vía un Animation<double> que controla el offset del gradiente. AnimationController(duration: 1500ms)..repeat().
AnimatedSwitcher(duration: Duration(milliseconds: 200), child: ...). El key del child debe cambiar cuando llega la data — si la key no cambia, el switcher no anima.
// Esqueleto base. Lee el controller del SkeletonScope ancestro. class SkeletonBox extends StatelessWidget { final double width, height; final BorderRadius radius; const SkeletonBox({this.width, this.height, this.radius}); Widget build(BuildContext context) { final reduceMotion = MediaQuery.disableAnimationsOf(context); final controller = SkeletonScope.of(context).controller; return AnimatedBuilder( animation: controller, builder: (_, __) => Container( width: width, height: height, decoration: BoxDecoration( color: SkeletonColors.of(context).fill, borderRadius: radius, ), // Si reduceMotion: pulso de opacidad. Si no: ShaderMask con gradiente animado. child: reduceMotion ? Opacity(opacity: 0.6 + 0.4 * controller.value, child: ...) : ShaderMask(shaderCallback: ..., child: ...), ), ); } } // Crossfade al llegar la data: AnimatedSwitcher( duration: const Duration(milliseconds: 200), switchInCurve: Curves.fastOutSlowIn, child: balance == null ? SkeletonBox(key: ValueKey('loading'), width: 180, height: 44) : Text(formatCop(balance), key: ValueKey(balance)), )