Arrow Fill Button

A directional CTA button where the arrow and fill animation reinforce click intent without delaying the action.

Published On: June 4, 2026
Last Updated: August 17, 2026
Arrow Fill Button

Overview

Arrow Fill Button gives a button or link a small, useful moment of feedback: the arrow and fill move as one CTA signal.

Use it where a CTA needs to feel responsive and intentional, not theatrical. A button should confirm action, state, or direction; it should not become a tiny stage play that delays the click.

The main risk is not letting motion stand in for the control. Real buttons and anchors, visible focus, instant response, and clear loading and disabled states come first; the effect is a layer on top of a control that already works.


Install Command

npx hyperiux add arrow-fill-button

Usage Code

page.jsx
import ArrowFillButton from "@/components/effects/arrow-fill-button";

export default function Page() {
    return (
  <>
        <div className="h-screen w-screen flex items-center justify-center relative">
                   <ArrowFillButton
                        className="bg-linear-to-r from-[#ff6b00] to-[#ff5f00]"
                        href="#"
                        btnText="Hover me"
                    />
        </div>
        </>
    );
}

Component Code

ArrowFillButton.jsx
// Built using Hyperiux Vault: https://vault.hyperiux.com
"use client";
import { useEffect, useRef, useState } from "react";
import { ArrowRight } from "lucide-react";
const DEFAULT_HREF = "#";
const COMPACT_LAYOUT_BREAKPOINT = 1280;
const ANIMATION_DURATION_MS = 450;
function ArrowFillButton({ btnText = "Hover Me", href = DEFAULT_HREF, className = "", bgColor = "#ff5f00", textColor = "#ffffff", fillBgColor = "#ffffff", fillTextColor = "#ff5f00", hoverFillBgColor = "#ffffff", hoverFillTextColor = "#ff5f00", arrowColor, hoverArrowColor, ...props }) {
    const [isReady, setIsReady] = useState(false);
    const [isCompactLayout, setIsCompactLayout] = useState(false);
    const [isPressed, setIsPressed] = useState(false);
    const releaseTimeoutRef = useRef(null);
    const usesUtilityBackground = className.includes("bg-") ||
        className.includes("from-") ||
        className.includes("via-") ||
        className.includes("to-");
    useEffect(() => {
        const frame = window.requestAnimationFrame(() => {
            setIsReady(true);
        });
        return () => window.cancelAnimationFrame(frame);
    }, []);
    useEffect(() => {
        const mediaQuery = window.matchMedia(`(max-width: ${COMPACT_LAYOUT_BREAKPOINT - 1}px)`);
        const syncCompactLayout = (event) => {
            const matches = "matches" in event ? event.matches : event.currentTarget.matches;
            setIsCompactLayout(matches);
            if (!matches) {
                setIsPressed(false);
            }
        };
        syncCompactLayout(mediaQuery);
        mediaQuery.addEventListener("change", syncCompactLayout);
        return () => {
            mediaQuery.removeEventListener("change", syncCompactLayout);
        };
    }, []);
    useEffect(() => {
        return () => {
            if (releaseTimeoutRef.current) {
                window.clearTimeout(releaseTimeoutRef.current);
            }
        };
    }, []);
    const clearPressedState = () => {
        if (releaseTimeoutRef.current) {
            window.clearTimeout(releaseTimeoutRef.current);
        }
        releaseTimeoutRef.current = window.setTimeout(() => {
            setIsPressed(false);
            releaseTimeoutRef.current = null;
        }, ANIMATION_DURATION_MS);
    };
    const handlePointerDown = (event) => {
        props.onPointerDown?.(event);
        if (!isCompactLayout || event.pointerType === "mouse") {
            return;
        }
        if (releaseTimeoutRef.current) {
            window.clearTimeout(releaseTimeoutRef.current);
            releaseTimeoutRef.current = null;
        }
        setIsPressed(true);
    };
    const handlePointerUp = (event) => {
        props.onPointerUp?.(event);
        if (!isCompactLayout || event.pointerType === "mouse") {
            return;
        }
        clearPressedState();
    };
    const handlePointerCancel = (event) => {
        props.onPointerCancel?.(event);
        if (!isCompactLayout || event.pointerType === "mouse") {
            return;
        }
        clearPressedState();
    };
    return (<a href={href} {...props} data-pressed={isPressed ? "true" : "false"} onPointerDown={handlePointerDown} onPointerUp={handlePointerUp} onPointerCancel={handlePointerCancel} className={`group relative inline-flex h-[4.2vw] w-fit min-w-fit max-w-none cursor-pointer items-center justify-center overflow-hidden rounded-full px-[3vw] pr-[calc(var(--icon-circle)+var(--icon-right)+2vw)] whitespace-nowrap font-medium text-[1.1vw] leading-none [text-rendering:geometricPrecision] [--icon-circle:3.1vw] [--icon-right:0.55vw] [--circle-inset-y:calc((100%-var(--icon-circle))/2)] max-[1025px]:h-[11vw] max-[1025px]:px-[5vw] max-[1025px]:pr-[calc(var(--icon-circle)+var(--icon-right)+4vw)] max-[1025px]:text-[3vw] max-[1025px]:font-normal max-[1025px]:[--icon-circle:8vw] max-[1025px]:[--icon-right:1.5vw] max-md:h-[15vw] max-md:px-[7vw] max-md:pr-[calc(var(--icon-circle)+var(--icon-right)+5vw)] max-md:text-[4.2vw] max-md:[--icon-circle:11vw] max-md:[--icon-right:2vw] ${usesUtilityBackground ? "" : "bg-(--btn-bg)"} text-(--btn-text) ${className}`} style={{
            "--btn-bg": bgColor,
            "--btn-text": textColor,
            "--btn-fill-bg": fillBgColor,
            "--btn-fill-text": fillTextColor,
            "--btn-fill-bg-hover": hoverFillBgColor,
            "--btn-fill-text-hover": hoverFillTextColor,
            "--btn-arrow": arrowColor || fillTextColor,
            "--btn-arrow-hover": hoverArrowColor || hoverFillTextColor,
            visibility: isReady ? "visible" : "hidden",
        }}>
      <span className="relative z-1 pb-px">{btnText}</span>

      <div aria-hidden="true" className={`pointer-events-none absolute z-2 rounded-full bg-(--btn-fill-bg) inset-[var(--circle-inset-y)_var(--icon-right)_var(--circle-inset-y)_calc(100%-var(--icon-right)-var(--icon-circle))] ${isReady
            ? "transition-all duration-450 ease-[cubic-bezier(0.785,0.135,0.15,0.86)] motion-reduce:transition-none group-hover:bg-(--btn-fill-bg-hover) group-hover:inset-0 group-data-[pressed=true]:bg-(--btn-fill-bg-hover) group-data-[pressed=true]:inset-0"
            : ""}`}/>

      <div aria-hidden="true" className={`pointer-events-none absolute inset-0 z-2 flex items-center px-[3vw] pr-[calc(var(--icon-circle)+var(--icon-right)+2vw)] text-(--btn-fill-text) [clip-path:inset(var(--circle-inset-y)_var(--icon-right)_var(--circle-inset-y)_calc(100%-var(--icon-right)-var(--icon-circle)))] max-[1025px]:px-[5vw] max-[1025px]:pr-[calc(var(--icon-circle)+var(--icon-right)+4vw)] max-md:px-[7vw] max-md:pr-[calc(var(--icon-circle)+var(--icon-right)+5vw)] ${isReady
            ? "transition-all duration-450 ease-[cubic-bezier(0.785,0.135,0.15,0.86)] motion-reduce:transition-none group-hover:text-(--btn-fill-text-hover) group-hover:[clip-path:inset(0_0_0_0)] group-data-[pressed=true]:text-(--btn-fill-text-hover) group-data-[pressed=true]:[clip-path:inset(0_0_0_0)]"
            : ""}`}>
        <span className="relative z-1 pb-px whitespace-nowrap">{btnText}</span>
      </div>

      <span className={`pointer-events-none absolute right-[var(--icon-right)] top-1/2 z-3 inline-flex h-[var(--icon-circle)] w-[var(--icon-circle)] shrink-0 -translate-y-1/2 items-center justify-center overflow-hidden rounded-full bg-(--btn-fill-bg) text-(--btn-arrow) ${isReady
            ? "transition-colors duration-450 ease-[cubic-bezier(0.785,0.135,0.15,0.86)] motion-reduce:transition-none group-hover:bg-(--btn-fill-bg-hover) group-hover:text-(--btn-arrow-hover) group-data-[pressed=true]:bg-(--btn-fill-bg-hover) group-data-[pressed=true]:text-(--btn-arrow-hover)"
            : ""}`} style={{
            WebkitMaskImage: "-webkit-radial-gradient(white, black)",
            maskImage: "radial-gradient(white, black)",
        }} aria-hidden="true">
          <ArrowRight className={`absolute left-1/2 top-1/2 size-[1.5vw] max-[1025px]:size-[4vw] max-md:size-[5vw] translate-x-[-170%] -translate-y-1/2 origin-center scale-0 text-current ${isReady
            ? "transition-transform duration-450 ease-[cubic-bezier(0.785,0.135,0.15,0.86)] motion-reduce:transition-none group-hover:-translate-x-1/2 group-hover:-translate-y-1/2 group-hover:scale-100 group-data-[pressed=true]:-translate-x-1/2 group-data-[pressed=true]:-translate-y-1/2 group-data-[pressed=true]:scale-100"
            : ""}`} strokeWidth={1.8}/>

          <ArrowRight className={`absolute left-1/2 top-1/2 size-[1.5vw] max-[1025px]:size-[4vw] max-md:size-[5vw] -translate-x-1/2 -translate-y-1/2 origin-center text-current ${isReady
            ? "transition-transform duration-[450ms] ease-[cubic-bezier(0.785,0.135,0.15,0.86)] motion-reduce:transition-none group-hover:translate-x-[70%] group-hover:-translate-y-1/2 group-hover:scale-0 group-data-[pressed=true]:translate-x-[70%] group-data-[pressed=true]:-translate-y-1/2 group-data-[pressed=true]:scale-0"
            : ""}`} strokeWidth={1.8}/>
        </span>
    </a>);
}
export default ArrowFillButton;

