Stagger Button

PreviousNext

Interactive button with animated stagger text and customizable styles for modern uis.

Docs
scrollxuicomponent

Preview

Loading preview…
components/ui/stagger-button.tsx
"use client";
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import {
  AnimatePresence,
  motion,
  type Variants,
  useReducedMotion,
} from "framer-motion";
import { cn } from "@/lib/utils";

const buttonVariants = cva(
  "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
        destructive:
          "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
        outline:
          "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
        secondary:
          "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
        ghost: "hover:bg-accent hover:text-accent-foreground",
        link: "relative text-primary underline-offset-4 hover:text-primary/90 group",
        success: "bg-green-600 text-white hover:bg-green-700",
        warning: "bg-yellow-500 text-black hover:bg-yellow-600",
        info: "bg-blue-500 text-white hover:bg-blue-600",
        dark: "bg-gray-800 text-white hover:bg-gray-700",
        light: "bg-gray-100 text-gray-800 hover:bg-gray-200",
        gradient:
          "bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 text-white hover:opacity-90",
        glass:
          "bg-white/10 backdrop-blur-md text-white border border-white/20 hover:bg-white/20",
      },
      size: {
        default: "h-9 px-4 py-2",
        sm: "h-8 rounded-md px-3 text-xs",
        lg: "h-10 rounded-md px-8",
        icon: "h-9 w-9",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
);

interface StaggerButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean;
  text?: string;
  hoverText?: string;
  staggerDelay?: number;
  staggerDuration?: number;
  direction?: "up" | "down" | "alternate-even" | "alternate-odd";
  easing?: number[];
  disableStagger?: boolean;
}

const useProcessedChars = (text: string, hoverText?: string) =>
  React.useMemo(() => {
    const base = Array.from(text);
    const hover = Array.from(hoverText ?? text);
    const max = Math.max(base.length, hover.length);
    return {
      safeBase: Array.from({ length: max }, (_, i) => base[i] ?? " "),
      safeHover: Array.from({ length: max }, (_, i) => hover[i] ?? " "),
    };
  }, [text, hoverText]);

const useIsTouchDevice = () => {
  const [isTouch, setIsTouch] = React.useState(false);
  React.useEffect(() => {
    const check = () =>
      setIsTouch("ontouchstart" in window || navigator.maxTouchPoints > 0);
    check();
    window.addEventListener("resize", check);
    return () => window.removeEventListener("resize", check);
  }, []);
  return isTouch;
};

const getTargetY = (
  direction: StaggerButtonProps["direction"],
  isEven: boolean
) => {
  switch (direction) {
    case "up":
      return "-100%";
    case "down":
      return "0%";
    case "alternate-even":
      return isEven ? "-50%" : "0%";
    case "alternate-odd":
      return isEven ? "0%" : "-50%";
    default:
      return "-50%";
  }
};

