Link

Next.js Link wrapper with variant and color options. Renders as a button when no href is provided.

API Reference

PropTypeDefault
hrefstring
variant"base" | "base-underline" | "underline" | "arrow""base"
color"inherit" | "root" | "primary" | "secondary""inherit"
isExternalbooleanfalse

Variants

Base

default
<Link href="/about">Base link</Link>

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>

Colors

<Link href="#" variant="underline" color="root">root</Link>
<Link href="#" variant="underline" color="primary">primary</Link>
<Link href="#" variant="underline" color="secondary">secondary</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>

Basic Usage

import { Link } from "@/ui";

<Link href="/about">About</Link>

Source

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>
  );
}