Halo Rim Button with Staggered Loading Label

A pill button with a rim of drifting colour and three states. At rest, pink, violet, cyan and gold slide slowly around a one pixel edge with a soft halo behind. Press it and the label changes to a loading word one letter at a time, a spinner joins it, the colours speed up and the pill narrows with a spring to fit. When the work is done a green tick and a short confirmation appear, then it returns to rest. It is shown on a white release card and a black one, and both run through the cycle on their own.

Code

// Adapted from Cult UI (https://cult-ui.com/docs/components/halo-button)
// Copyright (c) 2023 Jordan-Gilliam. MIT License: https://we.inc/components/licenses
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { Check, GitCommitHorizontal, Loader } from "lucide-react";

type Phase = "idle" | "loading" | "done";

/** The three blobs of colour that drift around the rim: gradient and seconds per loop. */
const BLOBS = [
  { bg: "linear-gradient(90deg, #ff0080, #7928ca, #00d4ff, #0070f3)", dur: 6, cls: "halo-rim-button-with-staggered-loading-label-a" },
  { bg: "linear-gradient(90deg, #ff4d4d, #f9cb28, #ff0080)", dur: 5, cls: "halo-rim-button-with-staggered-loading-label-b" },
  { bg: "linear-gradient(90deg, #0070f3, #00d4ff, #7928ca)", dur: 4, cls: "halo-rim-button-with-staggered-loading-label-c" },
];

const LAYOUT_SPRING = { type: "spring" as const, bounce: 0.12, damping: 34, stiffness: 420 };

function StaggeredLabel({ text, slow }: { text: string; slow: boolean }) {
  const reduce = useReducedMotion();
  if (reduce) return <span>{text}</span>;
  return (
    <span aria-hidden="true" className="inline-flex whitespace-pre">
      {text.split("").map((ch, i) => (
        <motion.span key={`${ch}-${i}`} initial={{ opacity: 0, y: 6, filter: "blur(4px)" }} animate={{ opacity: 1, y: 0, filter: "blur(0px)" }} transition={{ delay: i * (slow ? 0.038 : 0.018), duration: slow ? 0.36 : 0.16, ease: "easeOut" }} className="inline-block">
          {ch}
        </motion.span>
      ))}
    </span>
  );
}

function HaloButton({ label, loadingLabel, doneLabel, dark = false, autoStart }: { label: string; loadingLabel: string; doneLabel: string; dark?: boolean; autoStart: number }) {
  const reduce = useReducedMotion();
  const [phase, setPhase] = useState<Phase>("idle");
  const timers = useRef<number[]>([]);
  const clear = () => {
    timers.current.forEach((t) => window.clearTimeout(t));
    timers.current = [];
  };

  const run = () => {
    clear();
    setPhase("loading");
    timers.current.push(window.setTimeout(() => setPhase("done"), 2000));
    timers.current.push(window.setTimeout(() => setPhase("idle"), 4200));
  };

  // Runs through the three states on a loop so the preview is alive. A click restarts it.
  useEffect(() => {
    if (reduce) return;
    const first = window.setTimeout(run, autoStart);
    const loop = window.setInterval(run, 7600);
    return () => {
      window.clearTimeout(first);
      window.clearInterval(loop);
      clear();
    };
  }, [reduce, autoStart]);

  const text = phase === "idle" ? label : phase === "loading" ? loadingLabel : doneLabel;

  return (
    <motion.div layout={!reduce} transition={{ layout: LAYOUT_SPRING }} className="relative inline-flex max-w-full" style={{ borderRadius: 9999 }}>
      {/* Halo: the drifting colours, once blurred behind the button and once sharp as a one pixel rim. */}
      {[true, false].map((soft) => (
        <span key={String(soft)} aria-hidden="true" className={`pointer-events-none absolute overflow-hidden rounded-full ${soft ? "-inset-1 opacity-60 blur-md" : dark ? "inset-0 bg-zinc-700" : "inset-0 bg-zinc-300"} ${phase === "loading" ? "opacity-100" : ""}`}>
          {BLOBS.map((b) => (
            <span key={b.cls} className={`absolute inset-y-0 w-2/5 blur-[6px] ${b.cls}`} style={{ background: b.bg, animationDuration: `${phase === "loading" ? b.dur / 3 : b.dur}s` }} />
          ))}
        </span>
      ))}
      <motion.button
        layout={!reduce}
        transition={{ layout: LAYOUT_SPRING }}
        type="button"
        aria-busy={phase === "loading"}
        aria-label={text}
        onClick={run}
        style={{ borderRadius: 9999 }}
        className={`relative m-px flex h-11 items-center gap-2 px-5 text-sm font-medium tracking-tight focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 ${
          dark ? "bg-gradient-to-b from-zinc-900 to-zinc-950 text-white focus-visible:ring-white focus-visible:ring-offset-zinc-950" : "bg-gradient-to-b from-white to-zinc-100 text-zinc-900 focus-visible:ring-zinc-900 focus-visible:ring-offset-white"
        }`}
      >
        <motion.span layout={reduce ? false : "position"} transition={{ layout: LAYOUT_SPRING }} className="inline-flex">
          <StaggeredLabel key={text} text={text} slow={phase === "loading"} />
        </motion.span>
        <AnimatePresence mode="popLayout" initial={false}>
          {phase !== "idle" && (
            <motion.span key={phase} layout={reduce ? false : "position"} initial={reduce ? false : { opacity: 0, scale: 0.6 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.6, transition: { duration: reduce ? 0 : 0.2 } }} transition={{ delay: reduce ? 0 : 0.25 }} className="inline-flex">
              {phase === "loading" ? <Loader aria-hidden="true" className="h-4 w-4 animate-spin motion-reduce:animate-none" /> : <Check aria-hidden="true" className="h-4 w-4 text-emerald-500" strokeWidth={3} />}
            </motion.span>
          )}
        </AnimatePresence>
      </motion.button>
    </motion.div>
  );
}

