Vela UI Docsvenizia.ai

Button Scramble

A call-to-action button whose label scrambles into place on hover, with primary and secondary variants, three sizes, an optional corner arrow and loading and disabled states.

npx shadcn@latest add @velaui/button-scramble
npx shadcn@latest add @velaui/button-scramble

Examples

  • Default
  • Variants
  • Arrow
  • Loading
  • Disabled

View code

Button Scramble · Default

Installation

pnpm dlx velaui add @velaui/button-scramble
pnpm dlx velaui add @velaui/button-scramble

Code

button-scramble-default.tsx
import { ButtonScramble } from "@/components/ui/button-scramble";

export default function Example() {
  return <ButtonScramble>Talk to us</ButtonScramble>;
}

View code

Button Scramble · Variants

Installation

pnpm dlx velaui add @velaui/button-scramble
pnpm dlx velaui add @velaui/button-scramble

Code

button-scramble-variants.tsx
import { ButtonScramble } from "@/components/ui/button-scramble";

export default function Example() {
  return (
    <div className="flex items-center gap-4">
      <ButtonScramble variant="primary">Talk to us</ButtonScramble>
      <ButtonScramble variant="secondary">Talk to us</ButtonScramble>
    </div>
  );
}

View code

Button Scramble · Arrow

Installation

pnpm dlx velaui add @velaui/button-scramble
pnpm dlx velaui add @velaui/button-scramble

Code

button-scramble-arrow.tsx
import { ButtonScramble } from "@/components/ui/button-scramble";

export default function Example() {
  return (
    <div className="flex items-center gap-4">
      <ButtonScramble arrow>Talk to us</ButtonScramble>
      <ButtonScramble arrow variant="secondary" href="#talk">
        Talk to us
      </ButtonScramble>
    </div>
  );
}

View code

Button Scramble · Loading

Installation

pnpm dlx velaui add @velaui/button-scramble
pnpm dlx velaui add @velaui/button-scramble

Code

button-scramble-loading.tsx
import { ButtonScramble } from "@/components/ui/button-scramble";

export default function Example() {
  return (
    <div className="flex items-center gap-4">
      <ButtonScramble loading>Delayed spinner</ButtonScramble>
      <ButtonScramble loading immediate variant="secondary">
        Immediate spinner
      </ButtonScramble>
    </div>
  );
}

View code

Button Scramble · Disabled

Installation

pnpm dlx velaui add @velaui/button-scramble
pnpm dlx velaui add @velaui/button-scramble

Code

button-scramble-disabled.tsx
import { ButtonScramble } from "@/components/ui/button-scramble";

export default function Example() {
  return (
    <div className="flex items-center gap-4">
      <ButtonScramble disabled>Talk to us</ButtonScramble>
      <ButtonScramble disabled variant="secondary">
        Talk to us
      </ButtonScramble>
    </div>
  );
}

Sizes

Usage

import { ButtonScramble } from "@/components/ui/button-scramble";

export default function Example() {
  return <ButtonScramble>Talk to us</ButtonScramble>;
}

Props

PropTypeDefault
arrowbooleanfalse
disabledbooleanfalse
hrefstring—
immediatebooleanfalse
loadingbooleanfalse
size"s" | "m" | "l""m"
variant"primary" | "secondary""primary"

Takes these and every prop of ButtonScrambleProps.

Dependencies

npm
class-variance-authority@0.7.1gsap@3.15.0
Installs with it
2 icons

Source

components/ui/button-scramble.tsx
import * as React from "react";
import { cva } from "class-variance-authority";
import { gsap } from "gsap";
import { ScrambleTextPlugin } from "gsap/ScrambleTextPlugin";

import { ArrowUpRight } from "@/components/icons/arrow-up-right";
import { Loader02 } from "@/components/icons/loader-02";
import { cn } from "@/lib/utils";

gsap.registerPlugin(ScrambleTextPlugin);

const SCRAMBLE_CHARS = "abcdefghijklmno";
const SPINNER_DELAY_MS = 300;

const buttonScrambleVariants = cva(
  "relative inline-flex shrink-0 items-center justify-center overflow-clip rounded-[4px] border font-medium whitespace-nowrap uppercase outline-none select-none transition-[background-color,border-color] duration-[180ms] ease-out focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-primary-default active:scale-[0.98] motion-reduce:transition-none motion-reduce:active:transform-none",
  {
    variants: {
      variant: {
        primary:
          "border-primary-default bg-primary-default text-text-inverse not-data-disabled:hover:border-primary-hover not-data-disabled:hover:bg-primary-hover not-data-disabled:hover:text-base-white",
        secondary:
          "border-neutral-950 bg-base-white text-neutral-950 not-data-disabled:hover:bg-neutral-950 not-data-disabled:hover:text-base-white",
      },
      size: {
        s: "h-10 px-[19px] text-sm",
        m: "h-12 px-[23px] text-sm",
        l: "h-14 px-[27px] text-base",
      },
      arrow: {
        true: "",
        false: "",
      },
    },
    compoundVariants: [
      { size: "s", arrow: true, className: "pr-[27px]" },
      { size: "m", arrow: true, className: "pr-[31px]" },
      { size: "l", arrow: true, className: "pr-[35px]" },
    ],
    defaultVariants: {
      variant: "primary",
      size: "m",
      arrow: false,
    },
  },
);

