loader

PreviousNext

A loader component with various variants

Docs
shadix-uicomponent

Preview

Loading preview…
registry/new-york/components/loader.tsx
import { useMemo } from "react";

import { cva, type VariantProps } from "class-variance-authority";
import { motion } from "motion/react";

import { cn } from "@/shadcn/lib/utils";

const loaderSizes = cva("", {
    variants: {
        size: {
            default: "size-10",
            sm: "size-6",
            md: "size-10",
            lg: "size-14",
            xl: "size-18",
        },
    },
    defaultVariants: {
        size: "default",
    },
});

// feat: bar loader sizes with more width for horizontal layout
const barLoaderSizes = cva("", {
    variants: {
        size: {
            default: "h-1 w-24",
            sm: "h-0.5 w-16",
            md: "h-1.5 w-32",
            lg: "h-2 w-40",
            xl: "h-3 w-48",
        },
    },
    defaultVariants: {
        size: "default",
    },
});

type LoaderVariant =
    | "arc"
    | "circle-dots"
    | "two-dots"
    | "three-dots"
    | "triangle"
    | "dual-arc"
    | "bounce"
    | "clip"
    | "bar"
    | "beat"
    | "puff";

interface LoaderProps extends React.HTMLAttributes<HTMLElement> {
    /** @public (optional) - The className of the loader container */
    className?: string;
    /** @public (optional) - The variant of the loader */
    variant?: LoaderVariant;
    /** @public (optional) - The size of the loader */
    size?: VariantProps<typeof loaderSizes>["size"];
}

const Loader: React.FC<LoaderProps> = ({
    variant = "arc",
    size = "default",
    className,
    ...props
}) => {
    const Component = useMemo(() => {
        switch (variant) {
            case "arc":
                return ArcLoader;
            case "circle-dots":
                return CircleDotsLoader;
            case "two-dots":
                return TwoDotsLoader;
            case "three-dots":
                return ThreeDotsLoader;
            case "triangle":
                return TriangleLoader;
            case "dual-arc":
                return DualArcLoader;
            case "bounce":
                return BounceLoader;
            case "clip":
                return ClipLoader;
            case "bar":
                return BarLoader;
            case "beat":
                return BeatLoader;
            case "puff":
                return PuffLoader;
            default:
                return ArcLoader;
        }
    }, [variant]);

    // refactor: apply variant-specific sizing (bar needs more width)
    const sizeClasses = useMemo(() => {
        if (variant === "bar") {
            return barLoaderSizes({ size });
        }
        return loaderSizes({ size });
    }, [variant, size]);

    return (
        <div
            className={cn(
                "flex items-center justify-center",
                sizeClasses,
                className,
            )}
            {...props}
        >
            <Component size={size} />
        </div>
    );
};

const ArcLoader = ({
    size,
}: {
    size: VariantProps<typeof loaderSizes>["size"];
}) => {
    const borderSize = useMemo(() => {
        switch (size) {
            case "default":
                return "border-[3px]";
            case "sm":
                return "border-[2px]";
            case "md":
            case "lg":
                return "border-[3px]";
            case "xl":
                return "border-[4px]";
            default:
                return "border-[3px]";
        }
    }, [size]);

    return (
        <motion.div
            className={cn(
                "w-full h-full border-2 border-primary border-t-transparent rounded-full",
                borderSize,
            )}
            animate={{ rotate: 360 }}
            transition={{
                duration: 3,
                ease: "linear",
                repeat: Infinity,
            }}
        />
    );
};

const DualArcLoader = ({
    size,
}: {
    size: VariantProps<typeof loaderSizes>["size"];
}) => {
    const borderSize = useMemo(() => {
        switch (size) {
            case "default":
                return "border-[3px]";
            case "sm":
                return "border-[2px]";
            case "md":
            case "lg":
                return "border-[3px]";
            case "xl":
                return "border-[4px]";
            default:
                return "border-[3px]";
        }
    }, [size]);

    const insetSize = useMemo(() => {
        switch (size) {
            case "default":
                return "inset-0.5";
            case "sm":
                return "inset-1";
            case "md":
            case "lg":
                return "inset-1.5";
            case "xl":
                return "inset-2";
            default:
                return "inset-0.5";
        }
    }, [size]);

    return (
        <div className="relative w-full h-full">
            <motion.div
                className={cn(
                    "absolute inset-0 border-4 border-primary border-t-transparent rounded-full",
                    borderSize,
                )}
                animate={{ rotate: 360 }}
                transition={{ duration: 1.2, ease: "linear", repeat: Infinity }}
            />
            <motion.div
                className={cn(
                    "absolute border-4 border-primary border-b-transparent rounded-full",
                    borderSize,
                    insetSize,
                )}
                animate={{ rotate: -360 }}
                transition={{ duration: 1.2, ease: "linear", repeat: Infinity }}
            />
        </div>
    );
};

