Link
Next.js Link wrapper with variant and color options. Renders as a button when no href is provided.
| Prop | Type | Default |
|---|---|---|
| href | string | — |
| variant | "base" | "base-underline" | "underline" | "arrow" | "base" |
| color | "inherit" | "root" | "primary" | "secondary" | "inherit" |
| isExternal | boolean | false |
Base Underline
Static underline with hover opacity.
<Link href="/about" variant="base-underline">Base underline link</Link>Underline
Animated underline that expands on hover.
<Link href="/about" variant="underline">Underline link</Link>Arrow
Animated underline with a trailing arrow icon.
<Link href="/about" variant="arrow">Arrow link</Link>External
Links starting with http or // are automatically treated as external. The arrow variant swaps to a north-east icon for external links.
<Link href="https://example.com" variant="arrow" color="primary" isExternal>
External link
</Link>import { Link } from "@/ui";
<Link href="/about">About</Link>src/ui/Link.tsx
import NextLink from "next/link";
import type { ComponentProps } from "react";
import { cn } from "@/utils";
import { ArrowForwardIcon, NorthEastIcon } from "./icons";
type LinkBaseProps = Omit<
ComponentProps<typeof NextLink> &
React.ButtonHTMLAttributes<HTMLButtonElement>,
"color" | "href"
>;
export interface LinkProps extends LinkBaseProps {
href?: string;
variant?: "base" | "base-underline" | "underline" | "arrow";
color?: "inherit" | "root" | "primary" | "secondary";
isExternal?: boolean;
}
export function Link({
variant = "base",
color = "inherit",
isExternal,
href,
children,
className,
...props
}: LinkProps) {
const isAutoExternal =
isExternal ??
(typeof href === "string" &&
(href.startsWith("http") || href.startsWith("//")));
const isAnimatedVariant = variant === "underline" || variant === "arrow";
const classes = cn(
isAnimatedVariant &&
"group relative inline-flex cursor-pointer items-center gap-1 font-medium leading-none transition-colors",
isAnimatedVariant &&
"after:absolute after:-bottom-1 after:left-0 after:h-0.5 after:w-0 after:bg-current after:transition-[width] after:duration-200 hover:after:w-full",
variant === "base-underline" &&
"underline underline-offset-2 transition-opacity hover:opacity-80",
color === "root" && "text-root-contrast",
color === "primary" && "text-primary",
color === "secondary" && "text-secondary",
className,
);
const ArrowIcon = isAutoExternal ? NorthEastIcon : ArrowForwardIcon;
const content =
variant === "arrow" ? (
<>
{children}
<ArrowIcon
size={4}
className="shrink-0 transition-transform group-hover:translate-x-1"
/>
</>
) : (
children
);
if (href) {
return (
<NextLink
href={href}
target={isAutoExternal ? "_blank" : undefined}
rel={isAutoExternal ? "noopener noreferrer" : undefined}
className={classes}
{...(props as Omit<ComponentProps<typeof NextLink>, "href">)}
>
{content}
</NextLink>
);
}
return (
<button
className={classes}
{...(props as React.ButtonHTMLAttributes<HTMLButtonElement>)}
>
{content}
</button>
);
}