Elapsed Time Pills on a Running Deploy Pipeline

A deploy status card built around a small reusable timer pill. Each of four pipeline steps has its own pill that counts in seconds and hundredths while the step runs, with a clock icon that turns slowly, then freezes and goes green when the step finishes. A larger pill in the header counts the whole run in minutes and seconds, and a progress bar fills as steps complete. The run can be cancelled, which turns the active pill red, or started again. The card is a single column and keeps the same layout on phones.

Code

// Adapted from Cult UI (https://www.cult-ui.com/docs/components/timer)
// Copyright (c) 2023 Jordan-Gilliam. MIT License: https://we.inc/components/licenses
import { useEffect, useRef, useState } from "react";
import { motion, useReducedMotion } from "framer-motion";
import { Check, Clock, GitBranch, RotateCcw, X } from "lucide-react";

/** Pipeline steps. `seconds` is how long the demo lets each one run. */
const steps = [
  { name: "Install packages", detail: "412 packages from the lockfile", seconds: 1.4 },
  { name: "Type check and lint", detail: "186 files", seconds: 1.9 },
  { name: "Run tests", detail: "94 unit, 12 browser", seconds: 2.6 },
  { name: "Build and upload", detail: "Static pages and 3 functions", seconds: 2.2 },
];

type Format = "SS.MS" | "MM:SS";

const pad = (n: number) => String(n).padStart(2, "0");
function format(ms: number, f: Format) {
  const total = Math.floor(ms / 1000);
  if (f === "MM:SS") return `${pad(Math.floor(total / 60))}:${pad(total % 60)}`;
  return `${pad(total)}.${pad(Math.floor((ms % 1000) / 10))}`;
}

/** Counts up while `running` is true and holds its value when it stops. Updates about 30 times a second. */
function useElapsed(running: boolean, resetKey: number) {
  const [ms, setMs] = useState(0);
  const base = useRef(0);
  useEffect(() => {
    base.current = 0;
    setMs(0);
  }, [resetKey]);
  useEffect(() => {
    if (!running) return;
    const started = performance.now() - base.current;
    let frame = 0;
    let last = 0;
    const tick = (now: number) => {
      if (now - last > 33) {
        last = now;
        base.current = now - started;
        setMs(base.current);
      }
      frame = requestAnimationFrame(tick);
    };
    frame = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(frame);
  }, [running, resetKey]);
  return ms;
}

const pill = {
  default: "border border-slate-200 bg-white text-slate-900 shadow-[inset_0_-2px_0_0_#f1f5f9,0_1px_6px_0_rgba(15,23,42,0.06)]",
  outline: "border border-slate-300 bg-transparent text-slate-700",
  ghost: "bg-transparent text-slate-600",
  success: "border border-emerald-200 bg-emerald-50 text-emerald-800",
  destructive: "border border-red-200 bg-red-50 text-red-700",
};

function TimerPill({ time, variant = "default", spinning = false, label, large = false }: { time: string; variant?: keyof typeof pill; spinning?: boolean; label: string; large?: boolean }) {
  return (
    <span role="timer" aria-label={`${label}: ${time}`} className={`inline-flex items-center rounded-full font-medium transition-colors duration-200 ${large ? "h-9 gap-2.5 px-3.5 text-base" : "h-7 gap-2 px-2.5 text-sm"} ${pill[variant]}`}>
      <Clock className={`${large ? "h-4 w-4" : "h-3.5 w-3.5"} ${spinning ? "animate-spin motion-reduce:animate-none" : ""}`} style={{ animationDuration: "2s" }} aria-hidden="true" />
      <span className="font-mono tabular-nums tracking-tight">{time}</span>
    </span>
  );
}

function StepRow({ step, state, run }: { step: (typeof steps)[number]; state: "waiting" | "running" | "done" | "failed"; run: number }) {
  const ms = useElapsed(state === "running", run);
  const shown = state === "waiting" ? 0 : ms;
  return (
    <li className="flex items-center gap-3 py-3">
      <span className={`grid h-7 w-7 shrink-0 place-items-center rounded-full ${state === "done" ? "bg-emerald-500 text-white" : state === "failed" ? "bg-red-500 text-white" : state === "running" ? "bg-slate-900 text-white" : "bg-slate-100 text-slate-500"}`}>
        {state === "done" ? <Check className="h-4 w-4" aria-hidden="true" /> : state === "failed" ? <X className="h-4 w-4" aria-hidden="true" /> : <span className={`h-2 w-2 rounded-full bg-current ${state === "running" ? "animate-pulse motion-reduce:animate-none" : ""}`} />}
      </span>
      <div className="min-w-0 flex-1">
        <p className={`truncate text-sm font-medium ${state === "waiting" ? "text-slate-500" : "text-slate-900"}`}>{step.name}</p>
        <p className="truncate text-xs text-slate-500">{state === "failed" ? "Stopped by you" : step.detail}</p>
      </div>
      <TimerPill label={step.name} time={format(shown, "SS.MS")} spinning={state === "running"} variant={state === "running" ? "default" : state === "done" ? "success" : state === "failed" ? "destructive" : "ghost"} />
    </li>
  );
}