const CircleDotsLoader = () => {
    const dots = Array.from({ length: 8 });

    return (
        <div className="relative w-full h-full">
            {dots.map((_, i) => {
                const angle = (i * 45 * Math.PI) / 180;
                const radius = 35; // percentage from center
                const x = 50 + radius * Math.cos(angle);
                const y = 50 + radius * Math.sin(angle);

                return (
                    <motion.span
                        key={i.toString()}
                        className={cn(
                            "absolute w-[20%] h-[20%] bg-primary rounded-full",
                        )}
                        style={{
                            left: `${x}%`,
                            top: `${y}%`,
                            x: "-50%",
                            y: "-50%",
                        }}
                        animate={{
                            opacity: [0.6, 1, 0.6],
                            scale: [0.4, 1, 0.4],
                        }}
                        transition={{
                            duration: 1.6,
                            ease: [0.4, 0, 0.6, 1],
                            repeat: Infinity,
                            repeatType: "loop",
                            delay: i * 0.2,
                        }}
                    />
                );
            })}
        </div>
    );
};

const TwoDotsLoader = () => {
    return (
        <div className="flex items-center justify-center w-full h-full">
            {[0, 1].map((i) => (
                <motion.span
                    key={i}
                    className="w-[25%] h-[25%] bg-primary rounded-full"
                    animate={{ opacity: [1, 0.3, 1] }}
                    transition={{
                        duration: 0.6,
                        repeat: Infinity,
                        delay: i * 0.3,
                    }}
                />
            ))}
        </div>
    );
};

const ThreeDotsLoader = () => {
    return (
        <div className="flex gap-2 w-full h-full items-center justify-center">
            {[0, 1, 2].map((i) => (
                <motion.span
                    key={i}
                    className="w-[25%] aspect-square bg-primary rounded-full"
                    animate={{ y: ["0%", "-50%", "0%"] }}
                    transition={{
                        ease: "easeInOut",
                        duration: 0.8,
                        repeat: Infinity,
                        delay: i * 0.2,
                    }}
                />
            ))}
        </div>
    );
};

const TriangleLoader = () => {
    const positions = [
        { x: 0, y: "-100%" },
        { x: "-70%", y: 0 },
        { x: "70%", y: 0 },
    ];

    return (
        <div className="relative w-full h-full flex items-center justify-center">
            {positions.map((p, i) => (
                <motion.span
                    key={i.toString()}
                    className="absolute w-[25%] h-[25%] bg-primary rounded-full"
                    style={{
                        transform: `translate(${p.x}, ${p.y})`,
                    }}
                    animate={{ opacity: [1, 0.2, 1] }}
                    transition={{
                        duration: 0.8,
                        repeat: Infinity,
                        delay: i * 0.2,
                    }}
                />
            ))}
        </div>
    );
};

const BounceLoader = () => {
    return (
        <div className="relative w-full h-full flex items-center justify-center">
            <motion.div
                className="absolute w-full h-full bg-primary/60 rounded-full"
                animate={{
                    scale: [0, 1, 0],
                }}
                transition={{
                    duration: 2.1,
                    ease: "easeInOut",
                    repeat: Infinity,
                }}
            />
            <motion.div
                className="absolute w-full h-full bg-primary/60 rounded-full"
                animate={{
                    scale: [0, 1, 0],
                }}
                transition={{
                    duration: 2.1,
                    ease: "easeInOut",
                    repeat: Infinity,
                    repeatType: "loop",
                    delay: 1,
                }}
            />
        </div>
    );
};