const StaggerButton = React.forwardRef<HTMLButtonElement, StaggerButtonProps>(
  (
    {
      className,
      variant,
      size,
      asChild = false,
      text,
      hoverText,
      staggerDelay = 0.025,
      staggerDuration = 0.4,
      direction = "down",
      easing = [0.25, 1, 0.5, 1],
      disableStagger = false,
      children,
      ...props
    },
    ref
  ) => {
    const [isHovered, setIsHovered] = React.useState(false);
    const [isTapped, setIsTapped] = React.useState(false);
    const displayText =
      text ??
      React.Children.toArray(children)
        .map((c) => (typeof c === "string" ? c : ""))
        .join("") ??
      "";
    const { safeBase, safeHover } = useProcessedChars(displayText, hoverText);
    const prefersReducedMotion = useReducedMotion();
    const isTouchDevice = useIsTouchDevice();

    const containerVariants: Variants = {
      initial: {},
      hover: {
        transition: {
          staggerChildren: prefersReducedMotion ? 0 : staggerDelay,
        },
      },
      exit: {},
    };

    const stackVariants: Variants = {
      initial: { y: direction === "up" ? "0%" : "-50%" },
      hover: ({ index, isEven }: { index: number; isEven: boolean }) =>
        prefersReducedMotion
          ? { y: direction === "up" ? "0%" : "-50%" }
          : {
              y: getTargetY(direction, isEven),
              transition: {
                duration: staggerDuration,
                delay: index * staggerDelay,
                ease: easing,
              },
            },
      exit: { y: direction === "up" ? "0%" : "-50%" },
    };

    const handleTouchStart = (e: React.TouchEvent<HTMLButtonElement>) => {
      if (isTouchDevice && !disableStagger) {
        setIsTapped(true);
        setTimeout(
          () =>
            setIsTapped(false),
          staggerDuration * 1000 + safeBase.length * staggerDelay * 1000
        );
      }
      props.onTouchStart?.(e);
    };

    const shouldAnimate = disableStagger
      ? false
      : isTouchDevice
        ? isTapped
        : isHovered;

    const Comp = asChild ? Slot : motion.button;

    return (
      <Comp
        className={cn(
          buttonVariants({ variant, size }),
          className,
          "relative group"
        )}
        ref={ref}
        onMouseEnter={() => !isTouchDevice && setIsHovered(true)}
        onMouseLeave={() => !isTouchDevice && setIsHovered(false)}
        onTouchStart={handleTouchStart}
        {...props}
      >
        <AnimatePresence mode="wait">
          <motion.span
            className="relative h-fit leading-none select-none transform-gpu will-change-transform inline-flex"
            variants={containerVariants}
            initial="initial"
            exit="exit"
            animate={shouldAnimate ? "hover" : "initial"}
            style={{ perspective: 1000 }}
          >
            {safeBase.map((char, index) => {
              const nextChar = safeHover[index];
              const isSpace = char === " " && nextChar === " ";
              const visibleCharsBefore = safeBase
                .slice(0, index)
                .filter((c) => c !== " ").length;
              const isEven = visibleCharsBefore % 2 === 0;
              const isUp = direction === "up";

              return (
                <span
                  key={index}
                  className="inline-block h-[1em] align-baseline overflow-hidden relative"
                  style={{ lineHeight: 1 }}
                >
                  <motion.span
                    className="block relative"
                    variants={stackVariants}
                    custom={{ index, isEven }}
                    style={{
                      backfaceVisibility: "hidden",
                      transform: "translateZ(0)",
                      lineHeight: 1,
                    }}
                  >
                    {isUp ? (
                      <>
                        <span className="block h-[1em] leading-none relative">
                          {isSpace ? "\u00A0" : char}
                        </span>
                        <span className="block h-[1em] leading-none absolute top-full left-0">
                          {isSpace ? "\u00A0" : nextChar}
                        </span>
                      </>
                    ) : (
                      <>
                        {isEven && (
                          <span className="block h-[1em] leading-none">
                            {isSpace ? "\u00A0" : nextChar}
                          </span>
                        )}
                        <span className="block h-[1em] leading-none">
                          {isSpace ? "\u00A0" : char}
                        </span>
                        {!isEven && (
                          <span className="block h-[1em] leading-none">
                            {isSpace ? "\u00A0" : nextChar}
                          </span>
                        )}
                      </>
                    )}
                  </motion.span>
                </span>
              );
            })}
          </motion.span>
        </AnimatePresence>
        {variant === "link" && (
          <span className="absolute left-1/2 bottom-2 h-[1px] w-0 bg-current transition-all duration-300 group-hover:w-3/4 group-hover:-translate-x-1/2" />
        )}
      </Comp>
    );
  }
);

StaggerButton.displayName = "StaggerButton";

export { StaggerButton, buttonVariants };
export type { StaggerButtonProps };

Installation

npx shadcn@latest add @scrollxui/stagger-button

Usage

import { StaggerButton } from "@/components/stagger-button"
<StaggerButton />