Browse docs
Browse docs
A minimal, token-driven anchor primitive. The design system's identity (Link is primary + semibold + always-underlined at size sm, for example) lives in the theme, not in the JSX. Consumers write <Link>text</Link> and inherit the DS visual language everywhere.
import { Link } from '@dashforge/tw';
<Link href="/dashboard">Go to dashboard</Link><Button asChild> vs plain <a>Three ways to render an anchor in Dashforge — pick by intent:
Use <Link> when... | Use <Button asChild> when... | Use plain <a> when... |
|---|---|---|
| The affordance is textual (nav, prose, footer, breadcrumb) | The affordance is a button-shaped CTA that happens to navigate | You need raw semantics with zero design system opinion |
| DS identity is anchored in theme.components.Link.defaults | You want Button's shape (fill / outline / ghost + padding + radius) | You're rendering markdown or user-generated HTML |
import { Link, Typography } from '@dashforge/tw';
<Typography>
Read the <Link href="/docs">documentation</Link> for the full API reference.
</Typography>Every axis is opt-in AND theme.components.Link.defaults-configurable. TV defaults (color=primary / underline=hover / weight=normal / size=md) apply until the DS overrides them.
Read the documentation for the full API reference.
import { Link, Stack, Typography } from '@dashforge/tw';
<Stack gap={3}>
<Typography variant="body2">
Read the <Link href="/docs">documentation</Link> for the full API reference.
</Typography>
<Stack direction="row" gap={4} wrap>
<Link href="/x">primary</Link>
<Link href="/x" color="secondary">secondary</Link>
<Link href="/x" color="neutral">neutral</Link>
<Link href="/x" color="muted">muted</Link>
</Stack>
<Stack direction="row" gap={4} wrap>
<Link href="/x" underline="always">underline always</Link>
<Link href="/x" underline="hover">underline hover</Link>
<Link href="/x" underline="none">underline none</Link>
</Stack>
</Stack>startIcon / endIcon)Both slots accept any ReactNode — a lucide icon, an inline <svg>, an <img src="s3://…">, whatever. The consumer owns the visual affordance.
endIcon (typical: chevron, external glyph, download)
startIcon (typical: brand mark, category glyph)
import { Link, Stack } from '@dashforge/tw';
import { ChevronRight, ExternalLink, Download, Star } from 'lucide-react';
<Stack gap={3}>
<Stack direction="row" gap={4} wrap>
<Link href="/next" endIcon={<ChevronRight size={12} />}>Continue</Link>
<Link href="https://docs.example.com" external endIcon={<ExternalLink size={12} />}>
External docs
</Link>
<Link href="/report.pdf" endIcon={<Download size={12} />}>Download PDF</Link>
</Stack>
<Stack direction="row" gap={4} wrap>
<Link href="/featured" startIcon={<Star size={12} />} weight="semibold">
Featured
</Link>
{/* Any ReactNode works — including an <img> from an S3 URL */}
<Link
href="https://github.com/example/project"
startIcon={<img src="https://s3.example.com/gh.svg" alt="" width={14} height={14} />}
>
GitHub project
</Link>
</Stack>
</Stack>Note the external example — external={true} toggles target="_blank" + rel="noopener noreferrer" but does NOT auto-append any glyph. The dev decides which icon (and whether) to show via endIcon. Consumer-supplied target / rel on the same instance are honoured (not overwritten).
theme.components.Link.defaults (Option C)This is the whole point of Option C for <Link>. Patch the theme once — every bare <Link> in the tree mirrors the DS identity, no per-instance repetition.
The theme was patched on mount with { color: 'primary', underline: 'always', weight: 'semibold', size: 'sm' }. Every bare <Link> below inherits that identity — zero per-instance props.
import { Link, Stack } from '@dashforge/tw';
import { patchTheme } from '@dashforge/tw-theme';
// One-time DS identity patch — usually in your app's theme setup.
patchTheme({
components: {
Link: {
defaults: {
color: 'primary',
underline: 'always',
weight: 'semibold',
size: 'sm',
},
},
},
});
// From then on, EVERY bare <Link> inherits the DS identity — no
// per-instance props needed.
<Stack gap={2}>
<Link href="/dashboard">go to dashboard</Link>
<Link href="/billing">view billing</Link>
<Link href="/settings">settings</Link>
</Stack>// In your app's theme setup (once):
patchTheme({
components: {
Link: {
defaults: {
color: 'primary',
underline: 'always',
weight: 'semibold',
size: 'sm',
},
},
},
});
// Elsewhere — bare Link, no props:
<Link href="/dashboard">go to dashboard</Link>
// → text-primary-700 font-semibold underline text-smSame DS identity flows through nav bars, footers, in-prose links, breadcrumbs — everywhere. Individual <Link> instances that need to deviate override with a prop (level 3 in the precedence chain).
asChildWhen the app uses a router (Next.js, TanStack Router, React Router, …), the router's Link component brings its own <a> (prefetch, client-side nav, active state). Wrapping it inside another Dashforge <a> would produce nested anchors — invalid HTML. asChild (Radix Slot) paints Dashforge classes onto the child element directly, no wrapping.
// Next.js example
import NextLink from 'next/link';
import { Link } from '@dashforge/tw';
<Link asChild color="primary" underline="hover">
<NextLink href="/settings">Settings</NextLink>
</Link>
// DOM: a single <a> with both Dashforge and Next.js classes merged.Same pattern as <Button asChild> and <Chip asChild>. When asChild={true}, startIcon / endIcon are ignored — the consumer child renders its own content and the Slot has no seam to inject a wrapper. A dev-only warning nudges the consumer if they mix them.
sx — utility class shortcut. Merged via tailwind-merge so consumer classes win over the variant on the same axis.
<Link href="/x" sx="tracking-wide font-mono">go</Link>slotProps — per-slot overrides. Same shape as elsewhere in the catalog.
<Link
href="/x"
startIcon={<Star />}
slotProps={{ startIcon: { className: 'text-warning-500' } }}
>
Featured
</Link>Configure <Link> defaults application-wide.
import { patchTheme } from '@dashforge/tw-theme';
patchTheme({
components: {
Link: {
defaults: { color: 'primary', underline: 'always', weight: 'semibold', size: 'sm' },
slotProps: { root: { className: 'tracking-wide' } },
},
},
});Configurable axes (LinkVariantProps):
| Axis | Type | Notes |
|---|---|---|
color | 'primary' | 'secondary' | 'neutral' | 'muted' | 'inherit' | inherit for links on coloured surfaces. |
underline | 'always' | 'hover' | 'none' | always for prose, hover for nav / CTAs. |
weight | 'normal' | 'medium' | 'semibold' | 'bold' | Matches Typography weight scale. |
size | 'xs' | 'sm' | 'md' | 'lg' | Matches Typography scale (text-xs → text-lg). |
Precedence chain (lowest → highest):
defaultVariants (color=primary, underline=hover, weight=normal, size=md).theme.components.Link.defaults (application-wide DS identity).<Link color="danger" />) — wins over theme.sx — appended to the class chain, wins on conflicting utilities via tailwind-merge.Same chain applies to slotProps (theme slotProps → instance slotProps → sx on the root).
TypeScript: theme.components.Link.defaults autocompletes to the four axes above. slotProps autocompletes to the three slot names (root, startIcon, endIcon).
Reactivity: useComponentDefaults('Link') subscribes to the theme store — patchTheme re-renders every mounted <Link> inheriting the changed axis.
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | Radix Slot polymorphism. When true, the Link's styles paint onto the single React child (matching the pattern used by <Button asChild>, <Chip asChild>, etc.). Idiomatic for router integration:tsx <Link asChild> <NextLink to="/x">Go</NextLink> </Link> With asChild=true, startIcon / endIcon are ignored — the consumer child renders its own content and the Slot has no seam to inject a wrapper. |
color | 'primary' | 'secondary' | 'neutral' | 'inherit' | 'muted' | (theme.components.Link.defaults.color ?? 'primary') | Color intent. inherit opts out for links on coloured surfaces. |
endIcon | ReactNode | — | Icon rendered AFTER the link text. Same acceptance as startIcon. Common uses: chevron, external-link glyph, download icon. Ignored when asChild is true. |
external | boolean | false | When true, applies target="_blank" + rel="noopener noreferrer" on the rendered <a>. Consumer-supplied target / rel on the same instance are honoured (not overwritten).Does NOT append any visual glyph — the caller owns that choice. Pass an icon via endIcon for the standard ↗ affordance. |
size | 'md' | 'sm' | 'lg' | 'xs' | (theme.components.Link.defaults.size ?? 'md') | Font size step. Matches Typography's caption / body / subtitle scale. |
slotProps | LinkSlotProps | — | Per-slot overrides. Each slot accepts a { className?: string } override — see the LinkSlotProps type for the three slot names (root, startIcon, endIcon). |
startIcon | ReactNode | — | Icon rendered BEFORE the link text. Accepts any ReactNode — a lucide/Radix icon, an inline <svg>, an <img src="..."> (S3 / CDN URL included). Ignored when asChild is true (the consumer child renders its own content). |
sx | string | — | Utility-class escape hatch. Appended to the variant chain and resolved via tailwind-merge so consumer classes win over the variant defaults on the same axis (Dashforge idiom). |
underline | 'none' | 'hover' | 'always' | (theme.components.Link.defaults.underline ?? 'hover') | Underline behavior. always for prose, hover for buttons/nav, none for icon-heavy links where the underline would be visual noise. |
weight | 'normal' | 'medium' | 'semibold' | 'bold' | (theme.components.Link.defaults.weight ?? 'normal') | Font weight override. Matches Typography's weight. |
...rest | Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'className' | 'color'> | — | Additional native attributes forwarded via Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'className' | 'color'>. |
<Link> has three named slots. Each accepts a { className?: string } override via slotProps:
| Slot | Purpose |
|---|---|
root | The <a> (or, with asChild, the polymorphic host). sx is a shortcut for slotProps.root.className. |
startIcon | The leading icon wrapper — a <span> inline-flex. Applied only when startIcon is set and asChild is false. |
endIcon | The trailing icon wrapper — a <span> inline-flex. Applied only when endIcon is set and asChild is false. |
<Link
href="/x"
startIcon={<Star />}
slotProps={{
root: { className: 'tracking-wide' },
startIcon: { className: 'text-warning-500' },
}}
>
Featured
</Link>external is a11y + intent, not decoration: external={true} sets target="_blank" rel="noopener noreferrer" to prevent reverse-tabnabbing and window-opener leaks. It intentionally does NOT append a visual glyph — the caller passes one via endIcon if the DS calls for it. Consumer-supplied target / rel on the same instance are honoured (not overwritten).startIcon / endIcon are ReactNode. Icon libraries (lucide, Radix, Heroicons), inline <svg>, <img src="…"> (including remote S3 / CDN URLs), and even nested React trees all work. The wrapper is a <span> inline-flex so the icon aligns vertically with the text.asChild and icons are mutually exclusive: the Slot pattern paints classes on the child element, which owns its own content. Passing startIcon / endIcon with asChild is silently ignored (dev-warn fires so you know). Move the icon inside the child element instead.<Link> renders a persistent focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 focus-visible:rounded. Keyboard users see the focus target regardless of the underline variant.motion-reduce, so users who opt out get an instant snap on hover instead of a fade.<Button>: if the affordance is a button-shaped CTA that navigates, use <Button asChild variant="…"><a href="/x">Go</a></Button> (button geometry) rather than <Link> (text geometry). Choose by visual weight, not by the underlying HTML.