const ClipLoader = ({
    size,
}: {
    size: VariantProps<typeof loaderSizes>["size"];
}) => {
    const borderSize = useMemo(() => {
        switch (size) {
            case "default":
                return "border-[4px]";
            case "sm":
                return "border-[3px]";
            case "md":
            case "lg":
                return "border-[4px]";
            case "xl":
                return "border-[5px]";
            default:
                return "border-[4px]";
        }
    }, [size]);

    return (
        <motion.div
            className={cn(
                "w-full h-full border-primary border-t-transparent rounded-full",
                borderSize,
            )}
            animate={{
                rotate: 360,
                scale: [0.8, 1, 0.8],
            }}
            transition={{
                duration: 1,
                ease: "linear",
                repeat: Infinity,
            }}
        />
    );
};

const BarLoader = ({
    size: _size,
}: {
    size: VariantProps<typeof loaderSizes>["size"];
}) => {
    return (
        <div className="relative w-full h-full bg-primary/30 overflow-hidden rounded-sm">
            <motion.span
                className="absolute inset-y-0 bg-primary z-10 h-full"
                animate={{
                    left: ["-35%", "100%", "100%"],
                    right: ["100%", "-90%", "-90%"],
                }}
                style={{
                    willChange: "left,right",
                }}
                transition={{
                    duration: 1.5,
                    ease: [0.65, 0.815, 0.735, 0.395],
                    times: [0, 0.6, 1],
                    repeat: Infinity,
                    repeatType: "loop",
                }}
            />
            <motion.span
                className="absolute inset-y-0 bg-primary z-10 h-full"
                animate={{
                    left: ["-200%", "107%", "107%"],
                    right: ["100%", "-8%", "-8%"],
                }}
                style={{
                    willChange: "left,right",
                }}
                transition={{
                    duration: 1.5,
                    ease: [0.165, 0.84, 0.44, 1],
                    times: [0, 0.6, 1],
                    repeat: Infinity,
                    repeatType: "loop",
                }}
            />
        </div>
    );
};

const BeatLoader = () => {
    return (
        <div className="relative w-full h-full flex items-center justify-center gap-0">
            {[0, 1, 2].map((i) => (
                <motion.span
                    key={i}
                    className="w-[25%] aspect-square bg-primary rounded-full"
                    initial={{
                        opacity: 0.5,
                        scale: 0.75,
                    }}
                    animate={{
                        opacity: [0.5, 1, 0.5],
                        scale: [0.75, 1, 0.75],
                    }}
                    transition={{
                        ease: "easeInOut",
                        duration: 1,
                        repeat: Infinity,
                        delay: i === 1 ? 0.5 : 0,
                    }}
                />
            ))}
        </div>
    );
};

const PuffLoader = ({
    size,
}: {
    size: VariantProps<typeof loaderSizes>["size"];
}) => {
    const borderSize = useMemo(() => {
        switch (size) {
            case "default":
                return "border-[4px]";
            case "sm":
                return "border-[3px]";
            case "md":
            case "lg":
                return "border-[4px]";
            case "xl":
                return "border-[5px]";
            default:
                return "border-[4px]";
        }
    }, [size]);

    return (
        <div className="relative w-full h-full flex items-center justify-center">
            <motion.span
                className={cn(
                    "absolute inset-0 w-full h-full border-secondary rounded-full",
                    borderSize,
                )}
                initial={{
                    opacity: 0,
                    scale: 0,
                }}
                animate={{
                    scale: [0, 1, 0],
                    opacity: [1, 0],
                }}
                transition={{
                    duration: 2,
                    repeat: Infinity,
                }}
            />

            <motion.span
                className={cn(
                    "absolute inset-0 w-full h-full border-primary rounded-full",
                    borderSize,
                )}
                initial={{
                    opacity: 0,
                    scale: 0,
                }}
                animate={{
                    scale: [0, 1, 0],
                    opacity: [1, 0],
                }}
                transition={{
                    duration: 2,
                    delay: 1,
                    repeat: Infinity,
                }}
            />
        </div>
    );
};

export { Loader };

Installation

npx shadcn@latest add @shadix-ui/loader

Usage

import { Loader } from "@/components/loader"
<Loader />