const arrowSizes = {
  s: "right-[5px] top-[5px] size-2.5",
  m: "right-1.5 top-1.5 size-3",
  l: "right-[7px] top-[7px] size-3.5",
} as const;

const spinnerSizes = { s: 16, m: 18, l: 20 } as const;

type OwnProps = {
  variant?: "primary" | "secondary";
  size?: "s" | "m" | "l";
  arrow?: boolean;
  loading?: boolean;
  immediate?: boolean;
  disabled?: boolean;
};

export type ButtonScrambleProps = OwnProps &
  (
    | ({ href: string } & Omit<React.ComponentProps<"a">, keyof OwnProps | "href">)
    | ({ href?: undefined } & Omit<React.ComponentProps<"button">, keyof OwnProps>)
  );

function canScramble() {
  return (
    window.matchMedia("(hover: hover) and (pointer: fine)").matches &&
    !window.matchMedia("(prefers-reduced-motion: reduce)").matches
  );
}

function ButtonScramble({
  className,
  variant = "primary",
  size = "m",
  arrow = false,
  loading = false,
  immediate = false,
  disabled = false,
  children,
  ref,
  ...props
}: ButtonScrambleProps) {
  const buttonRef = React.useRef<HTMLElement | null>(null);
  const labelRef = React.useRef<HTMLSpanElement | null>(null);
  const [delayElapsed, setDelayElapsed] = React.useState(false);

  const label = typeof children === "string" ? children : undefined;
  const showSpinner = loading && (immediate || delayElapsed);
  const inactive = disabled || loading;

  React.useEffect(() => {
    if (!loading) return;
    const timer = setTimeout(() => setDelayElapsed(true), SPINNER_DELAY_MS);
    return () => {
      clearTimeout(timer);
      setDelayElapsed(false);
    };
  }, [loading]);

  React.useEffect(() => {
    const button = buttonRef.current;
    const labelEl = labelRef.current;
    if (!button || !labelEl || label === undefined) return;

    const handleEnter = () => {
      if (button.hasAttribute("data-disabled") || !canScramble()) return;
      gsap.killTweensOf(labelEl);
      button.style.width = `${button.offsetWidth}px`;
      gsap.to(labelEl, {
        duration: 1 * 1.5,
        ease: "power3.out",
        scrambleText: { text: label, chars: SCRAMBLE_CHARS },
      });
    };

    const handleLeave = () => {
      if (!canScramble()) return;
      gsap.killTweensOf(labelEl);
      gsap.to(labelEl, {
        duration: 0.6 * 1.5,
        ease: "power3.out",
        scrambleText: { text: label, chars: SCRAMBLE_CHARS, speed: 2 },
        onComplete: () => {
          button.style.width = "";
        },
      });
    };

    button.addEventListener("mouseenter", handleEnter);
    button.addEventListener("mouseleave", handleLeave);
    return () => {
      button.removeEventListener("mouseenter", handleEnter);
      button.removeEventListener("mouseleave", handleLeave);
      gsap.killTweensOf(labelEl);
      button.style.width = "";
    };
  }, [label]);

  const setRef = (node: HTMLElement | null) => {
    buttonRef.current = node;
    if (typeof ref === "function") ref(node as never);
    else if (ref) (ref as React.RefObject<HTMLElement | null>).current = node;
  };

  const shared = {
    "data-slot": "button-scramble",
    "data-variant": variant,
    "data-size": size,
    "data-disabled": inactive ? "" : undefined,
    "data-loading": loading ? "" : undefined,
    "aria-label": label,
    "aria-busy": loading || undefined,
    className: cn(
      buttonScrambleVariants({ variant, size, arrow }),
      disabled && !loading && "cursor-not-allowed opacity-60",
      className,
    ),
  };

  const content = (
    <>
      <span
        key={label}
        ref={labelRef}
        className={cn(
          "transition-colors duration-[250ms] ease-out motion-reduce:transition-none",
          showSpinner && "opacity-0",
        )}
      >
        {children}
      </span>
      {arrow && !showSpinner ? (
        <ArrowUpRight
          aria-hidden
          size="100%"
          className={cn("pointer-events-none absolute", arrowSizes[size])}
        />
      ) : null}
      {showSpinner ? (
        <Loader02
          aria-hidden
          size={spinnerSizes[size]}
          strokeWidth={1.5}
          className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 animate-spin"
        />
      ) : null}
    </>
  );

  if (props.href !== undefined) {
    const { onClick, ...anchorProps } = props as React.ComponentProps<"a">;
    return (
      <a
        {...anchorProps}
        {...shared}
        ref={setRef}
        aria-disabled={inactive || undefined}
        tabIndex={inactive ? -1 : anchorProps.tabIndex}
        onClick={(event) => {
          if (inactive) {
            event.preventDefault();
            return;
          }
          onClick?.(event);
        }}
      >
        {content}
      </a>
    );
  }

  const buttonProps = props as React.ComponentProps<"button">;
  return (
    <button type="button" {...buttonProps} {...shared} ref={setRef} disabled={inactive}>
      {content}
    </button>
  );
}

export { ButtonScramble };