# Legal links row

> A compact row of policy links (privacy, terms, cookies, accessibility) for a footer's small print or a checkout page, with optional copyright and a Cookie settings button, divided by CSS dots or bars.

- ID: `cmp_footer_legal_links_01`
- Slug: `footer-legal-links-01`
- Version: `1.0.0` (current)
- Status: published
- Published: 2026-09-30T23:46:47Z
- Updated: 2026-09-30
- Available versions: `1.0.0`
- Kind: control
- Primary category: `footers`
- Detail page: https://pagesugar.com/components/footer-legal-links-01?variant=neutral
- Preview: https://pagesugar.com/preview/footer-legal-links-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `neutral` | Neutral | yes | `sha256-da006442a6afca02ee26630968e82eebfb5843e1d97fdc420afbf57d086adaa1` |
| `dark` | Dark footer | no | `sha256-f8bc7378c62232f67b5a530eae72d8ef2056c14006e5c4e412f1dae009de6d15` |

## 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: local-interaction
- Appearance modes: light
- Suggested directory: `src/lib/components/footer-legal-links-01`

## Dependencies

No third-party runtime packages.

## Services

No external services required.

## Usage

Renders the policy links, copyright and button it is given. It supplies no policy text, does not implement cookie consent (the button only calls your consent manager), does not decide which policies you need, and does not compute the copyright year. No runtime packages beyond Svelte and Tailwind CSS.

Required props: `links`

```svelte
<script lang="ts">
	import LegalLinks from '$lib/components/footer-legal-links-01/LegalLinks.svelte';

	const links = [
		{ label: 'Privacy', href: '/legal/privacy' },
		{ label: 'Terms', href: '/legal/terms' },
		{ label: 'Cookies', href: '/legal/cookies' },
		{ label: 'Accessibility', href: '/legal/accessibility' }
	];

	function openCookieSettings() {
		// Call your consent manager here, for example window.CookieConsent?.showPreferences().
	}
</script>

<LegalLinks
	{links}
	copyright="© 2026 Example Co Ltd."
	align="between"
	currentHref="/legal/privacy"
	onOpenCookieSettings={openCookieSettings}
/>
```

Limitations:

- With align="center", hiding the separator at the start of a wrapped line needs JavaScript; before it runs, a wrapped centred line can open with a mark. Start and between alignments need none.
- currentHref matches a link's href exactly, ignoring a trailing slash, query and hash; it does not match nested paths.
- With no links and no callback the navigation is omitted; only the copyright renders.
- The row paints no background: it takes the surface of the footer it sits in. Retone its text for a dark footer through the variables.

## Usage guide

### Legal links row

A row of policy links for a footer's small print or a checkout page. It renders the links,
copyright and button you pass; it holds no policy text and implements no cookie consent.

#### Cookie settings

Pass `onOpenCookieSettings` and a **Cookie settings** button renders after the links. It is a
real `<button>`, because it opens a panel instead of going somewhere. Call your consent
manager's "show preferences" method from it:

```svelte
<LegalLinks {links} onOpenCookieSettings={() => window.CookieConsent?.showPreferences()} />
```

Leave it out and the row is plain links that work without JavaScript.

#### Current page

Pass the current path as `currentHref` (in SvelteKit, `page.url.pathname` from `$app/state`,
read in your own layout) and the matching link carries `aria-current="page"`. Matching ignores
a trailing slash, the query and the hash.

#### Separators

The dots or bars are CSS generated content, so screen readers never read them. Each item holds
its separator in its own 24 px leading padding, and the list is pulled back by that padding and
clipped, so the mark before the first item of a start-aligned line is never shown. Centred rows
hide it with a small `ResizeObserver` once JavaScript runs. If you change the gap, change the
item's `ps-6`, the list's `-ms-6` and the `clip-path` inset together.

#### Dark footer

The row paints no background. On a dark footer, set:

```css
.site-footer {
	--footer-legal-links-ink: #fafafa;
	--footer-legal-links-muted: #a1a1aa;
	--footer-legal-links-hairline: #ffffff47;
	--footer-legal-links-accent: #fafafa;
}
```

