UI Elements

Status Toggle

A pill button that cycles through loading, success, and error states, animating its width, icon, and label for each.

status-toggle.tsx
"use client";

import { cn } from "@/lib/utils";
import { Loader } from "lucide-react";
import {
  AnimatePresence,
  motion as m,
  Transition,
  useAnimate,
} from "motion/react";
import { HTMLAttributes, useEffect, useState } from "react";

type IconProps = HTMLAttributes<SVGElement>;

const Icons = {
  check: (props: IconProps) => (
    <svg
      xmlns="http://www.w3.org/2000/svg"
      viewBox="0 0 24 24"
      fill="currentColor"
      {...props}
    >
      <path
        fillRule="evenodd"
        d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm13.36-1.814a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z"
        clipRule="evenodd"
      />
    </svg>
  ),
  alert: (props: IconProps) => (
    <svg
      xmlns="http://www.w3.org/2000/svg"
      viewBox="0 0 24 24"
      fill="currentColor"
      {...props}
    >
      <path
        fillRule="evenodd"
        d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z"
        clipRule="evenodd"
      />
    </svg>
  ),
};

type Status = "Loading" | "Success" | "Error";

const sequences: Status[] = ["Loading", "Success", "Loading", "Error"];
const SEQUENCEDURATION = 2000;

const buttonTransition: Transition = {
  type: "spring",
  bounce: 0,
  duration: 0.6,
};

const spanTransition: Transition = {
  type: "spring",
  bounce: 0,
  duration: 0.3,
};

const DynamicStatusButton = () => {
  const [status, setStatus] = useState<Status>("Loading");

  const [scope, animate] = useAnimate();

  useEffect(() => {
    if (status === "Error") {
      setTimeout(() => {
        animate(
          scope.current,
          { rotate: [0, 20, -20, 0, 20, -20, 0] },
          { duration: 0.2 }
        );
      }, 750);
    }
  }, [status]);

  useEffect(() => {
    let currentIndex = 0;
    const interval = setInterval(() => {
      setStatus(sequences[currentIndex]);
      currentIndex = (currentIndex + 1) % sequences.length;
    }, SEQUENCEDURATION);
    return () => clearInterval(interval);
  }, []);

  return (
    <div className="full center">
      <m.button
        className={cn(
          status === "Loading" &&
            "bg-blue-100 shadow-none hover:bg-blue-100 text-blue-500",
          status === "Success" &&
            "bg-green-100 shadow-none hover:bg-green-100 text-green-500",
          status === "Error" &&
            "bg-red-100 shadow-none text-destructive hover:bg-red-100",
          "rounded-full overflow-hidden flex items-center gap-2 px-4 py-2"
        )}
        animate={{
          width: status === "Loading" ? 220 : status === "Success" ? 180 : 210,
        }}
        transition={buttonTransition}
      >
        <AnimatePresence mode="sync">
          <div className="center">
            {status === "Loading" && <Loader className="size-6 animate-spin" />}
            {status === "Success" && (
              <Icons.check className="size-6 fill-green-500" />
            )}
            {status === "Error" && (
              <m.span ref={scope}>
                <Icons.alert className="size-6" />
              </m.span>
            )}
          </div>
          {status === "Loading" && (
            <m.span
              initial={{ opacity: 0, x: -20 }}
              animate={{ opacity: 1, x: 0 }}
              exit={{ opacity: 0, x: 20 }}
              className="whitespace-pre"
              transition={spanTransition}
            >
              Analyzing Transaction
            </m.span>
          )}
          {status === "Success" && (
            <m.span
              initial={{ opacity: 0, x: 20 }}
              animate={{ opacity: 1, x: 0 }}
              exit={{ opacity: 0, x: -20 }}
              className="whitespace-pre"
              transition={spanTransition}
            >
              Transaction Safe
            </m.span>
          )}
          {status === "Error" && (
            <m.span
              initial={{ opacity: 0, x: 20 }}
              animate={{ opacity: 1, x: 0 }}
              exit={{ opacity: 0, x: -20 }}
              className="whitespace-pre"
              transition={spanTransition}
            >
              Transaction Warning
            </m.span>
          )}
        </AnimatePresence>
      </m.button>
    </div>
  );
};

export default DynamicStatusButton;