Skip to content
Domphy

Link Button

Use linkButton on an <a> element to give it the visual appearance of a button while preserving full link semantics — href, middle-click to open in new tab, right-click context menu, and browser history.

Identical styling to button(), but the host element must be <a>. A console warning fires if applied to any other tag.

Props

PropTypeDefaultDescription
colorValueOrState<ThemeColor>"primary" (ghost defaults "neutral")Button color tone. Reactive — pass a State<ThemeColor> to switch theme at runtime.
variant"solid" | "outline" | "ghost""outline"Same visual system as button() — filled CTA, outlined control, or transparent ghost.
size"small" | "medium" | "large""medium"Density-aware padding + type size, matching button().

Usage

import { linkButton } from "@domphy/ui"

{ a: "Open app", href: "/app", $: [linkButton()] }

{ a: "Get started", href: "/start", $: [linkButton({ variant: "solid", color: "primary" })] }

{ a: "Docs", href: "/docs", $: [linkButton({ variant: "ghost", color: "neutral" })] }

For a button that triggers JavaScript (no URL), use button() instead. linkButton is for navigational actions that should be a real anchor.

Customization

Must see the source of patch at the bottom of each patch page to understand the structure then code it still code as html native element.

There are four levels of customization, in increasing order of effort:

  1. Patch props. Each patch exposes a small, stable set of props—typically fewer than five. Lowest friction.
  2. Context attributes. Use dataTone, dataSize, and dataDensity on a container to shift tone, size, or density for an entire subtree without touching individual elements.
  3. Inline override. Native-wins merge strategy: any property set directly on the element overrides the patch value.
  4. Create a variant. Clone a similar patch and edit it. Use this only when you need a reusable custom version.
Formulas

Unit - U = fontSize / 4 - convert final values with themeSpacing(n).

Size - n = intrinsic text lines, w = wrapping level, d = density factor:

height        = (n * 6 + 2 * d * w) * U
paddingBlock  = d * w * U
paddingInline = ceil(3 / w) * d * w * U
radius        = d * w * U

Base density d = 1.5:

Uw=0w=1w=2w=3
height (n = 1)691215
paddingBlock01.534.5
paddingInline34.564.5
radius01.534.5

Tone - K = N / 2 where N is the palette length. For N = 18, K = 9.

RoleShiftn=0
Backgroundparent +/- n0
Textbg + K6
Borderbg + K/23
Hoverbg + 2K/34
Selected / Focusabove +/- K/32-4

State shift range: K/3 <= delta <= 2K/3.

import { type PartialElement, toState, type ValueOrState } from "@domphy/core";
import {
  type ThemeColor,
  themeColor,
  themeDensity,
  themeSize,
  themeSpacing,
} from "@domphy/theme";
import { BUTTON_SIZE_FONT, type ButtonSize } from "../utils/buttonSize.js";
import { focusRing } from "../utils/focusRing.js";
import type { ButtonVariant } from "./button.js";

const PADDING_STEPS: Record<ButtonSize, { block: number; inline: number }> = {
  small: { block: 0.5, inline: 2 },
  medium: { block: 1, inline: 3 },
  large: { block: 1.5, inline: 4 },
};

const GHOST_PADDING: Record<ButtonSize, number> = {
  small: 0.5,
  medium: 1,
  large: 1.5,
};

/**
 * An `<a>` element styled to look like a button — same visual system as
 * `button()` (`variant` / `size` / color) while preserving link semantics
 * (href, middle-click, right-click). Apply to an `<a>` element.
 *
 * @hostTag a
 * @param props.color - Button color tone. Optional `ValueOrState<ThemeColor>`, default "primary".
 * @param props.variant - `"outline"` (default) | `"solid"` | `"ghost"` — matches `button()`.
 * @param props.size - `"small" | "medium" | "large"`, defaults to `"medium"`.
 * @example { a: "Open app", href: "/app", $: [linkButton({ color: "primary" })] }
 * @example { a: "Get started", href: "/start", $: [linkButton({ variant: "solid" })] }
 */