Example Production Use Case

Use this as button-interaction implementation guidance. Verify the shipped element semantics, anchor/button mode, disabled and loading states, focus-visible behavior, keyboard activation, and reduced-motion handling before relying on exact props, defaults, imports, or installation steps.


Best Used For

  • Primary CTAs where direction, fill, and label should read as one confident action.
  • Hero and product sections where feedback must remain faster than hesitation.
  • Arrow Fill Button adds feedback to an action without weakening semantics, focus, or activation speed.

Not For

Not for destructive actions, unclear states, or places where animation hides the label or delays activation.


Performance Budget

Keep feedback fast, avoid layout-triggering letter movement, and preserve instant activation.


Accessibility and Mobile

Use real buttons or anchors, visible focus-visible styles, and clear loading/disabled states. Touch targets should stay large enough on mobile.


Common Mistakes

  • Building Arrow Fill Button with divs instead of real buttons or links.
  • Hiding the label during hover.
  • Forgetting loading and disabled states.

Changelog

v1.1.0

Jul 21, 2026
Added prefers-reduced-motion support: fill/clip-path/arrow hover transitions are disabled or shown in their end state instead of animating

Props

PropTypeDefaultDescription
btnTextstringHover MeButton label text.
bgColorcolor#ff5f00Resting button background color.
textColorcolor#ffffffResting label color.
fillBgColorcolor#ffffffCircle fill color before hover.
fillTextColorcolor#ff5f00Label color inside the fill before hover.
hoverFillBgColorcolor#ffffffExpanded fill color on hover.
hoverFillTextColorcolor#ff5f00Label color inside the expanded fill on hover.
arrowColorcolor—Arrow color before hover. Falls back to fillTextColor when unset.
hoverArrowColorcolor—Arrow color after hover. Falls back to hoverFillTextColor when unset.

Frequently Asked Questions

When should I use Arrow Fill Button?

Use it when a primary CTA needs arrow and fill moving as one direction-and-feedback signal without delaying the action.

Request a Custom Button Animation

Need a custom effect? Tell us what to create.