Cómo crear una página de mantenimiento a pantalla completa con cuenta atrás en React
Una página de mantenimiento React con cuenta atrás guarda los segundos restantes en useState, los reduce con setInterval dentro de useEffect y formatea horas/minutos/segundos con padStart. Framer Motion se encarga de la entrada con fundido y de la animación de vibración repetida de la llave inglesa.
- Stack: React 18 + Framer Motion 11 + Lucide React, ~200 líneas, cero dependencias adicionales.
- Temporizador: setInterval nativo vía useEffect, limpiado al desmontar, sin librería de fechas externa.
- Fondo: dos iconos Settings (Lucide) giran de forma continua al 3% de opacidad usando la prop animate de Framer Motion.
- Accesible: aria-hidden en los engranajes decorativos; las celdas de la cuenta atrás usan tabular-nums para un ancho de dígito estable.
- Totalmente responsive, el layout es un flex centrado, la cuenta atrás usa inline-flex con gap; funciona a cualquier ancho de viewport.
Este componente es una página de mantenimiento a pantalla completa diseñada para reemplazar toda tu aplicación mientras se realizan los trabajos. Muestra una cuenta atrás en vivo (duración configurable en minutos), una animación de llave inglesa repetida, decoraciones de engranajes girando y un enlace de contacto mailto. Toda la tarjeta se anima a la entrada con un ease spring, para que la página resulte cuidada incluso cuando el producto está caído.
Anatomía
El layout es una única columna centrada dentro de una sección a viewport completo. Arriba, un recuadro cuadrado de icono contiene el icono Wrench con una animación de vibración repetida. Debajo se sitúan el título y el subtítulo. El bloque de cuenta atrás renderiza tres celdas estilo card (horas, minutos, segundos) como una fila inline-flex. Le sigue una línea de regreso estimado con el icono Clock, después una lista opcional de funcionalidades en proceso de actualización renderizadas como pill tags y, por último, un ancla mailto con el icono Mail al final.
Cómo funciona
La cuenta atrás deriva de un único estado: `remaining` (segundos totales). Un useEffect arranca un interval que lo reduce en 1 cada 1000ms y se limpia al desmontar el componente o cuando remaining llega a 0. Las horas, minutos y segundos se calculan en cada render a partir del valor remaining con división entera y módulo, luego se rellenan a dos dígitos con String.padStart. La animación de entrada usa el hook `useInView` de Framer Motion con `once: true` para que el contenido suba con fundido desde y:30 solo la primera vez que la sección entra en el viewport. Los engranajes de fondo usan `animate={{ rotate: 360 }}` con `repeat: Infinity` y `ease: "linear"` para una rotación continua y fluida a velocidades opuestas (20s / 30s).
Cómo crearlo en React
Configurar el estado y el interval de la cuenta atrás
Inicializa `remaining` en useState con los segundos totales (minutos × 60). En un useEffect, arranca un setInterval que reduce el estado en 1 cada segundo. Devuelve una función de limpieza que llama a clearInterval para que el temporizador se detenga al desmontar el componente.
const [remaining, setRemaining] = useState(countdownMinutes * 60); useEffect(() => { const interval = setInterval(() => { setRemaining((prev) => (prev > 0 ? prev - 1 : 0)); }, 1000); return () => clearInterval(interval); }, []);Formatear las unidades de tiempo
Deriva las horas, minutos y segundos a partir del valor remaining usando división entera y módulo. Rellena cada uno a dos caracteres con padStart para que el ancho de la pantalla se mantenga estable cuando cambian los dígitos.
const hours = Math.floor(remaining / 3600); const minutes = Math.floor((remaining % 3600) / 60); const seconds = remaining % 60; const pad = (n: number) => String(n).padStart(2, "0");Animar la entrada con useInView
Adjunta una ref al contenedor exterior y pásala a useInView de Framer Motion con `once: true`. Controla `animate` en el wrapper de contenido: cuando inView, pasa de opacity 0 e y 30 al estado final. El bloque de cuenta atrás recibe un ligero scale-up con un retraso de 0.2s para un efecto en cascada.
const ref = useRef<HTMLDivElement>(null); const inView = useInView(ref, { once: true, margin: "-40px" }); <motion.div initial={{ opacity: 0, y: 30 }} animate={inView ? { opacity: 1, y: 0 } : {}} transition={{ duration: 0.7, ease: [0.16, 1, 0.3, 1] }} >Añadir los engranajes de fondo giratorios
Coloca dos divs en posición absolute con aria-hidden y pointer-events:none dentro de la sección. Envuelve cada icono Settings en una motion.div con `animate={{ rotate: 360 }}` (uno en sentido contrario con -360). Usa duraciones diferentes y una opacidad global de 0.03 para que se lean como textura sin competir con el contenido.
<motion.div animate={{ rotate: 360 }} transition={{ duration: 20, repeat: Infinity, ease: "linear" }} aria-hidden > <Settings style={{ opacity: 0.03 }} /> </motion.div>
Cuándo usarlo
Usa este componente cuando necesites desactivar toda tu aplicación por mantenimiento programado y quieras que los usuarios sepan cuándo volverá. Encaja con productos SaaS, herramientas internas y sitios de e-commerce durante despliegues o migraciones de base de datos. Evítalo para caídas parciales o feature flags: un toast o una banner superior resulta menos intrusivo en esos casos. Evita también la cuenta atrás si realmente no puedes predecir cuándo terminarán los trabajos: mostrar un temporizador que pasa de cero erosiona la confianza más rápido que no poner ninguno.
Usado por
- GitHub, Muestra una página de estado dedicada con horas de resolución estimadas durante incidentes y mantenimientos programados.
- Shopify, Usa pantallas de mantenimiento a página completa con temporizadores de cuenta atrás durante las actualizaciones mayores de la plataforma.
- Notion, Reemplaza la app por una página de mantenimiento y publica horas de regreso estimadas durante las interrupciones programadas.
FAQ
¿Cómo detengo la cuenta atrás en cero en lugar de pasar a negativo?
La función de actualización se encarga de ello: `(prev) => (prev > 0 ? prev - 1 : 0)`. El interval sigue corriendo pero el estado se fija en 0, así que la pantalla se congela en 00:00:00 sin necesidad de una llamada extra a clearInterval.
¿Puedo mantener la cuenta atrás entre recargas de página?
Guarda el timestamp de fin objetivo (Date.now() + duración) en localStorage al montar, luego calcula remaining a partir de (endTime - Date.now()) / 1000 en cada tick en lugar de reducir un contador local. Esto sobrevive a las recargas y a múltiples pestañas del navegador.
¿Por qué tabular-nums en los dígitos de la cuenta atrás?
Las fuentes proporcionales asignan anchos distintos a '1' y '8', por lo que las celdas de la cuenta atrás se desplazan horizontalmente cada segundo. Aplicar font-variant-numeric: tabular-nums hace que cada dígito ocupe el mismo espacio horizontal, manteniendo el layout estable.
¿Debe reemplazar toda la app o superponerse sobre ella?
Para un mantenimiento real debe reemplazar todo el árbol de rutas, ya sea definiendo una variable de entorno que cortocircuite el layout de Next.js en la raíz, o desplegando una versión estática de esta página en tu CDN. Renderizarla por encima de la app sigue cargando todos tus bundles de JS, lo que pierde el sentido durante una migración de base de datos.