# Text link

> A native text link for prose, footers, navigation and card actions: a drawn underline that thickens on hover, a focus ring on every wrapped line, current-page and visited states, arrows, external links and stretched rows.

- ID: `cmp_text_link_01`
- Slug: `text-link-01`
- Version: `1.0.0` (current)
- Status: published
- Published: 2026-10-01T07:31:37Z
- Updated: 2026-10-01
- Available versions: `1.0.0`
- Kind: control
- Primary category: `buttons`
- Detail page: https://pagesugar.com/components/text-link-01
- Preview: https://pagesugar.com/preview/text-link-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `neutral` | Neutral | yes | `sha256-31f036aa6bfe81bc7e56a1782edceded8b21ad64a29c3517a833ee17653975a5` |
| `blue` | Blue accent | no | `sha256-c5fc50dc684481a01b3e98735bcfbb631c6d4fb156b9b1b7f4d0db99c358ea6a` |

## Runtime and compatibility

- Runtime: svelte
- Svelte: 5
- SvelteKit required: no (portable Svelte component)
- Tailwind CSS: 4
- SSR: supported
- Requires client-side JavaScript: no
- Integration level: presentational
- Appearance modes: light
- Suggested directory: `src/lib/components/text-link-01`

## Dependencies

No third-party runtime packages.

## Services

No external services required.

## Usage

Pass href and the link text as children. It renders a plain \<a\>, so SvelteKit client navigation, prefetching and middle-click work as they do for any link, with no script. It takes its size and weight from the text around it. It does not detect external URLs (set icon='external' and newTab yourself), validate or rewrite URLs, configure route prefetching, or make other controls inside a stretched card clickable.

Required props: `href`, `children`

```svelte
<script lang="ts">
	import TextLink from '$lib/components/text-link-01/TextLink.svelte';
</script>

<p>
	Invite guests from <TextLink href="/settings/board">board settings</TextLink>, or read the
	<TextLink href="https://example.com/guide" icon="external" newTab>guest access guide</TextLink>.
</p>

<nav aria-label="Guides">
	<TextLink href="/guides/timelines" tone="muted" underline="hover" current>Timelines</TextLink>
</nav>

<TextLink href="/guides" icon="arrow" underline="hover">Read every guide</TextLink>
```

Limitations:

- underline='hover' is for links that stand on their own (lists, an action link under a paragraph). Inside a sentence keep the default, because colour alone does not mark a link (WCAG 1.4.1).
- The visited colour applies to accent links only. Browsers let :visited change colours and nothing else, so a visited link keeps its underline weight.
- stretched needs a positioned ancestor (position: relative) to cover, and any other link or button inside that ancestor must sit above the overlay (position: relative; z-index: 2) to stay clickable.
- The 44 px touch hit area for standalone links is an invisible box; keep at least 44 px between rows on touch screens or neighbouring areas overlap.
- target='\_blank' is treated like newTab: it is announced and gets noopener and noreferrer. Use a button for actions that do not navigate.

## Usage guide

### Text link

One anchor for every text link on a site: inline links in prose, quiet footer and sidebar
lists, the current page, an action link with an arrow, a whole card or row, and a link to
another site. It renders a plain `<a>` and inherits its size and weight from the text around
it, so set those on the paragraph or list.

| Where                    | Props                                               |
| ------------------------ | --------------------------------------------------- |
| A sentence in body copy  | defaults (`tone="accent"`, `underline="always"`)    |
| Inside a coloured note   | `tone="inherit"`                                    |
| Footer and sidebar lists | `tone="muted" underline="hover"`                    |
| The page you are on      | add `current` (ink and weight 500)                  |
| End of a card or section | `icon="arrow" underline="hover"` (or `icon="back"`) |
| Another site, new tab    | `icon="external" newTab`                            |
| A whole card or list row | `stretched` on the title link                       |

#### The underline

At rest the underline is the link's colour at 45%, one pixel (or 1/16 em) thick and set 0.2 em
below the baseline so it clears descenders. On hover it turns full strength and doubles in
thickness. Keyboard focus replaces it with a two-pixel ring and a faint tinted fill, cloned onto
every line a wrapped link spans. Keep `underline="always"` inside sentences: hover-only
underlines leave colour as the only signal, which fails WCAG 1.4.1. Use `underline="hover"` for
links that stand on their own, such as lists and an action link under a paragraph.