## Props

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `links` | `LegalLink[]` | yes |  | Policy destinations in display order, each { label, href }. |
| `label` | `string` | no | `'Legal'` | Accessible name of the nav landmark. |
| `onOpenCookieSettings` | `() => void` | no |  | Supplied, a Cookie settings button renders after the links and calls it; open your consent manager's preferences here. |
| `cookieSettingsLabel` | `string` | no | `'Cookie settings'` | Text of the cookie settings button. |
| `copyright` | `string` | no |  | Complete copyright text, including the year. Blank, nothing renders. |
| `align` | 'start' \| 'center' \| 'between' | no | `'start'` | start: copyright then links from the start edge. center: links over copyright, centred. between: copyright at the start, links at the end. Below 640 px the links always sit above the copyright. |
| `separator` | 'dot' \| 'bar' | no | `'dot'` | The mark drawn between items: a small dot or a thin vertical bar. |
| `currentHref` | `string` | no |  | The current page's path; the matching link gets aria-current="page" and ink colour. |

## Customization

Change content through props and retone the row through four --footer-legal-links-\* variables (accent, ink, muted, hairline). It paints no background and no accent fill, so it has no surface or on-accent token.

- Text: --footer-legal-links-muted sets the links and copyright; --footer-legal-links-ink sets the hover and current-page colour. Keep muted at 4.5:1 against your footer.
- Separators: --footer-legal-links-hairline colours the dots or bars; they are decorative, so they only need to be visible.
- Focus: --footer-legal-links-accent is the focus ring; keep it at 3:1 against the footer.
- Dark footer: ink #fafafa, muted #a1a1aa, hairline #ffffff47, accent #fafafa. usage.md has the snippet.
- Spacing: each item's ps-6 is the gap and the room its separator sits in; the list's -ms-6 and the clip-path in the style block must change with it.
- Copyright: pass the full string, including the year; the component never computes it, so server and client renders agree.

| Token | Public CSS variable |
| --- | --- |
| `accent` | `--footer-legal-links-accent` |
| `ink` | `--footer-legal-links-ink` |
| `muted` | `--footer-legal-links-muted` |
| `hairline` | `--footer-legal-links-hairline` |

## Accessibility

