Retour au catalogue

Maintenance Mode

Banniere plein ecran de maintenance avec compte a rebours anime et illustration de construction.

bannersmedium Both Responsive a11y
minimalcorporateuniversalcentered
Theme

How to build a full-screen maintenance page with countdown in React

A React maintenance page with countdown stores the remaining seconds in useState, ticks it down with setInterval inside useEffect, and formats hours/minutes/seconds with padStart. Framer Motion handles the fade-in entry and the repeating wrench wobble animation.

  • Stack: React 18 + Framer Motion 11 + Lucide React, ~200 lines, zero extra dependencies.
  • Timer: native setInterval via useEffect, cleaned up on unmount, no third-party date lib.
  • Background: two Settings icons (Lucide) rotate continuously at 3% opacity using Framer Motion's animate prop.
  • Accessible: aria-hidden on decorative gears; countdown cells use tabular-nums for stable digit width.
  • Fully responsive, layout is centered flex, countdown uses inline-flex with gap; works at any viewport width.

This component is a full-screen maintenance page designed to replace your entire app while work is in progress. It shows a live countdown (configurable duration in minutes), a repeating wrench animation, spinning gear decorations, and a mailto contact link. The whole card animates in with a spring ease so the page feels polished even when the product is down.

Anatomy

The layout is a single centered column inside a full-viewport section. At the top, a square icon box holds the Wrench icon with a repeating wobble animation. Below it sit the title and subtitle text. The countdown block renders three card-style cells (hours, minutes, seconds) as an inline-flex row. An estimated return line with a Clock icon follows, then an optional list of features being updated rendered as pill tags, and finally a mailto anchor with a Mail icon at the bottom.

How it works

The countdown derives from a single piece of state: `remaining` (total seconds). A useEffect starts an interval that decrements it by 1 every 1000ms and clears itself when the component unmounts or when remaining reaches 0. Hours, minutes, and seconds are computed from the remaining value on each render with integer division and modulo, then padded to two digits with String.padStart. The entry animation uses Framer Motion's `useInView` hook with `once: true` so the content fades up from y:30 only the first time the section enters the viewport. The background gears use `animate={{ rotate: 360 }}` with `repeat: Infinity` and `ease: "linear"` for smooth continuous rotation at opposite speeds (20s / 30s).

How to build it in React

  1. Set up the countdown state and interval

    Initialize `remaining` in useState with the total seconds (minutes × 60). In a useEffect, start a setInterval that decrements the state by 1 each second. Return a cleanup function that calls clearInterval so the timer stops when the component unmounts.

    const [remaining, setRemaining] = useState(countdownMinutes * 60);
    
    useEffect(() => {
      const interval = setInterval(() => {
        setRemaining((prev) => (prev > 0 ? prev - 1 : 0));
      }, 1000);
      return () => clearInterval(interval);
    }, []);
  2. Format the time units

    Derive hours, minutes, and seconds from the remaining value using integer division and modulo. Pad each to two characters with padStart so the display width stays stable as digits change.

    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");
  3. Animate the entry with useInView

    Attach a ref to the outer container and pass it to Framer Motion's useInView with `once: true`. Drive `animate` on the content wrapper: when inView, fade from opacity 0 and y 30 to the final state. The countdown block gets a slight scale-up with a 0.2s delay for a staggered feel.

    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] }}
    >
  4. Add the rotating background gears

    Place two absolutely-positioned divs with aria-hidden and pointer-events:none inside the section. Wrap each Settings icon in a motion.div with `animate={{ rotate: 360 }}` (one counterclockwise with -360). Use different durations and a global opacity of 0.03 so they read as texture without competing with the content.

    <motion.div
      animate={{ rotate: 360 }}
      transition={{ duration: 20, repeat: Infinity, ease: "linear" }}
      aria-hidden
    >
      <Settings style={{ opacity: 0.03 }} />
    </motion.div>

When to use it

Use this component when you need to take down your entire application for scheduled maintenance and want users to know when it will return. It suits SaaS products, internal tools, and e-commerce sites during deployments or database migrations. Skip it for partial outages or feature flags, a toast or top banner is less disruptive in those cases. Also skip the countdown if you genuinely cannot predict when the work will finish: showing a timer that runs past zero erodes trust faster than no timer at all.

Used by

  • GitHub, Displays a dedicated status page with estimated resolution times during incidents and scheduled maintenance.
  • Shopify, Uses full-page maintenance screens with countdown timers during major platform upgrades.
  • Notion, Replaces the app with a maintenance page and posts estimated return times during scheduled downtime.

FAQ

How do I stop the countdown at zero instead of going negative?

The updater function guards against it: `(prev) => (prev > 0 ? prev - 1 : 0)`. The interval keeps running but the state clamps at 0, so the display freezes at 00:00:00 without needing an extra clearInterval call.

Can I persist the countdown across page refreshes?

Store the target end timestamp (Date.now() + duration) in localStorage on mount, then derive remaining from (endTime - Date.now()) / 1000 each tick instead of decrementing a local counter. This survives refreshes and multiple browser tabs.

Why tabular-nums on the countdown digits?

Proportional fonts give different widths to '1' and '8', so the countdown cells shift horizontally every second. Setting font-variant-numeric: tabular-nums makes each digit occupy the same horizontal space, keeping the layout stable.

Should this replace the entire app or sit on top of it?

For real maintenance it should replace the entire routing tree, either by setting an environment variable that short-circuits the Next.js layout at the root, or by deploying a static version of this page to your CDN. Rendering it on top of the app still loads all your JS bundles, which defeats the purpose during a database migration.

"use client";

import { useEffect, useState, useRef } from "react";
import { motion, useInView } from "framer-motion";
import { Wrench, Clock, Mail, Settings, RefreshCw } from "lucide-react";

interface BannerMaintenanceModeProps {
  title?: string;
  subtitle?: string;
  contactEmail?: string;
  estimatedReturn?: string;
  features?: string[];
  countdownMinutes?: number;
}

const EASE: [number, number, number, number] = [0.16, 1, 0.3, 1];

export default function BannerMaintenanceMode({
  title = "Maintenance en cours",
  subtitle = "Nous ameliorons votre experience.",
  contactEmail = "[email protected]",
  estimatedReturn = "Retour prevu dans environ 2 heures",

Code complet réservé à Pro

Code source intégral, export multi-framework et playground.

Passer en Pro, 9,99€/mois

Reviews

React Maintenance Page with Live Countdown, Code + Tutorial