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
1280px
Install
One command installs the files and packages this item needs.
TerminalTerminalTerminalbash
npx shadcn@latest add https://dsui.vercel.app/r/button-link.jsonFiles
components/arc/button-link/button-link.module.csscomponents/arc/button-link/button-link.tsx
Registry items it pulls in
Source
View as Markdownbutton-link.module.cssbutton-link.module.cssbutton-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.tsxbutton-link.tsxbutton-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;