Link
Navigation. The rule: if it changes the page, it is a Link; if it changes data, it is a Button.
Playground
Change a prop and watch it. The code below follows along.
Live
Open full cardLink — Variants, sizes and states.
Install and import
Link is in @dovetail-ds/react. Install it with React, if you haven't:
npm install @dovetail-ds/react react react-dom
import { Link } from "@dovetail-ds/react";
Import @dovetail-ds/react/styles.css once at your app's root, then your theme after it. Setup covers the stylesheets, fonts and dark mode.
Guidelines
Navigation. The rule: if it changes the page, it is a Link; if it changes data, it is a Button.
Use it when
- Navigating within the app or out to another site.
Don't use it when
- It performs an action. Use
Button variant="ghost". A link that deletes something is a trap.
Example
<Link href="/settings">Account settings</Link>
<Link href="https://example.com" external>Read the spec</Link>
Variants
underline="always" is the default and is correct inside prose, because colour alone is not a sufficient signal. underline="hover" is acceptable in navigation lists where position already marks the links.
Accessibility
external adds rel="noopener noreferrer" and a visible icon, so a new tab is never a surprise.
Content
Link text describes the destination. "Account settings", not "click here" or a bare URL.
Props
The typed contract, from Link.d.ts.
import * as React from "react";
/** Navigation. If it changes the page, it is a Link; if it changes data, it is a Button. */
export interface LinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
href: string;
/** Opens in a new tab, adds rel="noopener noreferrer" and a visible indicator icon. */
external?: boolean;
/** "always" is correct for links in prose. @default "always" */
underline?: "always" | "hover" | "never";
/** "inherit" for links inside a coloured block where the primary colour would clash. @default "primary" */
tone?: "primary" | "inherit";
children?: React.ReactNode;
}
export declare function Link(props: LinkProps): React.JSX.Element;
Tokens it reads
Every custom property Link.jsx resolves, read from the source: 4 semantic. Component tier first, because that is the one to reach for.
- semantic Shared roles. Override one and everything using that role moves with it.
| Token | Tier | Declared as |
|---|---|---|
--dt-motion-micro | semantic | var(--dt-duration-100) var(--dt-easing-standard) |
--dt-space-inline-2xs | semantic | var(--dt-dim-1) |
--dt-text-link | semantic | var(--dt-color-neutral-900) |
--dt-text-link-hover | semantic | var(--dt-color-neutral-700) |
Source
React only, no dependencies, every value a token.
import React from "react";
export function Link({ href, external = false, underline = "always", tone = "primary", children, style, ...rest }) {
const [hover, setHover] = React.useState(false);
const color = tone === "inherit" ? "inherit" : hover ? "var(--dt-text-link-hover)" : "var(--dt-text-link)";
return (
<a
href={href}
target={external ? "_blank" : undefined}
rel={external ? "noopener noreferrer" : undefined}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
style={{
color,
textDecoration: underline === "never" ? "none" : underline === "hover" ? (hover ? "underline" : "none") : "underline",
textUnderlineOffset: 2,
textDecorationThickness: hover ? 2 : 1,
display: external ? "inline-flex" : undefined,
alignItems: external ? "center" : undefined,
gap: external ? "var(--dt-space-inline-2xs)" : undefined,
transition: "color var(--dt-motion-micro)",
...style,
}}
{...rest}
>
{children}
{external && (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M15 3h6v6" /><path d="M10 14 21 3" /><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
</svg>
)}
</a>
);
}
In context
Link also appears on the actions reference cards, alongside the rest of its family.