"use client";
import * as React from "react";
export type AnimatedLinkVariant = "slide" | "grow" | "double" | "highlight" | "arrow" | "wave";
export interface AnimatedLinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
/** Underline animation. */
variant?: AnimatedLinkVariant;
/** Colour of the animated line / highlight (hex). */
color?: string;
/** Thickness of the animated line in px. */
thickness?: number;
/** Duration of the draw in ms. */
duration?: number;
/** Gap between the text baseline area and the line, in px. */
offset?: number;
/** Keep a faint resting underline so the link is identifiable without hover or colour. */
restingLine?: boolean;
/** Open in a new tab: adds target/rel, an ↗ indicator and screen-reader text. */
external?: boolean;
/** Screen-reader text appended for external links. */
externalLabel?: string;
}
/* --------------------------------- helpers --------------------------------- */
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
const WORD_JOINER = "\u2060";
const WAVE_WIDTH = 16;
const WAVE_HEIGHT = 6;
/** A single wave period as an SVG data URI; the stroke keeps its width when the tile is squashed. */
function waveImage(color: string, thickness: number) {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${WAVE_WIDTH}" height="${WAVE_HEIGHT}" viewBox="0 0 16 6" preserveAspectRatio="none"><path d="M0 3 C4 0 4 0 8 3 S12 6 16 3" fill="none" stroke="${color}" stroke-width="${thickness}" vector-effect="non-scaling-stroke" stroke-linecap="square"/></svg>`;
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
}
const line = (c: string) => `linear-gradient(${c},${c})`;
/**
* Underlines are background images on the inline <a> itself, so they follow every wrapped line.
* Slices (the default box-decoration-break) treat the wrapped fragments as one long strip, so the
* line draws across the first line and continues on the next.
*/
function underlineStyle(variant: AnimatedLinkVariant, on: boolean, color: string, t: number, resting: boolean): React.CSSProperties {
const restColor = "color-mix(in oklab, currentColor 28%, transparent)";
const rest = { image: line(restColor), size: `100% ${resting ? 1 : 0}px`, position: "0 100%" };
const drawn = on ? "100%" : "0%";
// Draw from the left, leave towards the right: the anchor flips sides while the size is 0.
const slideAnchor = on ? "0%" : "100%";
type Layer = { image: string; size: string; position: string; repeat?: string };
let layers: Layer[];
switch (variant) {
case "grow":
layers = [{ image: line(color), size: `${drawn} ${t}px`, position: "50% 100%" }];
break;
case "double":
layers = [
{ image: line(color), size: `${drawn} ${t}px`, position: "0% 100%" },
{ image: line(color), size: `${drawn} ${t}px`, position: `100% calc(100% - ${t + 2}px)` },
];
break;
case "highlight":
layers = [
{
image: line(`color-mix(in oklab, ${color} 34%, transparent)`),
size: `${drawn} 100%`,
position: `${slideAnchor} 100%`,
},
];
break;
case "wave":
// The wave grows out of a flat line: the tile height animates from 0 to its full amplitude.
layers = [{ image: waveImage(color, t), size: `${WAVE_WIDTH}px ${on ? WAVE_HEIGHT : 0}px`, position: "0 100%", repeat: "repeat-x" }];
break;
case "slide":
case "arrow":
default:
layers = [{ image: line(color), size: `${drawn} ${t}px`, position: `${slideAnchor} 100%` }];
}
// The resting line hides while the wave replaces it.
const all: Layer[] = [...layers, variant === "wave" && on ? { ...rest, size: "100% 0px" } : rest];
return {
backgroundImage: all.map((l) => l.image).join(", "),
backgroundSize: all.map((l) => l.size).join(", "),
backgroundPosition: all.map((l) => l.position).join(", "),
backgroundRepeat: all.map((l) => l.repeat ?? "no-repeat").join(", "),
};
}
/**
* An inline link with refined hover / focus underline animations. Lines are painted as background
* images on the <a>, so they work across line wraps, and a faint resting underline keeps links
* recognisable at rest.
*/
export const AnimatedLink = React.forwardRef<HTMLAnchorElement, AnimatedLinkProps>(function AnimatedLink(
{
variant = "slide",
color = "#FF7448",
thickness = 2,
duration = 450,
offset = 2,
restingLine = true,
external = false,
externalLabel = "(opens in new tab)",
className = "",
style,
children,
target,
rel,
onPointerEnter,
onPointerLeave,
onFocus,
onBlur,
...props
},
forwardedRef,
) {
const reduceMotion = usePrefersReducedMotion();
const [hovered, setHovered] = React.useState(false);
const [focused, setFocused] = React.useState(false);
const on = hovered || focused;
const ref = React.useRef<HTMLAnchorElement>(null);
React.useImperativeHandle(forwardedRef, () => ref.current as HTMLAnchorElement, []);
// The wave flows sideways while engaged (Web Animations; it only runs during hover / focus).
React.useEffect(() => {
const el = ref.current;
if (!el || variant !== "wave" || !on || reduceMotion || typeof el.animate !== "function") return;
const restPosition = "0 100%";
const animation = el.animate(
[
{ backgroundPosition: `0px 100%, ${restPosition}` },
{ backgroundPosition: `${-WAVE_WIDTH}px 100%, ${restPosition}` },
],
{ duration: 700, iterations: Infinity, easing: "linear" },
);
return () => animation.cancel();
}, [variant, on, reduceMotion]);
const transition = reduceMotion
? "none"
: `background-size ${duration}ms cubic-bezier(0.22, 1, 0.36, 1), color 200ms ease`;
const wavePad = variant === "wave" ? WAVE_HEIGHT - 2 : variant === "double" ? thickness + 2 : 0;
return (
<a
ref={ref}
target={external ? "_blank" : target}
rel={external ? (rel ? `${rel} noopener noreferrer` : "noopener noreferrer") : rel}
data-variant={variant}
onPointerEnter={(event) => {
onPointerEnter?.(event);
// Touch taps fire pointerenter without a hover; let focus drive those instead.
if (event.pointerType === "mouse") setHovered(true);
}}
onPointerLeave={(event) => {
onPointerLeave?.(event);
setHovered(false);
}}
onFocus={(event) => {
onFocus?.(event);
setFocused(event.currentTarget.matches(":focus-visible"));
}}
onBlur={(event) => {
onBlur?.(event);
setFocused(false);
}}
className={`rounded-[2px] text-inherit no-underline outline-hidden focus-visible:outline-2 focus-visible:outline-offset-[3px] focus-visible:outline-solid focus-visible:outline-[var(--al-color)] ${className}`}
style={{
["--al-color" as string]: color,
paddingBottom: offset + wavePad,
transition,
...underlineStyle(variant, on, color, thickness, restingLine),
...style,
}}
{...props}
>
{children}
{(variant === "arrow" || external) && (
// A text glyph (not an inline-block), so the word joiner keeps it on the last word's line.
// Relative offsets work on inline boxes, where transforms do not.
<span
aria-hidden="true"
className="relative ml-[0.12em] font-normal"
style={{
left: on ? (external ? 2 : 3) : 0,
top: on && external ? -2 : 0,
transition: reduceMotion ? "none" : "left 300ms cubic-bezier(0.22, 1, 0.36, 1), top 300ms cubic-bezier(0.22, 1, 0.36, 1)",
}}
>
{WORD_JOINER}
{external ? "↗" : "→"}
</span>
)}
{external && <span className="sr-only"> {externalLabel}</span>}
</a>
);
});