- The links sit in a nav landmark named by label ("Legal") holding a list, so screen readers announce the count.
- Separators are CSS generated content with no text, so they are not in the accessibility tree and are never read.
- Cookie settings is a \<button type="button"\> because it opens a panel rather than navigating; it renders only when onOpenCookieSettings is supplied.
- The link matching currentHref carries aria-current="page" and is set in ink at weight 500, so the current page is marked by more than colour.
- Links are muted at rest (#52525b, 7.7:1 on white) and turn ink on hover; focus shows a two-pixel accent outline offset by two pixels on :focus-visible only.
- Targets are at least 24 px under a fine pointer and 44 px under a coarse one.
- The row uses logical properties and mirrors under dir="rtl", separators and clip included.

Known limitations:

- The row inherits the footer's background: re-check contrast for any retone (4.5:1 for muted text, 3:1 for the focus ring).
- Your consent manager owns focus once its panel opens and should return it to the button when it closes.

## License

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

## Source

- Palette: Neutral (`neutral`)
- Entry: `LegalLinks.svelte`
- Suggested directory: `src/lib/components/footer-legal-links-01`
- Files: 1
- Artifact digest: `sha256-da006442a6afca02ee26630968e82eebfb5843e1d97fdc420afbf57d086adaa1`

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

#### `LegalLinks.svelte`

Role: entry · 7665 bytes · SHA-256 `73f37226116aa09e46a70bd640f4de144f96cf6334b4eadecf9063b3739c651d`

```svelte
<script lang="ts" module>
	export interface LegalLink {
		label: string;
		href: string;
	}
</script>

<script lang="ts">
	interface Props {
		/** Policy destinations in display order. */
		links: LegalLink[];
		/** Accessible name of the navigation landmark. */
		label?: string;
		/** Supplied, a Cookie settings button renders and calls it; your consent tool opens its panel. */
		onOpenCookieSettings?: () => void;
		/** Text of the cookie settings button. */
		cookieSettingsLabel?: string;
		/** Complete copyright text, including the year. */
		copyright?: string;
		/** Where the row sits: copyright then links from the start, stacked in the centre, or copyright at the start and links at the end. */
		align?: 'start' | 'center' | 'between';
		/** The mark drawn between items. */
		separator?: 'dot' | 'bar';
		/** The current page's path; the link whose href matches it, ignoring a trailing slash, the query and the hash, is marked aria-current="page". */
		currentHref?: string;
	}

	let {
		links,
		label = 'Legal',
		onOpenCookieSettings,
		cookieSettingsLabel = 'Cookie settings',
		copyright,
		align = 'start',
		separator = 'dot',
		currentHref
	}: Props = $props();

	let list = $state<HTMLUListElement>();

	const trimmed = $derived(copyright?.trim() ?? '');
	const hasItems = $derived(links.length > 0 || Boolean(onOpenCookieSettings));

	/* Trailing slashes, query strings and hashes do not stop a match: /legal/privacy/ is /legal/privacy. */
	const normalise = (href: string) => href.split(/[?#]/)[0].replace(/(.)\/+$/, '$1');
	const isCurrent = (href: string) =>
		currentHref !== undefined && normalise(href) === normalise(currentHref);

	/*
	 * Each item carries its separator in its own leading padding, and the list is pulled back by
	 * that padding and clipped there, so the mark before the first item of every start-aligned
	 * line falls outside the row. Centred lines start mid-row, where no clip reaches: once
	 * JavaScript runs, the item that opens each line is marked and its separator hidden, and
	 * marked again whenever an item, the list or the loaded fonts change where lines break.
	 */
	$effect(() => {
		const ul = list;
		/* Read so the marks are redone once new or relabelled items reach the page. */
		void links.map((link) => link.label);
		void cookieSettingsLabel;
		void onOpenCookieSettings;
		if (!ul || typeof ResizeObserver === 'undefined') return;
		let live = true;
		const mark = () => {
			let top = Number.NaN;
			for (const item of ul.children as HTMLCollectionOf<HTMLElement>) {
				const starts = !(Math.abs(item.offsetTop - top) < 2);
				item.toggleAttribute('data-line-start', starts);
				top = item.offsetTop;
			}
		};
		const observer = new ResizeObserver(mark);
		observer.observe(ul);
		for (const item of ul.children) observer.observe(item);
		mark();
		document.fonts?.ready.then(() => live && mark());
		return () => {
			live = false;
			observer.disconnect();
		};
	});

	/* 24 px targets under a fine pointer and 44 px under a coarse one; the colour is set per state. */
	const itemClass =
		'inline-flex min-h-11 min-w-11 items-center gap-2 rounded-sm transition-colors duration-150 ease-[cubic-bezier(.2,0,0,1)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--_accent) pointer-fine:min-h-6 pointer-fine:min-w-6';
</script>

<div
	class={[
		'footer-legal-links flex min-w-0 flex-col gap-y-4 text-[0.8125rem] leading-5',
		align === 'center' && 'items-center text-center',
		align === 'start' && 'sm:flex-row sm:flex-wrap sm:items-baseline sm:gap-x-8',
		align === 'between' &&
			'sm:flex-row sm:flex-wrap sm:items-baseline sm:justify-between sm:gap-x-8'
	]}
	data-separator={separator}
>
	{#if trimmed}
		<!-- Links first on a phone and in the centred stack; the copyright leads the line from sm. -->
		<p
			class={[
				'order-last flex min-h-6 min-w-0 items-center [overflow-wrap:anywhere] text-(--_muted) tabular-nums',
				align === 'center' ? 'text-balance' : 'text-pretty',
				align !== 'center' && 'sm:order-none sm:max-w-[30em] sm:pointer-coarse:min-h-11'
			]}
		>
			{trimmed}
		</p>
	{/if}

	{#if hasItems}
		<!--
			The list's pulled-back strip never adds scrollable width, even in a right-to-left block.
			The nav reaches 8 px past the row on both sides, so focus rings at either end sit inside
			its clip.
		-->
		<nav
			aria-label={label}
			class={['-mx-2 min-w-0 overflow-x-clip px-2', align === 'center' && 'max-w-full']}
		>
			<ul
				bind:this={list}
				role="list"
				class={[
					'footer-legal-links__list -ms-6 flex flex-wrap gap-y-2 pointer-coarse:gap-y-0',
					align === 'center' && 'justify-center'
				]}
			>
				{#each links as link, index (index)}
					<li class="footer-legal-links__item relative flex min-w-0 items-center ps-6">
						<a
							href={link.href}
							class={[
								itemClass,
								isCurrent(link.href)
									? 'font-medium text-(--_ink)'
									: 'text-(--_muted) hover:text-(--_ink)'
							]}
							aria-current={isCurrent(link.href) ? 'page' : undefined}
						>
							<span class="min-w-0 [overflow-wrap:anywhere]">{link.label}</span>
						</a>
					</li>
				{/each}
				{#if onOpenCookieSettings}
					<li class="footer-legal-links__item relative flex min-w-0 items-center ps-6">
						<!-- A button, not a link: it opens the consent tool's panel instead of navigating. -->
						<button
							type="button"
							class={[itemClass, 'cursor-pointer text-start text-(--_muted) hover:text-(--_ink)']}
							onclick={() => onOpenCookieSettings?.()}
						>
							<svg
								class="size-3.5 shrink-0"
								viewBox="0 0 16 16"
								fill="none"
								stroke="currentColor"
								stroke-width="1.75"
								stroke-linecap="round"
								aria-hidden="true"
							>
								<path d="M2.5 5h6M12.5 5h1M2.5 11h1M7.5 11h6" />
								<circle cx="10.5" cy="5" r="1.75" />
								<circle cx="5.5" cy="11" r="1.75" />
							</svg>
							<span class="min-w-0 [overflow-wrap:anywhere]">{cookieSettingsLabel}</span>
						</button>
					</li>
				{/if}
			</ul>
		</nav>
	{/if}
</div>

<style>
	/* Public tokens: set --footer-legal-links-* on this row or any ancestor to retone it. */
	.footer-legal-links {
		--_accent: var(--footer-legal-links-accent, #18181b);
		--_ink: var(--footer-legal-links-ink, #18181b);
		--_muted: var(--footer-legal-links-muted, #52525b);
		--_hairline: var(--footer-legal-links-hairline, rgb(0 0 0 / 0.24));
	}

	/*
	 * The list sits 24 px before the row's start edge; clipping 18 px of that hides the separator
	 * of every item that opens a start-aligned line, and leaves room for its focus ring. The clip
	 * reaches past the other three sides so rings there are never cut. CSS, because the inset is
	 * tied to the items' padding and separator position and flips with the direction.
	 */
	.footer-legal-links__list {
		clip-path: inset(-0.5rem -0.5rem -0.5rem 1.125rem);
	}
	.footer-legal-links__list:dir(rtl) {
		clip-path: inset(-0.5rem 1.125rem -0.5rem -0.5rem);
	}

	/* Separators are generated content, so assistive technology never reads them. */
	.footer-legal-links__item::before {
		content: '';
		position: absolute;
		inset-inline-start: calc(0.75rem - 1.5px);
		top: 50%;
		width: 3px;
		height: 3px;
		margin-top: -1.5px;
		border-radius: 9999px;
		background-color: var(--_hairline);
	}
	[data-separator='bar'] .footer-legal-links__item::before {
		inset-inline-start: calc(0.75rem - 0.5px);
		width: 1px;
		height: 0.75rem;
		margin-top: -0.375rem;
		border-radius: 0;
	}
	.footer-legal-links__item:first-child::before,
	.footer-legal-links__item:global([data-line-start])::before {
		visibility: hidden;
	}
</style>
```

## Artifacts

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

- Artifact digest: `sha256-da006442a6afca02ee26630968e82eebfb5843e1d97fdc420afbf57d086adaa1`
- Entry: `LegalLinks.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_footer_legal_links_01/1.0.0/neutral/sha256-da006442a6afca02ee26630968e82eebfb5843e1d97fdc420afbf57d086adaa1/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_footer_legal_links_01/1.0.0/neutral/sha256-da006442a6afca02ee26630968e82eebfb5843e1d97fdc420afbf57d086adaa1/bundle.zip (5883 bytes, sha256 `123e0f31350a159b29edc65dff0473c7ff9aa470a27b54fff063a98f8123ae2d`)

Files:

- `LegalLinks.svelte` (entry, 7665 bytes): https://pagesugar.com/artifacts/cmp_footer_legal_links_01/1.0.0/neutral/sha256-da006442a6afca02ee26630968e82eebfb5843e1d97fdc420afbf57d086adaa1/source/LegalLinks.svelte