The current page (`current`) is ink and weight 500. In a hover-underline list it has no resting
underline, so only the link under the pointer shows one.

#### Stretched links

`stretched` adds an overlay that covers the nearest positioned ancestor. Give the card
`position: relative`, put the card's title in the link, and set `--text-link-card-radius` to the
card's radius so the focus ring follows it. Raise any other link or button in the card above the
overlay:

```svelte
<li class="relative rounded-xl p-5 [--text-link-card-radius:12px]">
	<h3>
		<TextLink href="/guides/timelines" stretched tone="inherit" underline="hover" icon="arrow">
			Plan a quarter on one timeline
		</TextLink>
	</h3>
	<p>Set the quarter's dates and share the view with guests.</p>
	<a class="relative z-2" href="/authors/sam">Sam's other guides</a>
</li>
```

Highlight the card on hover yourself with `:has(a:hover)` on the card.

#### New tabs

Opening a new tab is the visitor's choice; only set `newTab` when leaving the page would lose
their work. When you do, the link gains `target="_blank"`, `rel="noopener noreferrer"` (alongside
any `rel` you pass) and `newTabText`, read by screen readers and shown on screen, small and
muted after the link. `showNewTabText={false}` keeps it for screen readers only. Translate
`newTabText` with the rest of the page.

#### Retoning

On a `#09090b` band:

```css
.band {
	background: #09090b;
	color: #d4d4d8;
	--text-link-accent: #fafafa;
	--text-link-ink: #fafafa;
	--text-link-muted: #a1a1aa;
	--text-link-visited: #d4d4d8;
}
```

## Props

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `href` | `string` | yes |  | Destination URL. Always rendered, so the link works before any script runs. |
| `children` | `Snippet` | yes |  | The link text. Name the destination ('Read the guest access guide'), not 'Learn more'. |
| `tone` | 'accent' \| 'inherit' \| 'muted' | no | `'accent'` | accent for links in copy, inherit to match the surrounding text colour, muted for quiet footer and navigation lists (ink on hover). |
| `underline` | 'always' \| 'hover' | no | `'always'` | always in running text. hover hides the resting underline and is for links that stand on their own: footer and navigation lists, an action link under a paragraph. Those also get a 44 px hit area on touch screens. |
| `current` | `boolean` | no | `false` | Marks the current page: aria-current="page", the ink colour and weight 500. In running text it keeps a solid underline; in a hover-underline list it has none at rest, like its siblings. |
| `icon` | 'arrow' \| 'back' \| 'chevron' \| 'external' \| Snippet | no |  | A decorative icon joined to the neighbouring word. arrow, back and chevron mirror in right-to-left text and nudge 2 px on hover; external does neither. A snippet supplies your own inline SVG, sized to 1em. |
| `iconPosition` | 'start' \| 'end' | no |  | Which side of the text the icon sits on. Defaults to start for back and end for everything else. |
| `newTab` | `boolean` | no | `false` | Opens in a new tab: target="\_blank", rel gains noopener and noreferrer, and newTabText is added to the link's name. |
| `newTabText` | `string` | no | `'(opens in new tab)'` | The words that announce a new tab. Translate it with the page. |
| `showNewTabText` | `boolean` | no | `true` | Shows newTabText on screen after the link, small, muted and kept on one line, so sighted visitors are told too (WCAG G201). false keeps it for screen readers only. |
| `stretched` | `boolean` | no | `false` | Stretches the click area over the nearest positioned ancestor, such as a card or list row, and moves the focus ring to that box. |
| `rel` | `string` | no |  | rel tokens, kept alongside the noopener and noreferrer a new tab adds. |
| `target` | `string` | no |  | Passed through. target="\_blank" is treated as newTab, so it is announced and gets the safe rel tokens. |
| `class` | `string` | no |  | Extra classes for placement. |

## Customization

Choose a tone per context and retone through five --text-link-\* CSS variables. The underline is mixed from the link's own colour, so it follows any accent you set.

- Tone: accent in body copy, muted for footer and sidebar lists, inherit inside a note or banner that sets its own text colour.
- Accent: --text-link-accent colours accent links and the focus ring. Keep it at 4.5:1 on the page. Hover mixes 20% of --text-link-ink into it.
- Visited: --text-link-visited is the colour of visited accent links. The neutral default is the muted grey; the blue palette uses violet.
- Neutrals: --text-link-ink is the current page and the muted tone's hover colour; --text-link-muted is the muted tone at rest.
- Stretched cards: set --text-link-card-radius on the card to the card's radius so the focus ring follows its corners (12px by default; '12px 12px 0 0' for the first row of a list).
- Worked retone for a #09090b band: --text-link-accent: #fafafa; --text-link-ink: #fafafa; --text-link-muted: #a1a1aa; --text-link-visited: #d4d4d8.
- Size and weight come from the parent: set font-size and font-weight on the paragraph or list, not on the link. Action links read well at weight 500.
- External links: set icon='external' and newTab together; the arrow alone does not tell anyone a new tab will open.

| Token | Public CSS variable |
| --- | --- |
| `accent` | `--text-link-accent` |
| `ink` | `--text-link-ink` |
| `muted` | `--text-link-muted` |
| `visited` | `--text-link-visited` |
| `cardRadius` | `--text-link-card-radius` |

## Accessibility

- A native \<a href\>: Enter follows it, it sits in the tab order, and it is never rendered without href.
- In running text the underline is always on, so colour is never the only thing that marks a link (WCAG 1.4.1). underline='hover' is for standalone lists only, where the list itself marks the links.
- current sets aria-current="page" and is marked by weight as well as colour.
- Focus shows a two-pixel accent ring and an 8% accent fill on every line of a wrapped link, plus a transparent outline that forced-colours mode turns visible. A stretched link rings its whole card instead.
- Icons and word joiners are aria-hidden. newTab adds newTabText to the link's name and, by default, shows it on screen too, so a new tab is announced in words (WCAG G201), not by the icon. If you set aria-label or aria-labelledby, the announcement is carried into it.
- Default colours: accent #18181b and visited #52525b clear 4.5:1 on white; the blue palette's #1d4ed8 and #6d28d9 clear 6:1. Keep a retoned accent at 4.5:1 on the page and 3:1 for the focus ring.
- Write link text that makes sense out of context. If a card's link must stay short, put the card title in the link, as the stretched rows fixture does.

## License

- Declared source: MIT
- Default license approval is pending. See https://pagesugar.com/docs/license.

## Source

- Palette: Neutral (`neutral`)
- Entry: `TextLink.svelte`
- Suggested directory: `src/lib/components/text-link-01`
- Files: 1
- Artifact digest: `sha256-31f036aa6bfe81bc7e56a1782edceded8b21ad64a29c3517a833ee17653975a5`

Paths below are relative to the suggested directory. Copy the files as they are;
they import nothing from this site.

#### `TextLink.svelte`

Role: entry · 10527 bytes · SHA-256 `bfa1f69c85081020669f9960964916dbb225df7c500024efbf68c35c055e145c`

```svelte
<!--
	A text link: a native <a> that takes its size and weight from the text around it. The underline
	is drawn rather than left to the browser: a tinted hairline set clear of the descenders that
	turns full strength and doubles on hover, and focus is a ring and a tinted fill cloned onto
	every line the link wraps across. The same anchor covers inline prose links, quiet footer and
	navigation lists, the current page, an action link with an arrow (optionally stretched over its
	card) and an external link that says in text when it opens a new tab.
-->
<script lang="ts" module>
	export type TextLinkTone = 'accent' | 'inherit' | 'muted';
	export type TextLinkUnderline = 'always' | 'hover';
	export type TextLinkIcon = 'arrow' | 'back' | 'chevron' | 'external';
</script>

<script lang="ts">
	import type { Snippet } from 'svelte';
	import type { HTMLAnchorAttributes } from 'svelte/elements';

	type Passthrough = Omit<
		HTMLAnchorAttributes,
		'href' | 'children' | 'class' | 'rel' | 'target' | 'aria-current'
	>;

	interface Props extends Passthrough {
		/** Destination. Always rendered, so the link is a real link before any script runs. */
		href: string;
		/** The link text. Name the destination: "Read the guest access guide", not "Learn more". */
		children: Snippet;
		/** accent for links in copy, muted for quiet footer and navigation lists, inherit to match the text. */
		tone?: TextLinkTone;
		/** always in running text (WCAG 1.4.1). hover only for standalone lists outside a sentence. */
		underline?: TextLinkUnderline;
		/** Marks the current page: aria-current="page", ink colour and weight 500 (and a solid underline in prose). */
		current?: boolean;
		/** A built-in decorative icon, or a snippet holding your own inline SVG. */
		icon?: TextLinkIcon | Snippet;
		/** Which side of the text the icon sits on. back defaults to start, everything else to end. */
		iconPosition?: 'start' | 'end';
		/** Open in a new tab: sets target="_blank", adds noopener and noreferrer, and says so in text. */
		newTab?: boolean;
		/** The text that announces a new tab. */
		newTabText?: string;
		/** Show newTabText on screen, small and muted after the link text. false keeps it for screen readers only. */
		showNewTabText?: boolean;
		/** Stretch the click area over the nearest positioned ancestor, such as a card. */
		stretched?: boolean;
		/** Extra rel tokens; noopener and noreferrer are always added for a new tab. */
		rel?: string;
		/** Passed through. target="_blank" is treated as newTab, so it is announced and made safe. */
		target?: string;
		/** Extra classes for placement. */
		class?: string;
	}

	let {
		href,
		children,
		tone = 'accent',
		underline = 'always',
		current = false,
		icon,
		iconPosition,
		newTab = false,
		newTabText = '(opens in new tab)',
		showNewTabText = true,
		stretched = false,
		rel,
		target,
		class: className,
		...rest
	}: Props = $props();

	const uid = $props.id();
	const noteId = `${uid}-new-tab`;
	/* Target keywords are case-insensitive, so "_BLANK" opens a new tab too. */
	const opensNewTab = $derived(newTab || target?.toLowerCase() === '_blank');
	const relValue = $derived.by(() => {
		const tokens = (rel ?? '').split(/\s+/).filter(Boolean);
		if (opensNewTab) tokens.push('noopener', 'noreferrer');
		return tokens.length ? [...new Set(tokens)].join(' ') : undefined;
	});
	const side = $derived(iconPosition ?? (icon === 'back' ? 'start' : 'end'));
	/** A link outside a sentence (underline on hover) gets a 44 px hit area on touch screens. */
	const standalone = $derived(underline === 'hover' && !stretched);
	const visibleNote = $derived(opensNewTab && showNewTabText);
	/** A trailing icon and the visible note travel as one unbreakable group with the last word. */
	const trailing = $derived((icon && side === 'end') || visibleNote);
	/* A consumer's aria-label or aria-labelledby replaces the text as the name, so the new-tab
	   announcement is carried into whichever one they set. */
	const ariaLabel = $derived(
		opensNewTab && rest['aria-label'] ? `${rest['aria-label']} ${newTabText}` : rest['aria-label']
	);
	const ariaLabelledby = $derived(
		opensNewTab && rest['aria-labelledby']
			? `${rest['aria-labelledby']} ${noteId}`
			: rest['aria-labelledby']
	);
</script>

<a
	{...rest}
	{href}
	target={opensNewTab ? '_blank' : target}
	rel={relValue}
	aria-label={ariaLabel}
	aria-labelledby={ariaLabelledby}
	aria-current={current ? 'page' : undefined}
	data-tone={tone}
	class={[
		'text-link group -mx-0.5 rounded-[3px] box-decoration-clone px-0.5 py-px',
		'underline underline-offset-[0.2em]',
		'transition-[color,background-color] duration-150 ease-(--_ease) motion-reduce:transition-none',
		'hover:decoration-current hover:decoration-(length:--_thick)',
		'focus-visible:bg-(--_focus-fill) focus-visible:decoration-transparent focus-visible:shadow-(--_ring) focus-visible:outline-2 focus-visible:outline-transparent',
		'decoration-(length:--_thin)',
		underline === 'hover'
			? 'decoration-transparent'
			: current
				? 'decoration-current'
				: 'decoration-(--_line)',
		current
			? 'font-medium text-(--_ink)'
			: tone === 'accent'
				? 'text-(--_accent) visited:text-(--_visited) hover:text-(--_accent-hover)'
				: tone === 'muted'
					? 'text-(--_muted) hover:text-(--_ink) focus-visible:text-(--_ink)'
					: '',
		stretched && 'text-link--stretched',
		standalone && 'text-link--standalone',
		className
	]}
	>{#if icon && side === 'start'}{@render glyph(
			icon
		)}{@render joiner()}{/if}{@render children()}{#if trailing}<span class="whitespace-nowrap"
			>{@render joiner()}{#if icon && side === 'end'}{@render glyph(
					icon
				)}{/if}{#if visibleNote}<span
					class="ms-1 inline-block text-[0.8125em] font-normal text-(--_muted)"
					aria-hidden="true">{newTabText}</span
				>{/if}</span
		>{/if}{#if opensNewTab}<span id={noteId} class="sr-only">{` ${newTabText}`}</span>{/if}</a
>

<!-- The icon and the visible new-tab note are joined to the neighbouring word with a word joiner
     (U+2060), and a trailing pair sits in one nowrap group, so a wrapped label never leaves either
     alone on a line. Joiners, icons and the
     visible note are hidden; the text and the screen-reader note name the link. -->
{#snippet joiner()}<span aria-hidden="true">&#8288;</span>{/snippet}

{#snippet glyph(kind: TextLinkIcon | Snippet)}
	<span
		class={[
			'text-link__icon inline-block',
			kind === 'external'
				? 'size-[0.75em] align-baseline'
				: kind === 'chevron'
					? 'h-[1em] w-[0.75em] align-[-0.14em]'
					: 'size-[1em] align-[-0.14em]',
			side === 'start' ? 'me-1' : 'ms-1',
			kind !== 'external' &&
				'transition-transform duration-150 ease-(--_ease) motion-safe:group-hover:translate-x-(--_nudge) motion-safe:group-focus-visible:translate-x-(--_nudge) motion-reduce:transition-none',
			kind === 'back' && 'text-link__icon--back'
		]}
		aria-hidden="true"
	>
		{#if typeof kind === 'function'}
			{@render kind()}
		{:else if kind === 'external'}
			<!-- Cropped to the stroke, so the mark sits on the cap height and punctuation follows it. -->
			<svg class="block size-full" viewBox="3.5 3.5 9 9" fill="none">
				<path
					d="M6 4.75h5.25V10M11 5 4.75 11.25"
					stroke="currentColor"
					stroke-width="1.125"
					stroke-linecap="round"
					stroke-linejoin="round"
				/>
			</svg>
		{:else if kind === 'chevron'}
			<svg class="block h-full w-[0.75em] rtl:-scale-x-100" viewBox="2.5 0 12 16" fill="none">
				<path
					d="m6.5 4 4 4-4 4"
					stroke="currentColor"
					stroke-width="1.5"
					stroke-linecap="round"
					stroke-linejoin="round"
				/>
			</svg>
		{:else}
			<svg
				class={[
					'block size-full',
					kind === 'back' ? '-scale-x-100 rtl:scale-x-100' : 'rtl:-scale-x-100'
				]}
				viewBox="0 0 16 16"
				fill="none"
			>
				<path
					d="M3 8h9.5M9 4.5 12.5 8 9 11.5"
					stroke="currentColor"
					stroke-width="1.5"
					stroke-linecap="round"
					stroke-linejoin="round"
				/>
			</svg>
		{/if}
	</span>
{/snippet}

<style>
	/* Public tokens: set --text-link-* on the link or any ancestor to retone it. */
	.text-link {
		--_accent: var(--text-link-accent, #18181b);
		--_ink: var(--text-link-ink, #18181b);
		--_muted: var(--text-link-muted, #52525b);
		--_visited: var(--text-link-visited, #52525b);
		--_card-radius: var(--text-link-card-radius, 12px);

		/* Formulas, read by the utilities in the markup. The rest underline is the link's own
		   colour at 45%, so it follows every tone, the visited colour and a retoned accent. */
		--_line: color-mix(in oklab, currentColor 45%, transparent);
		--_thin: max(1px, 0.0625em);
		--_thick: max(2px, 0.125em);
		--_accent-hover: color-mix(in oklab, var(--_accent) 80%, var(--_ink));
		--_focus-fill: color-mix(in oklab, var(--_accent) 8%, transparent);
		--_ring: 0 0 0 2px var(--_accent);
		--_ease: cubic-bezier(0.2, 0, 0, 1);
	}

	/* The arrow nudges 2 px the way it points, which flips with the reading direction. The
	   external mark stays still: it points out of the page, not along the line. */
	.text-link__icon {
		--_nudge: 2px;
	}
	.text-link__icon:dir(rtl),
	.text-link__icon--back {
		--_nudge: -2px;
	}
	.text-link__icon--back:dir(rtl) {
		--_nudge: 2px;
	}

	/* A consumer's icon snippet fills the same 1em box as the built-in ones. */
	@layer components {
		.text-link__icon :global(:where(svg)) {
			display: block;
			width: 100%;
			height: 100%;
		}
	}

	/* Stretched: an overlay covers the nearest positioned ancestor, so the whole card is the link,
	   and focus moves from the text to a ring around the card. Unlayered, so it beats the focus
	   utilities on the text. */
	.text-link--stretched::after {
		content: '';
		position: absolute;
		inset: 0;
		z-index: 1;
		border-radius: var(--_card-radius);
	}
	.text-link--stretched:focus-visible {
		background-color: transparent;
		box-shadow: none;
	}
	/* Inset, so the ring follows the card's own corners and is never clipped by its parent. */
	.text-link--stretched:focus-visible::after {
		outline: 2px solid var(--_accent);
		outline-offset: -2px;
	}

	/* A link standing on its own row is at least 44 px tall to a finger; the box is invisible and
	   never moves the layout. Links inside a sentence keep WCAG 2.5.8's inline exception. */
	@media (pointer: coarse) {
		.text-link--standalone {
			position: relative;
		}
		.text-link--standalone::before {
			content: '';
			position: absolute;
			inset-inline: 0;
			top: 50%;
			height: max(100%, 44px);
			translate: 0 -50%;
		}
	}
</style>
```

