component — navigation

Link

Ink text link built on react-aria-components, styled with the quebi design system. Underlined at rest so it stays distinguishable from body copy; add no-underline for navigation. External hrefs render as a plain anchor.

  • navigation
  • link
  • anchor
  • interactive

06 examples

Default

Ink, underlined at rest; the underline drops away from the text on hover.

Inline in text

Sits inside body copy and takes its font. The underline is what tells it apart from the prose around it — ink against the body grey is not a cue WCAG 1.4.1 accepts on its own.

Read the documentation or browse the component gallery to get started.

External

http(s):, mailto:, and tel: hrefs render as a plain anchor that opens normally.

Without the underline

Navigation rows and breadcrumbs are not inside a block of text, so they opt out with no-underline; the underline comes back on hover.

Call to action

A standalone link that asks for something takes the Outfit link role: a lowercase verb phrase ending in an arrow.

Disabled

Non-interactive and dimmed.

unavailable link

source

link.tsx

Copy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.

"use client"

import {
  composeRenderProps,
  Link as LinkPrimitive,
  type LinkProps as LinkPrimitiveProps,
} from "react-aria-components"
import { cn } from "@/lib/utils"

/**
 * Link — quebi design system
 *
 * The design's text link: signal (a link is state — it goes somewhere), a 1px
 * underline at a 3px offset that drops to 5px on hover. Built on react-aria-components for the accessibility baseline.
 * External hrefs (http(s):, mailto:, tel:) render as a plain anchor so they keep
 * working outside a router context.
 *
 * No font of its own: a link inside prose is set in the prose's Inter at the
 * prose's size and weight, and the underline is what sets it apart (WCAG 1.4.1
 * — a hue against the body grey is not a second cue on its own). A standalone
 * call to action takes the design's Outfit link role from the caller —
 * `className="font-display text-quebi-link"`, a lowercase verb phrase ending
 * in → — rather than a variant, because the only difference is two utilities.
 * Places where a link is not inside prose — nav rows, breadcrumbs, sidebar
 * items — opt out with `no-underline`, set their own text colour, and still
 * underline on hover.
 */
const EXTERNAL_HREF_RE = /^(https?:|mailto:|tel:)/i

const BASE_CLASSES = [
  "text-quebi-signal",
  "underline decoration-1 underline-offset-3",
  "transition-[text-underline-offset] duration-150 ease-out",
  "hover:underline hover:underline-offset-5",
  "outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-quebi-focus focus-visible:ring-offset-2 focus-visible:ring-offset-quebi-bg",
  "disabled:cursor-default disabled:opacity-45 disabled:no-underline",
  "data-disabled:cursor-default data-disabled:opacity-45 data-disabled:no-underline",
]

export interface LinkProps extends LinkPrimitiveProps {
  ref?: React.Ref<HTMLAnchorElement>
}

export function Link({ className, ref, ...props }: LinkProps) {
  const href = "href" in props ? props.href : undefined
  if (typeof href === "string" && EXTERNAL_HREF_RE.test(href)) {
    return <ExternalLink ref={ref} className={className} {...props} href={href} />
  }
  return (
    <LinkPrimitive
      ref={ref}
      // `className` may be a render-prop function (react-aria passes it
      // isHovered/isPressed/... ), which is how a caller styles a link per
      // state. `cn` is clsx underneath and clsx drops a function silently, so
      // merging it directly threw the caller's classes away and left only
      // BASE_CLASSES behind — a sidebar item rendered as underlined prose.
      // composeRenderProps keeps both shapes: it calls the function and merges
      // what it returns, and passes a plain string straight through.
      className={composeRenderProps(className, (resolved) =>
        cn([...BASE_CLASSES, "href" in props && "cursor-pointer"], resolved),
      )}
      {...props}
    />
  )
}

type ExternalLinkProps = LinkProps & { href: string }

function ExternalLink({
  className,
  children,
  style,
  ref,
  href,
  isDisabled: _isDisabled,
  onPress: _onPress,
  onPressStart: _onPressStart,
  onPressEnd: _onPressEnd,
  onPressChange: _onPressChange,
  onPressUp: _onPressUp,
  slot: _slot,
  routerOptions: _routerOptions,
  ...rest
}: ExternalLinkProps) {
  const anchorProps = rest as React.AnchorHTMLAttributes<HTMLAnchorElement>
  return (
    // biome-ignore lint/correctness/noRestrictedElements: this is the anchor's wrapping layer. An external href (http(s)/mailto/tel) must render a plain <a> so it keeps working outside a router context, which is exactly what react-aria's Link cannot do here.
    <a
      ref={ref}
      href={href}
      className={cn(
        [...BASE_CLASSES, "cursor-pointer"],
        typeof className === "function" ? undefined : className,
      )}
      style={typeof style === "function" ? undefined : style}
      {...anchorProps}
    >
      {typeof children === "function" ? null : children}
    </a>
  )
}