const changes = ["Checkout remembers the last used address", "Search results load 40 ms sooner", "Fixes the double charge warning on refunds"];

export default function HaloRimButtonWithStaggeredLoadingLabel() {
  return (
    <section className="bg-zinc-100 px-4 py-16 sm:px-6 sm:py-20">
      <style>{`
        @keyframes halo-rim-button-with-staggered-loading-label-a { 0%, 100% { left: -5%; } 50% { left: 75%; } }
        @keyframes halo-rim-button-with-staggered-loading-label-b { 0%, 100% { left: 65%; } 50% { left: 10%; } }
        @keyframes halo-rim-button-with-staggered-loading-label-c { 0%, 100% { left: 25%; } 50% { left: 55%; } }
        .halo-rim-button-with-staggered-loading-label-a { left: -5%; animation: halo-rim-button-with-staggered-loading-label-a ease-in-out infinite; }
        .halo-rim-button-with-staggered-loading-label-b { left: 65%; animation: halo-rim-button-with-staggered-loading-label-b ease-in-out infinite; }
        .halo-rim-button-with-staggered-loading-label-c { left: 25%; animation: halo-rim-button-with-staggered-loading-label-c ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .halo-rim-button-with-staggered-loading-label-a, .halo-rim-button-with-staggered-loading-label-b, .halo-rim-button-with-staggered-loading-label-c { animation: none; }
        }
      `}</style>
      <div className="mx-auto grid max-w-4xl gap-6 md:grid-cols-2">
        <article className="rounded-3xl border border-zinc-200 bg-white p-6 sm:p-8">
          <p className="flex items-center gap-2 text-sm font-medium text-zinc-600">
            <GitCommitHorizontal aria-hidden="true" className="h-4 w-4" /> Storefront ยท release 4.2
          </p>
          <h2 className="mt-3 text-2xl font-semibold tracking-tight text-zinc-900">Ready to ship</h2>
          <ul className="mt-4 space-y-2 text-sm leading-6 text-zinc-600">
            {changes.map((c) => (
              <li key={c} className="flex gap-2">
                <Check aria-hidden="true" className="mt-1 h-4 w-4 shrink-0 text-emerald-600" />
                {c}
              </li>
            ))}
          </ul>
          <div className="mt-8 flex h-12 items-center">
            <HaloButton label="Deploy to production" loadingLabel="Deploying" doneLabel="Live" autoStart={900} />
          </div>
        </article>

        <article className="rounded-3xl bg-zinc-950 p-6 sm:p-8">
          <p className="flex items-center gap-2 text-sm font-medium text-zinc-400">
            <GitCommitHorizontal aria-hidden="true" className="h-4 w-4" /> Orders database
          </p>
          <h2 className="mt-3 text-2xl font-semibold tracking-tight text-white">One migration waiting</h2>
          <p className="mt-4 text-sm leading-6 text-zinc-400">Adds two indexes to the orders table. Reads stay available throughout, and the change can be rolled back from this screen for the next 24 hours.</p>
          <div className="mt-8 flex h-12 items-center">
            <HaloButton dark label="Run migration" loadingLabel="Migrating" doneLabel="Done" autoStart={2600} />
          </div>
        </article>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/halo-rim-button-with-staggered-loading-label.json

When to use it

How to customize it

Accessibility

Questions

How is the glowing rim made?

Behind the button there is a pill-shaped layer with three blurred strips of gradient that slide left and right at different speeds. The button's fill sits one pixel inside that layer, so only a thin line of the moving colour shows. A second, blurred copy placed slightly outside gives the halo.

How does the button resize smoothly when the label changes?

The wrapper and the button have Framer Motion's `layout` prop. When the text changes, the new size is measured and the pill springs from the old size to the new one. The label and icon use `layout="position"` so they move without being stretched.

How do the letters appear one by one?

The label is split into characters, each in its own `motion.span` that fades in, rises six pixels and sharpens from a small blur, with a delay that grows with its position. Giving the label its text as a `key` replays this on every change.

How do I connect it to a real action?

In `run`, call your function instead of the two timers: set the phase to `loading`, await the work, then set `done` and, after a pause, `idle`. Remove the effect that starts it automatically.

Is animating `left` a performance problem?

The strips are absolutely positioned inside a clipped layer, so moving them never changes the layout of anything else. There are six small elements per button.

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

Related: Button with Rotating Gradient Glow Border, Hold to Confirm Button with Progress Ring, Shimmer Button with Light Sweep in Three Sizes.

More animated buttons or all free UI components. Start for free to have a full site built around it.

More in Animated Buttons

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