An anchor-styled button whose underline sweeps in from the left on hover and retracts on leave.
npx shadcn@latest add @paragon/link-buttonimport * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cn } from "@/lib/utils";
export interface LinkButtonProps extends React.ComponentProps<"a"> {
/** Render the child element instead of an <a> (e.g. a framework Link). */
asChild?: boolean;
/** Disables the underline sweep; hover shows the underline instantly. */
static?: boolean;
}
/**
* An anchor-styled button whose underline sweeps in from the left on hover
* and retracts to the right on leave.
*
* Technique: the underline is an `after` pseudo-element scaled on the X axis.
* At rest its transform-origin is `right`; on hover the origin flips to
* `left`, so the bar grows leftward-in and shrinks rightward-out from a
* single scaleX transition. The sweep is gated behind
* `(hover: hover) and (pointer: fine)` so touch devices never see a stuck
* half-drawn underline. Enter runs 150ms ease-out; exit runs half that with
* the exit curve.
*/
export function LinkButton({
className,
asChild = false,
static: isStatic = false,
...props
}: LinkButtonProps) {
const Comp = asChild ? Slot : "a";
return (
<Comp
data-slot="link-button"
className={cn(
"relative inline-flex items-center gap-1 rounded-sm text-sm font-medium text-primary outline-none",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"[&_svg]:pointer-events-none [&_svg]:size-3.5 [&_svg]:shrink-0",
// ≥40px hit area — the visual line box is ~20px tall.
"before:absolute before:top-1/2 before:left-1/2 before:h-10 before:w-full before:min-w-10 before:-translate-x-1/2 before:-translate-y-1/2",
// The underline bar: scaleX 0↔1, origin right at rest so it retracts
// toward the right edge on leave.
"after:pointer-events-none after:absolute after:inset-x-0 after:-bottom-0.5 after:h-px after:origin-right after:scale-x-0 after:bg-current",
isStatic
? // No motion: the underline simply appears and disappears.
"[@media(hover:hover)_and_(pointer:fine)]:hover:after:scale-x-100"
: [
// Exit (base state): half duration, exit curve.
"after:transition-transform after:duration-[calc(var(--duration-quick)/2)] after:ease-(--ease-exit)",
// Enter (hover): origin flips to left, full duration, ease-out.
"[@media(hover:hover)_and_(pointer:fine)]:hover:after:origin-left",
"[@media(hover:hover)_and_(pointer:fine)]:hover:after:scale-x-100",
"[@media(hover:hover)_and_(pointer:fine)]:hover:after:duration-(--duration-quick)",
"[@media(hover:hover)_and_(pointer:fine)]:hover:after:ease-(--ease-out)",
// Reduced motion: keep the underline, drop the sweep.
"motion-reduce:after:transition-none",
],
className,
)}
{...props}
/>
);
}