A lightweight animated link button for navigation, secondary actions, and product pages that need subtle interaction feedback.

Link Button gives a button or link a small, useful moment of feedback: a link earns a small interaction state.
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 thing to watch is semantics. Keep real button and anchor elements, visible focus, instant activation, and clear loading/disabled states. Motion sits on top of the action; it does not replace it.
npx hyperiux add link-buttonimport LinkButton from "@/components/effects/link-button";
export default function Page() {
return (
<>
<div className="h-screen w-screen flex items-center justify-center relative">
<div className="w-fit mx-auto">
<LinkButton
btnText="Hover me"
href="#"
iconClassName="size-[2vw] mt-[0.2vw] max-sm:size-[4vw]"
className="text-[2vw] hover:text-[#ff6b00] max-sm:text-[4.5vw]"
/>
</div>
</div>
</>
);
}
// Built using Hyperiux Vault: https://vault.hyperiux.com
"use client";
import { ArrowRight } from "lucide-react";
import { useLayoutEffect, useState } from "react";
const DEFAULT_HREF = "#";
const TABLET_BREAKPOINT = 1025;
export default function LinkButton({ btnText = "Hover me", mobileText = "Click me", textColor = "#ffffff", hoverColor = "#ff6b00", clickedColor = "#ff6b00", href = DEFAULT_HREF, className = "", linkProps = {}, icon: Icon = ArrowRight, iconClassName = "max-md:size-[6vw] max-[1025px]:size-[4vw]", showIcon = true, underlineHeight = 1.5, transitionDuration = 0.5, disableNavigation = false, onClick, ...props }) {
const [isIconRotated, setIsIconRotated] = useState(false);
const [isCompactViewport, setIsCompactViewport] = useState(false);
const [hasMeasuredViewport, setHasMeasuredViewport] = useState(false);
useLayoutEffect(() => {
const onResize = () => {
setIsCompactViewport(window.innerWidth <= TABLET_BREAKPOINT);
setHasMeasuredViewport(true);
};
onResize();
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("resize", onResize);
};
}, []);
const onLinkClick = (event) => {
setIsIconRotated((previousValue) => !previousValue);
if (disableNavigation) {
event.preventDefault();
}
onClick?.(event);
};
const { style: customStyle, ...restProps } = props;
const displayText = hasMeasuredViewport && isCompactViewport ? mobileText : btnText;
const mergedStyle = {
"--underline-height": `${underlineHeight}px`,
"--transition-duration": `${transitionDuration}s`,
...(hoverColor ? { "--link-hover-color": hoverColor } : null),
...(textColor ? { "--link-text-color": textColor } : null),
...(hasMeasuredViewport && isCompactViewport && isIconRotated ? { color: clickedColor } : null),
...customStyle,
};
const iconClassNames = `${isIconRotated ? "motion-safe:-rotate-45" : ""} ${!isCompactViewport ? "motion-safe:group-hover:-rotate-45" : ""} size-[1.1vw] max-[1025px]:size-[2vw] max-md:size-[3.5vw] transition-transform duration-[var(--transition-duration)] motion-reduce:rotate-0 motion-reduce:transition-none ${iconClassName}`;
return (<>
<a href={href} {...linkProps} {...restProps} onClick={onLinkClick} className={`group block w-fit cursor-pointer text-[1.1vw] leading-[1.2] duration-300 text-(--link-text-color) hover:text-(--link-hover-color) max-[1025px]:text-[4vw] max-md:text-[5.5vw] ${className}`} style={mergedStyle}>
<div className="flex items-center justify-start gap-2">
<span className={`btn-link-line relative inline-block w-fit after:absolute after:left-0 after:bottom-[-2%] after:h-[var(--underline-height)] after:w-full after:bg-current after:content-[''] after:transition-transform after:duration-[var(--transition-duration)] after:ease-[cubic-bezier(0.62,0.05,0.01,0.99)] motion-reduce:after:transition-none ${hasMeasuredViewport && isCompactViewport
? isIconRotated
? "after:origin-right after:scale-x-0 motion-safe:after:origin-left motion-safe:after:scale-x-100"
: "after:origin-right after:scale-x-0"
: "after:origin-right after:scale-x-0 motion-safe:group-hover:after:origin-left motion-safe:group-hover:after:scale-x-100"}`}>
{displayText}
</span>
<span className="sr-only">About {href}</span>
{showIcon && Icon && <Icon className={iconClassNames}/>}
</div>
</a>
<style jsx>{`
li :global(.btn-link-line)::after {
bottom: -20%;
}
`}</style>
</>);
}
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.
Not for destructive actions, unclear states, or places where animation hides the label or delays activation.
Keep feedback fast, avoid layout-triggering letter movement, and preserve instant activation.
Use real buttons or anchors, visible focus-visible styles, and clear loading/disabled states. Touch targets should stay large enough on mobile.
| Prop | Type | Default | Description |
|---|---|---|---|
btnText | string | Hover me | Button label text. |
textColor | color | #ffffff | Resting label and underline color. |
hoverColor | color | #ff6b00 | Hover label and underline color. |
underlineHeight | number | 1.5 | Underline thickness in pixels. |
showIcon | boolean | true | Shows or hides the arrow icon. |
transitionDuration | number | 0.5 | Animation duration in seconds. |
Use it when a navigation or secondary link needs a small, lightweight interaction state — without delaying the action.
Yes. Use `<button>` for actions and `<a>` for navigation.
Use visible focus-visible styles and support keyboard activation.
Loading should be visually clear and programmatically communicated without hiding the label.
Remove long staggers, scrambles, and looping emphasis while keeping instant state feedback.
Need a custom effect? Tell us what to create.