Skip to content
Component

Button link

An anchor that looks like the Button, for navigation that has to stay a real link.

  • #arc
  • #component
  • #button
  • #link
  • #navigation
  • #cta

Install

One command installs the files and packages this item needs.

Terminalbash
npx shadcn@latest add https://dsui.vercel.app/r/button-link.json

button-link.module.csscss
/* Everything the look needs comes from components/arc/button; this file only adds what an anchor needs and a button does not. */
.link { text-decoration: none; transition: color var(--duration-fast) var(--ease-standard), background var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard), box-shadow var(--duration-fast) var(--ease-standard), transform var(--duration-fast) var(--ease-standard); }
/* The Button presses on a Motion spring, which needs a real button element, so the link does the same ~.97 in CSS. */
.link:active:not([aria-disabled="true"]) { transform: scale(.97); }
.label { display: inline-flex; align-items: center; gap: var(--space-2); white-space: nowrap; }
.label svg { flex: none; }
/* An anchor is never `:disabled`, so the Button's disabled rules cannot reach it: the same look is repeated here. */
.link[aria-disabled="true"] { color: color-mix(in oklab, var(--foreground) 55%, transparent); background: var(--border); border-color: transparent; box-shadow: none; cursor: not-allowed; pointer-events: none; }
@media (prefers-reduced-motion: reduce) { .link { transition: none; } .link:active { transform: none; } }
button-link.tsxtsx
"use client";

import type { AnchorHTMLAttributes, ReactNode } from "react";
import buttonStyles from "../button/button.module.css";
import { ArcLink } from "../lib/arc-provider";
import { playUiSound } from "../sound/play";
import styles from "./button-link.module.css";

export type ButtonLinkVariant = "primary" | "secondary" | "ghost" | "accent" | "danger";
export type ButtonLinkSize = "sm" | "md" | "lg";

export interface ButtonLinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href"> {
  href: string;
  variant?: ButtonLinkVariant;
  size?: ButtonLinkSize;
  /** Greys the link out and stops it being followed or focused. */
  disabled?: boolean;
  /** Skip the click sound, for a link that already makes its own sound. */
  silent?: boolean;
  children?: ReactNode;
}

/**
 * A link that looks like the Button. Going somewhere is a navigation, not an action, so this stays an anchor: it opens
 * in a new tab with the usual modifiers, copies as a URL and is announced as a link. It borrows the Button's own
 * stylesheet so the two never drift apart, and adds the anchor-only parts — a CSS press (the Button's Motion press
 * needs a real `<button>`) and a disabled state built on `aria-disabled`, which an anchor has instead of `:disabled`.
 */
export function ButtonLink({
  className,
  href,
  variant = "primary",
  size = "md",
  disabled = false,
  silent = false,
  onClick,
  children,
  ...props
}: ButtonLinkProps) {
  const classes = [buttonStyles.button, buttonStyles[variant], buttonStyles[size], styles.link, className].filter(Boolean).join(" ");
  return (
    <ArcLink
      {...props}
      href={href}
      className={classes}
      aria-disabled={disabled || undefined}
      // A disabled link keeps its place in the page but leaves the tab order, the way a disabled button does.
      tabIndex={disabled ? -1 : props.tabIndex}
      onClick={event => {
        if (disabled) { event.preventDefault(); return; }
        if (!silent) playUiSound("click");
        onClick?.(event);
      }}
    >
      <span className={styles.label}>{children}</span>
    </ArcLink>
  );
}

ButtonLink.displayName = "ButtonLink";

export default ButtonLink;

Used items

Used by