Component · Skeleton Loader

Skeleton Loader

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).

Loading state shimmer · 1500ms · Curves.linear crossfade 200ms · Curves.fastOutSlowIn reduced-motion: pulse
Anatomy

Forma base y transición

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.

01Forma base

Rectángulo redondeado con relleno --color-neutral-200 en light · --color-neutral-800 (con alpha) en dark. Sobre él, el shimmer del foundation §3d.

Fill light
--color-neutral-200 · #E4ECEB
Fill dark
rgba(228, 236, 235, 0.08)
Radius default
8dp · alineado con tamaño del contenido
Animación
Shimmer ref. foundations-motion §3d

02Transición a contenido real

Crossfade simultáneo — el skeleton hace fade-out mientras el dato real hace fade-in. Sin overlap de movimiento; solo opacidad. Sin delay.

Saldo total
$2.483.500
skeleton ↔ real · 200ms loop
Duración
duration-default · 200ms
Easing
ease-standard · Curves.fastOutSlowIn
Widget
AnimatedSwitcher con cambio de key al llegar data
Movimiento
Solo opacidad — sin slide, sin scale
Variants

Variantes por tipo de contenido

Cada 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.

aText
h12dp (caption) · h16dp (body) · w 40-100%
Líneas de copy. Variar anchos 40 / 60 / 80 / 100% dentro de un párrafo — el contenido real no es una tabla. Radius 4dp.
bHeading
h20dp · w 60%
Títulos de sección, nombres de pantalla en saludos ("Hola, ___"). Radius 4dp.
cMoney amount
h32dp · w120dp
El skeleton más frecuente en MiCapi. Saldos y precios — balance card, fila de activo, confirmation sheet. Radius 6dp.
dMoney amount · large
h44dp · w180dp
El hero amount: saldo principal del portafolio en el dashboard, precio actual en el detalle de activo. Radius 8dp.
eIcon / Avatar
40dp circle · 24dp inline
Iconos de activo (BTC, ETH, USDT) en listas y cards. Variante 24dp para iconos inline.
fCard
w100% · h~120dp
Para el balance card cargando. Padding interno 16dp + gap 8dp, idéntico al card real. Radius 12dp.
gList item
h64dp · row
Fila de transacción · fila de activo. Avatar 40dp + 2 líneas (nombre 60% + subtítulo 40%) + monto a la derecha. Gap interno 12dp.
hChart
w100% · h~160dp
Bloque uniforme — el chart se carga como pieza completa, no se intenta dibujar la línea con skeleton. Radius 12dp.
iButton
h48dp · w~140dp
CTAs cuyo label depende de data: "Comprar a $458.320k". Mientras carga el precio, el botón es skeleton para no mentir.
Compositions

Composiciones en pantalla

Tres pantallas reales cargando. Frames a 360dp con bottom-nav y top-bar renderizados normales — la navegación nunca es skeleton.

a · Dashboard cargandolight
9:41
Saldo total
Tus activos Ver todo
Recientes
Bottom nav y el ícono de notificaciones no son skeleton — están siempre disponibles.
b · Historial cargandolight
9:41
Historial
Top app bar y acciones (back, search, filter) renderizadas completas. Anchos de las líneas varían entre filas.
c · Detalle Bitcoin cargandolight
9:41
Bitcoin
BTC · COP
Volumen 24h
Cap. mercado
Máximo 24h
Mínimo 24h
Labels de stats (Volumen 24h, etc.) son literales estáticos — no son skeleton. Solo el valor.
a · Dashboard cargandodark
9:41
Saldo total
Tus activos Ver todo
Recientes
Misma jerarquía, misma composición — solo cambia el relleno del skeleton (alpha 8% sobre superficie oscura) y el shimmer (alpha 14% sobre alpha 0).
States

Tres estados

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".

DEFAULT

Shimmer activo

Saldo total

Loop continuo, gradiente recorriendo de izquierda a derecha. 1500ms · Curves.linear. Todos los skeletons en pantalla comparten el mismo AnimationController para estar en sync.

REDUCED MOTION

Pulso de opacidad

Saldo total

Cuando MediaQuery.disableAnimationsOf(context) es true: el shimmer lateral desaparece y el rectángulo pulsa opacidad 0.6 ↔ 1.0 cada 2000ms · ease-standard.

TRANSITION

Crossfade → real

Saldo total
Saldo total
$2.483.500
≈ 0,0204 BTC

AnimatedSwitcher con duration: 200ms + ease-standard. Sin slide, sin scale — solo opacidad. La key cambia cuando llega el dato.

01 · skeleton
02 · crossfade
03 · real
Spacing

Layout idéntico al contenido real

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.

SKELETON

Balance card cargando

Saldo total
padding: 16dp · gap: 8dp · radius: 12dp
REAL

Balance card con data

Saldo total
$2.483.500
≈ 0,0204 BTC
Mismos padding, gap y radius. Solo cambia el contenido del slot.
Not a skeleton

Qué no se debe skeletonear

Hay UI que siempre se renderiza completa, incluso mientras el resto carga. Si un elemento no depende de la data, no es skeleton.

Bottom navigation

Las pestañas (Inicio, Operar, Historial, Cuenta) siempre se renderizan. El usuario debe poder navegar incluso si la pantalla está cargando.

Top app bar con título estático

"Historial", "Bitcoin", "Mi cuenta" — el título de la pantalla es un literal, no depende de la API. Se muestra completo.

Acciones sin data dependency

Botón "+" de nueva operación, ícono de notificaciones, ícono de búsqueda. Su existencia no depende de qué devuelve el backend.

Empty states

"Aún no tienes transacciones" no es skeleton — es un componente distinto. El usuario no está esperando data; no hay data que esperar.

Implementation

Notas de implementación Flutter

Patrón de implementación

Widgets base

Crear SkeletonBox(width, height, borderRadius) y SkeletonCircle(diameter). Las composiciones se arman apilando estos con Row / Column / Padding — mismos paddings que el widget real.

Controller compartido

Todos los skeletons visibles comparten el mismo AnimationController. Provisto por un InheritedWidget arriba en el árbol — no crear un controller por skeleton.

Shimmer

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().

Transición a data real

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)),
)