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-scramblepnpm dlx velaui add @velaui/button-scrambleyarn dlx shadcn@latest add @velaui/button-scramblebunx --bun shadcn@latest add @velaui/button-scrambleveni add button-scrambleInstalls the copy of vela-ui that veni-builder serves to Venizia apps; veni-builder serves the Radix UI copy only, so this tab exists for that base alone.
npx shadcn@latest add @velaui/button-scramblepnpm dlx velaui add @velaui/button-scrambleyarn dlx shadcn@latest add @velaui/button-scramblebunx --bun shadcn@latest add @velaui/button-scrambleExamples
- Default
- Variants
- Arrow
- Loading
- Disabled
Sizes
Usage
Props
| Prop | Type | Default |
|---|---|---|
arrow | boolean | false |
disabled | boolean | false |
href | string | — |
immediate | boolean | false |
loading | boolean | false |
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 };