function linkButton(
  props: {
    color?: ValueOrState<ThemeColor>;
    variant?: ButtonVariant;
    size?: ButtonSize;
  } = {},
): PartialElement {
  const variant = props.variant ?? "outline";
  const color = toState(
    props.color ?? (variant === "ghost" ? "neutral" : "primary"),
    "color",
  );
  const size = props.size ?? "medium";
  const fontSize = BUTTON_SIZE_FONT[size];

  if (variant === "ghost") {
    const padding = GHOST_PADDING[size];
    return {
      _onInsert: (node) => {
        if (node.tagName !== "a") {
          console.warn(`"linkButton" primitive patch must use a tag`);
        }
      },
      style: {
        fontSize: (listener) => themeSize(listener, fontSize),
        textDecoration: "none",
        paddingBlock: (listener) =>
          themeSpacing(themeDensity(listener) * padding),
        paddingInline: (listener) =>
          themeSpacing(themeDensity(listener) * padding),
        borderRadius: (listener) => themeSpacing(themeDensity(listener) * 1.5),
        display: "inline-flex",
        justifyContent: "center",
        alignItems: "center",
        gap: (listener) => themeSpacing(themeDensity(listener) * 1),
        userSelect: "none",
        cursor: "pointer",
        fontFamily: "inherit",
        lineHeight: "inherit",
        border: "none",
        background: "none",
        outline: "none",
        transition:
          "background-color 140ms ease, color 140ms ease, box-shadow 140ms ease",
        // Resting text uses the "text" alias (shift-9), matching buttonGhost:
        // a text-labeled ghost control fails WCAG AA at the previous shift-6
        // (~2.4:1 on an edge surface). Hover/active differentiate via
        // background only.
        color: (listener) => themeColor(listener, "text", color.get(listener)),
        "&:hover:not([aria-disabled=true])": {
          color: (listener) =>
            themeColor(listener, "text", color.get(listener)),
          backgroundColor: (listener) =>
            themeColor(listener, "hover", color.get(listener)),
          textDecoration: "none",
        },
        "&:active:not([aria-disabled=true])": {
          color: (listener) =>
            themeColor(listener, "text", color.get(listener)),
          backgroundColor: (listener) =>
            themeColor(listener, "increase-2", color.get(listener)),
        },
        "&:focus-visible": {
          boxShadow: (listener) => focusRing(listener, color.get(listener)),
        },
        "&[aria-disabled=true]": {
          opacity: 0.7,
          cursor: "not-allowed",
          pointerEvents: "none",
          color: (listener) => themeColor(listener, "border-strong", "neutral"),
        },
      },
    };
  }

  const isSolid = variant === "solid";
  const padding = PADDING_STEPS[size];

  return {
    _onInsert: (node) => {
      if (node.tagName !== "a") {
        console.warn(`"linkButton" primitive patch must use a tag`);
      }
    },
    ...(isSolid
      ? {
          _doctorDisable: [
            "low-contrast",
            "color-shift-minimum",
            "tone-background-inherit",
          ] as const,
        }
      : {}),
    style: {
      fontSize: (listener) => themeSize(listener, fontSize),
      textDecoration: "none",
      paddingBlock: (listener) =>
        themeSpacing(themeDensity(listener) * padding.block),
      paddingInline: (listener) =>
        themeSpacing(themeDensity(listener) * padding.inline),
      borderRadius: (listener) => themeSpacing(themeDensity(listener) * 1.5),
      width: "fit-content",
      display: "inline-flex",
      justifyContent: "center",
      alignItems: "center",
      gap: (listener) => themeSpacing(themeDensity(listener) * 1),
      userSelect: "none",
      cursor: "pointer",
      fontFamily: "inherit",
      lineHeight: "inherit",
      border: "none",
      outlineOffset: "-1px",
      outlineWidth: "1px",
      outline: isSolid
        ? "none"
        : (listener) =>
            `1px solid ${themeColor(listener, "border-strong", color.get(listener))}`,
      // Solid: deep brand (shift-13) + light-end text (not mid-ramp ~2.2:1).
      // Outline: shift-13 for ≥4.5:1 on light bg.
      color: (listener) =>
        isSolid
          ? themeColor(listener, "shift-0", "neutral")
          : themeColor(listener, "shift-13", color.get(listener)),
      backgroundColor: (listener) =>
        isSolid
          ? themeColor(listener, "shift-13", color.get(listener))
          : themeColor(listener, "inherit", color.get(listener)),
      transition:
        "background-color 140ms ease, color 140ms ease, border-color 140ms ease, box-shadow 140ms ease",
      "&:hover:not([aria-disabled=true])": {
        textDecoration: "none",
        color: (listener) =>
          isSolid
            ? themeColor(listener, "shift-0", "neutral")
            : themeColor(listener, "shift-14", color.get(listener)),
        backgroundColor: (listener) =>
          isSolid
            ? themeColor(listener, "shift-14", color.get(listener))
            : themeColor(listener, "hover", color.get(listener)),
      },
      "&:active:not([aria-disabled=true])": {
        backgroundColor: (listener) =>
          isSolid
            ? themeColor(listener, "shift-15", color.get(listener))
            : themeColor(listener, "increase-2", color.get(listener)),
      },
      "&:focus-visible": {
        boxShadow: (listener) => focusRing(listener, color.get(listener)),
      },
      "&[aria-disabled=true]": {
        opacity: 0.7,
        cursor: "not-allowed",
        pointerEvents: "none",
        backgroundColor: (listener) =>
          themeColor(listener, "shift-2", "neutral"),
        outline: (listener) =>
          `1px solid ${themeColor(listener, "border-strong", "neutral")}`,
        color: (listener) => themeColor(listener, "muted", "neutral"),
      },
    },
  };
}

export { linkButton };