Skip to content
gnosticaignostic-ui
Esc
↑↓navigate↵open⌘Jpreview
On this page

Link

Astro anchors for page paths, external URLs, phone numbers, and obfuscated email.

Page
Button
External
Astro docs
Phone
+1 555 555 0100
Email
hello@example.com

Installation

bunx shadcn@latest add @gnostic/link
npx shadcn@latest add @gnostic/link
pnpm dlx shadcn@latest add @gnostic/link

Complete the Installation guide first. Then copy the component files below.

No extra packages are required.

Copy the files

src/components/ui/link/index.ts
export { default as Link } from "./link.astro"

export type { LinkProps } from "./types.ts"
src/components/ui/link/link.astro
---
import type { HTMLAttributes } from "astro/types"

import { cn } from "cn"
import { buttonVariants } from "../button/index.ts"
import type { LinkProps } from "./types.ts"

/** Anchor attributes pass through. `href` is required by `LinkProps`. */
type Props = Omit<HTMLAttributes<"a">, "href"> & LinkProps

const {
  class: className,
  "class:list": classList,
  variant,
  size = "lg",
  externalInNewTab = true,
  prefetch = true,
  href: rawHref,
  title,
  target: targetProp,
  rel: relProp,
  ...rest
} = Astro.props

const href = rawHref ?? "#"

const isExternal = href.startsWith("http")
const isHash = href.startsWith("#")
const isMail = href.startsWith("mailto:")
const isTel = href.startsWith("tel:")
const isInternal = !isExternal && !isHash && !isMail && !isTel
const isContact = isMail || isTel

const target =
  targetProp ?? (isExternal && externalInNewTab ? "_blank" : undefined)

const rel =
  relProp ??
  (isExternal
    ? "noopener nofollow"
    : isInternal && prefetch
      ? "prefetch"
      : undefined)

/** Prefix site BASE_URL for root-relative internal paths. */
let parsedUrl = href
if (!isExternal && !isHash && href.startsWith("/")) {
  const basePrefix = (import.meta.env.BASE_URL ?? "/").replace(/\/$/, "")
  parsedUrl = `${basePrefix}${href}`
}

/** Obfuscate mailto/tel hrefs so scrapers don't see them in the markup. */
let dataContact: string | undefined
if (isContact) {
  parsedUrl = "#"
  dataContact = btoa(href)
}
---

<a
  data-slot="button"
  data-size={size}
  data-variant={variant ?? "default"}
  href={parsedUrl}
  title={title || undefined}
  class:list={[
    "link",
    {
      "is-external": isExternal,
      "is-internal": isInternal,
      "is-hash": isHash,
      "is-mail": isMail,
      "is-tel": isTel,
    },
    cn(buttonVariants({ variant, size }), className),
    classList,
  ]}
  data-contact={dataContact}
  target={target}
  rel={rel}
  {...rest}
>
  <slot />
</a>
<script>
  /**
   * One delegated listener for all mail/tel links. Astro already includes this
   * script once per page; delegation avoids attaching a listener to each link
   * and still works if a contact link is added after the initial render.
   */
  document.addEventListener("click", (event) => {
    const link = (event.target as Element | null)?.closest(
      "a.link.is-mail, a.link.is-tel",
    )
    if (!(link instanceof HTMLAnchorElement)) return

    const contactHrefRaw = link.getAttribute("data-contact")
    if (!contactHrefRaw) return

    event.preventDefault()
    window.location.href = atob(contactHrefRaw)
  })
</script>
src/components/ui/link/types.ts
import type { ButtonVariantProps } from '../button/button-variants.ts'

/**
 * Props for a destination anchor.
 * Standard anchor attributes such as `class`, `target`, `title`, and `rel`
 * are also accepted on the component.
 */
export interface LinkProps extends ButtonVariantProps {
    /**
     * Destination address.
     *
     * A site path stays on this site. An `http` URL opens in a new tab.
     * A `mailto:` or `tel:` value is stored until the visitor clicks.
     */
    href: string
    /**
     * Open an external URL in a new tab.
     *
     * @default true
     */
    externalInNewTab?: boolean
    /**
     * Add `rel="prefetch"` on an internal path.
     *
     * @default true
     */
    prefetch?: boolean
}

Add a preset (optional)

Default Coss works with the component classes. To use another design, inspect and save its CSS to src/styles/presets/. Import it after the global stylesheet, then set data-preset on your layout or a container. These files also work with JavaScript disabled.

Usage

import { Link } from "@/components/ui/link"
<Link href="/components/button">Button</Link>
<Link href="https://docs.astro.build">Astro docs</Link>
<Link href="tel:+15555550100">+1 555 555 0100</Link>
<Link href="mailto:hello@example.com">hello@example.com</Link>

Link uses the same variant and size values as Button. The size default is lg. Use variant="link" for a text link.

Destinations

Link reads the start of href and sets the anchor behavior.

  • A site path stays on this site. A path that starts with / receives the site BASE_URL. The anchor gets rel="prefetch" unless you set prefetch={false}.
  • An http or https URL opens in a new tab. The anchor gets rel="noopener nofollow". Set externalInNewTab={false} to keep the same tab.
  • A value that starts with # stays on the current page.
  • A mailto: or tel: value is stored as base64 in data-contact. The rendered anchor uses href="#". A click reads that value and opens the mail or phone app. The address stays out of the page source.

Set target or rel when a link needs a value other than these defaults.

Props

PropType
hrefstring

Destination address. A site path stays on this site. An `http` URL opens in a new tab. A `mailto:` or `tel:` value is stored until the visitor clicks.

Typestring
externalInNewTab?boolean

Open an external URL in a new tab.

Typeboolean
Defaulttrue
prefetch?boolean

Add `rel="prefetch"` on an internal path.

Typeboolean
Defaulttrue
size?"default" | "icon" | "icon-lg" | "icon-sm" | "icon-xl" | "icon-xs" | "lg" | "sm" | "xl" | "xs" | null | undefined
Type"default" | "icon" | "icon-lg" | "icon-sm" | "icon-xl" | "icon-xs" | "lg" | "sm" | "xl" | "xs" | null | undefined
variant?"default" | "destructive" | "ghost" | "link" | "outline" | "secondary" | "glass" | "surface" | "destructive-outline" | null | undefined
Type"default" | "destructive" | "ghost" | "link" | "outline" | "secondary" | "glass" | "surface" | "destructive-outline" | null | undefined

Anchor attributes such as class, target, title, and rel pass through. target and rel replace the defaults when you set them. Read the installed source for the component.

Use Link for a destination. Use Button for an action that stays on the page, such as submitting a form. Put the label in the default slot. Link accepts the Button variants, so a destination can look like a button or a text link.

Keyboard and behavior

Link is a native anchor. Enter activates it. An external URL opens a new tab. A phone or email click uses one page script. That script decodes data-contact and opens the address. The same script handles contact links added after the first render.

Theme and layout

The styles come from Button variants and the shared theme tokens. Use the default variant for a primary destination and variant="link" for text in a sentence. A class on one link does not change the site palette.

Use Button for actions, Breadcrumb for a page trail, and Navigation Menu for a site menu.

Was this page helpful?