export default function ElapsedTimePillsOnARunningDeployPipeline() {
  const reduce = useReducedMotion();
  const [run, setRun] = useState(0);
  const [current, setCurrent] = useState(0);
  const [stopped, setStopped] = useState(false);
  const finished = current >= steps.length;
  const active = !finished && !stopped;
  const total = useElapsed(active, run);

  // Hand over to the next step when the current one has had its time; start again a while after the end.
  useEffect(() => {
    if (stopped) return;
    if (finished) {
      if (reduce) return;
      const t = window.setTimeout(() => restart(), 4200);
      return () => window.clearTimeout(t);
    }
    const t = window.setTimeout(() => setCurrent((c) => c + 1), steps[current].seconds * 1000);
    return () => window.clearTimeout(t);
  }, [current, finished, stopped, run, reduce]);

  const restart = () => {
    setStopped(false);
    setCurrent(0);
    setRun((r) => r + 1);
  };

  return (
    <section className="bg-slate-100 px-4 py-12 sm:px-6 sm:py-16">
      <div className="mx-auto max-w-xl rounded-3xl bg-white p-5 shadow-xl shadow-slate-300/50 ring-1 ring-slate-200 sm:p-6">
        <div className="flex flex-wrap items-start justify-between gap-3">
          <div className="min-w-0">
            <p className="inline-flex items-center gap-1.5 text-xs font-medium text-slate-600">
              <GitBranch className="h-3.5 w-3.5" aria-hidden="true" /> main · a41c9e2 · Fix cart rounding
            </p>
            <h2 className="mt-1 text-xl font-semibold tracking-tight text-slate-900">{stopped ? "Deploy cancelled" : finished ? "Deployed to production" : "Deploying to production"}</h2>
          </div>
          <TimerPill large label="Total time" time={format(total, "MM:SS")} spinning={active} variant={stopped ? "destructive" : finished ? "success" : "default"} />
        </div>

        <div className="mt-4 h-1.5 overflow-hidden rounded-full bg-slate-100" aria-hidden="true">
          <motion.div className={`h-full rounded-full ${stopped ? "bg-red-400" : "bg-emerald-500"}`} initial={false} animate={{ width: `${(Math.min(current, steps.length) / steps.length) * 100}%` }} transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 120, damping: 20 }} />
        </div>

        <ol className="mt-3 divide-y divide-slate-100">
          {steps.map((s, i) => (
            <StepRow key={`${run}-${s.name}`} step={s} run={run} state={i < current ? "done" : i === current ? (stopped ? "failed" : "running") : "waiting"} />
          ))}
        </ol>

        <div className="mt-4 flex items-center justify-between gap-3 border-t border-slate-100 pt-4">
          <p role="status" className="text-sm text-slate-600">
            {stopped ? "Nothing was published." : finished ? "Live at fernhill.example in 4 steps." : `Step ${current + 1} of ${steps.length}`}
          </p>
          {active ? (
            <button type="button" onClick={() => setStopped(true)} className="inline-flex h-10 items-center gap-1.5 rounded-full border border-slate-300 px-4 text-sm font-medium text-slate-900 outline-none hover:bg-slate-50 focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2">
              <X className="h-4 w-4" aria-hidden="true" /> Cancel
            </button>
          ) : (
            <button type="button" onClick={restart} className="inline-flex h-10 items-center gap-1.5 rounded-full bg-slate-900 px-4 text-sm font-medium text-white outline-none hover:bg-slate-700 focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2">
              <RotateCcw className="h-4 w-4" aria-hidden="true" /> Run again
            </button>
          )}
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/elapsed-time-pills-on-a-running-deploy-pipeline.json

When to use it

How to customize it

Accessibility

Questions

How does the timer count without drifting?

`useElapsed` records a start time from `performance.now()` and, on each animation frame, works out the difference. The value is always the real elapsed time, so skipped frames or a busy tab do not make it fall behind.

Why update only thirty times a second?

The readout shows hundredths, and thirty updates a second is enough to look continuous while halving the number of React renders compared with updating on every frame.

How do I stop a timer and keep its value?

Pass `false` as the first argument of `useElapsed`. The hook keeps the last value until its reset key changes.

How do I connect it to a real job?

Remove the effect with the timeouts and set `current` from your own status updates. For durations reported by a server, skip `useElapsed` and pass the server's number straight to `format`.

Can the pill be used on its own?

Yes. `TimerPill` only needs a `time` string and a `label`. Pair it with `useElapsed` anywhere you need a running clock.

Adapted from Timer in Cult UI, used under its open-source licence. Licences and credits

Related: Multi-Step Loading Sequence with Drawn Checkmarks, AI Agent Working Status with Scrolling Step Log, File Upload Dropzone with Animated Progress Ring.

More alerts and feedback or all free UI components. Start for free to have a full site built around it.

More in Feedback

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap