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.
cmp_text_link_01 Preview
Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.
Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
Using the PageSugar MCP server, fetch component cmp_text_link_01 version 1.0.0 with variant "blue", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.Not connected yet? Set up the MCP server
Code
- Palette
- Blue accent
- Version
- 1.0.0
- Digest
Full digest
sha256-c5fc50dc684481a01b3e98735bcfbb631c6d4fb156b9b1b7f4d0db99c358ea6a
<!--
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">⁠</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, #1d4ed8);
--_ink: var(--text-link-ink, #18181b);
--_muted: var(--text-link-muted, #52525b);
--_visited: var(--text-link-visited, #6d28d9);
--_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>
Usage#
On this pagePass 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.
- Suggested location
src/lib/components/text-link-01
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.
Example
<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>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:
<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:
.band {
background: #09090b;
color: #d4d4d8;
--text-link-accent: #fafafa;
--text-link-ink: #fafafa;
--text-link-muted: #a1a1aa;
--text-link-visited: #d4d4d8;
}Props and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
href | string | Yes | None | Destination URL. Always rendered, so the link works before any script runs. |
children | Snippet | Yes | None | 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 | None | 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 | None | 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 | None | rel tokens, kept alongside the noopener and noreferrer a new tab adds. |
target | string | No | None | Passed through. target="_blank" is treated as newTab, so it is announced and gets the safe rel tokens. |
class | string | No | None | Extra classes for placement. |
Customization#
On this pageChoose 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-accentcolours accent links and the focus ring. Keep it at 4.5:1 on the page. Hover mixes 20% of--text-link-inkinto it. - Visited:
--text-link-visitedis the colour of visited accent links. The neutral default is the muted grey; the blue palette uses violet. - Neutrals:
--text-link-inkis the current page and the muted tone's hover colour;--text-link-mutedis the muted tone at rest. - Stretched cards: set
--text-link-card-radiuson 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
#09090bband:--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
newTabtogether; the arrow alone does not tell anyone a new tab will open.
Public CSS variables
| Variable | Token |
|---|---|
--text-link-accent | accent |
--text-link-ink | ink |
--text-link-muted | muted |
--text-link-visited | visited |
--text-link-card-radius | cardRadius |
Accessibility#
On this page- 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.newTabaddsnewTabTextto 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 setaria-labeloraria-labelledby, the announcement is carried into it. - Default colours: accent
#18181band visited#52525bclear 4.5:1 on white; the blue palette's#1d4ed8and#6d28d9clear 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.
Release details#
On this page- Integration
- Presentational
- Works without client-side JavaScript
- Server-side rendering supported
- Dependencies
- No additional runtime packages beyond Svelte and Tailwind CSS
- License
MIT. Default license approval is pending; see the license status before adopting the source.
- Version history
- 1.0.0 (Published) Current release · 1 October 2026
Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.