## Artifacts

### Neutral (`neutral`) (default)

- Artifact digest: `sha256-31f036aa6bfe81bc7e56a1782edceded8b21ad64a29c3517a833ee17653975a5`
- Entry: `TextLink.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_text_link_01/1.0.0/neutral/sha256-31f036aa6bfe81bc7e56a1782edceded8b21ad64a29c3517a833ee17653975a5/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_text_link_01/1.0.0/neutral/sha256-31f036aa6bfe81bc7e56a1782edceded8b21ad64a29c3517a833ee17653975a5/bundle.zip (7419 bytes, sha256 `d4b370c5e7c39d7961f862d6709bd15b5d945151e9457626e2e9fd61f4d90620`)

Files:

- `TextLink.svelte` (entry, 10527 bytes): https://pagesugar.com/artifacts/cmp_text_link_01/1.0.0/neutral/sha256-31f036aa6bfe81bc7e56a1782edceded8b21ad64a29c3517a833ee17653975a5/source/TextLink.svelte

### Blue accent (`blue`)

- Artifact digest: `sha256-c5fc50dc684481a01b3e98735bcfbb631c6d4fb156b9b1b7f4d0db99c358ea6a`
- Entry: `TextLink.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_text_link_01/1.0.0/blue/sha256-c5fc50dc684481a01b3e98735bcfbb631c6d4fb156b9b1b7f4d0db99c358ea6a/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_text_link_01/1.0.0/blue/sha256-c5fc50dc684481a01b3e98735bcfbb631c6d4fb156b9b1b7f4d0db99c358ea6a/bundle.zip (7428 bytes, sha256 `39f937b22ad49db38e6fce12d24cb2ebab30fe133f734335b1d4b6795d7228bd`)

Files:

- `TextLink.svelte` (entry, 10527 bytes): https://pagesugar.com/artifacts/cmp_text_link_01/1.0.0/blue/sha256-c5fc50dc684481a01b3e98735bcfbb631c6d4fb156b9b1b7f4d0db99c358ea6a/source/TextLink.svelte
