Los estados que shadcn/ui te deja a ti: carga, éxito, error, vacío y movimiento reducido
shadcn/ui te da un botón limpio, no un botón de carga. Los estados difíciles de la interfaz que todo proyecto de shadcn implementa a mano, con los tiempos que hacen que se sientan bien.
Preet Suthar · · 5 min de lectura
- React
- shadcn/ui
- UX
- Accessibility
Busca "shadcn button loading state" y encontrarás cien fragmentos que hacen lo mismo: añadir una prop isLoading, cambiar la etiqueta por un spinner, deshabilitar el botón. Publica eso y tu app se sentirá peor que antes de añadirlo.
No es una crítica a shadcn/ui. Te da un punto de partida limpio y te deja los estados a ti, a propósito. Pero "los estados" son donde vive la mayor parte de la sensación de una interfaz, y casi nadie reserva tiempo para ellos. Esta es la lista de estados que manejo en cada componente que publico, y los tiempos concretos que separan "terminado" de "funciona".
1. Carga que no parpadea
El botón de carga ingenuo muestra un spinner en el instante en que haces clic. La mayoría de las peticiones terminan en menos de 200ms, así que lo que el usuario ve es un destello de spinner y luego el resultado. Se lee como un tembleque.
Dos reglas lo arreglan:
- Espera antes de mostrar el spinner. Si la petición termina en unos 150ms, no lo muestres nunca.
- Una vez mostrado, mantenlo un tiempo mínimo. Unos 400ms. Un spinner que aparece durante 30ms es peor que ningún spinner.
Eso es un hook, no una prop. Esta es la forma:
track recibe la promesa. El hook decide si la petición fue lo bastante rápida como para saltarse el spinner, mantiene el spinner el tiempo suficiente para que no parpadee y pasa el botón a éxito o error cuando la promesa se resuelve. El hook useButtonFeedback de HextaUI y useDelayedLoading hacen exactamente esto, y son lo bastante pequeños como para leerlos de una sentada.
La misma regla del retardo se aplica a los skeletons. Un skeleton que aparece durante 50ms y es sustituido por el contenido es un destello gris. Retrásalo 150ms y, cuando llegue el contenido, hazlo aparecer con un fundido sin mover nada a su alrededor.
2. Éxito y error, en el sitio
Cuando un guardado tiene éxito, el botón debería decirlo. "Saved" con una marca de verificación, durante unos dos segundos, y luego de vuelta al reposo. Cuando falla, el botón también debería decirlo, y mantener el error más tiempo, unos cuatro segundos, porque la gente necesita tiempo para leer un error y decidir qué hacer.
Dos detalles que importan más de lo que parece:
- No reinicies mientras el puntero está sobre el botón. Si alguien lo está sobrevolando, está leyendo. Reinicia cuando se vaya.
- Ignora los clics mientras la petición está en curso. No con
disabled, que saca el botón del orden de tabulación y hace que los lectores de pantalla se lo salten. Usaaria-busye ignora el clic.
Un Switch tiene el mismo problema en menos espacio. Cámbialo, envía la petición y, si la petición falla, devuélvelo a su sitio. El usuario necesita ver la reversión, así que anímala, y muestra un breve estado pendiente mientras el guardado está en camino.
3. Inválido, sin castigar al escribir
Sacude el campo cuando un intento de envío lo encuentra inválido. Nunca lo sacudas mientras alguien sigue escribiendo. La regla es el disparador: la validación al enviar o al perder el foco recibe la sacudida, la validación al cambiar no.
La sacudida en sí debería ser corta, tres o cuatro oscilaciones en unos 300ms, y con prefers-reduced-motion se convierte en un cambio de color en el borde y nada más.
El :user-invalid nativo ayuda aquí. Solo coincide después de que el usuario haya interactuado, así que puedes dar estilo al estado inválido en CSS sin un flag touched en React.
4. Vacío es un estado, no una ausencia
Una lista vacía es donde aterrizan primero los usuarios nuevos, y lo que viene por defecto es un área en blanco con una cabecera encima. Todo estado vacío necesita tres cosas: un icono o ilustración para que el espacio se lea como intencionado, una frase que diga qué aparecerá aquí y la acción que hace que aparezca algo.
Esto es barato de construir y casi siempre se salta porque la persona que construye la función tiene datos de prueba. Vacía tu base de datos de vez en cuando.
5. Movimiento reducido no es "sin movimiento"
prefers-reduced-motion: reduce está activo para una parte significativa de los usuarios, y la implementación perezosa es un * { transition: none } global. Eso elimina la información que el movimiento transportaba. Un diálogo que se abre de golpe sin fundido es más difícil de interpretar que uno que se funde.
La regla que uso: todo lo que se desplaza por la pantalla se convierte en un fundido. Todo lo que se funde sigue siendo un fundido. Todo lo que hace bucle se detiene. Las duraciones se acortan, no se ponen a cero.
Pon la comprobación en un solo sitio para que todos los componentes la lean:
Y comparte también las curvas de easing y las duraciones. Si cada componente elige su propio cubic-bezier, la app se sentirá como si la hubieran construido seis personas, porque así fue.
6. Movimiento que puedes interrumpir
Abre un acordeón y ciérralo a mitad de la animación de apertura. En muchas implementaciones termina de abrirse y luego se cierra, o da un salto. El comportamiento correcto es invertir desde donde esté.
La Web Animations API lo pone fácil: guarda una referencia a la animación en curso y, en el siguiente cambio, lee su tiempo actual y anima desde la altura calculada actual. Las transiciones CSS sobre height: auto no pueden hacerlo, y grid-template-rows: 0fr a 1fr puede, pero es más difícil de invertir a mitad de camino. Elige el enfoque que te permita interrumpir.
7. Áreas táctiles y el zoom de iOS
Dos cosas se rompen en los móviles que nunca verás en un portátil:
- Los inputs con menos de 16px hacen zoom en la página en iOS Safari. Mantén el texto de los inputs a 16px en punteros gruesos aunque tu tema use 15px en el resto. Una sola regla
@media (pointer: coarse)lo cubre. - Las áreas táctiles de menos de 44px fallan. Los botones pueden medir 32px de alto en escritorio y crecer en pantallas táctiles mediante la misma media query.
8. De derecha a izquierda
Si alguna vez publicas para usuarios de árabe o hebreo, cada ml-, pl- y left- de tus componentes es un bug. Usa propiedades lógicas: ms-, ps-, start-. shadcn/ui añadió soporte RTL en enero de 2026, así que los componentes nuevos que traigas están bien, pero los que copiaste en 2024 no.
Dónde te deja esto
Ninguno de estos es difícil. Todos juntos son unas semanas de trabajo en un conjunto de componentes del tamaño de shadcn/ui, y el trabajo es invisible cuando está hecho, que es por lo que se recorta.
Construí HextaUI porque seguía haciendo este trabajo en cada proyecto. Es un registro gratuito con licencia MIT que añades a un proyecto de shadcn/ui. Misma CLI, mismos nombres de componentes, mismos tokens de tema, y todos los estados anteriores ya resueltos, hasta el retardo de 150ms del spinner. Si prefieres ser dueño del código y escribir los estados tú mismo, los hooks son independientes y puedes instalar solo esos.
Preguntas frecuentes
¿Debería deshabilitar un botón mientras carga? Pon aria-busy="true" e ignora los clics. disabled lo saca del orden de tabulación y lo oculta a los lectores de pantalla en mitad de la acción.
¿Cuánto tiempo debería mostrarse un estado de éxito? Unos dos segundos, más si el puntero sigue sobre el control. Los errores, unos cuatro segundos.
¿Un skeleton siempre es mejor que un spinner? Un skeleton es mejor cuando conoces la forma de lo que viene. Un spinner es mejor para acciones. Los dos necesitan el retardo de 150ms.
¿Movimiento reducido significa que debo quitar toda la animación? No. Sustituye el desplazamiento por fundidos, detén los bucles y acorta las duraciones. Conserva la información.