Skip to content
Download ZIP

Dark footer palette · 5.7 KB ZIP File receipt View as Markdown View code

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.
cmp_footer_legal_links_01 · version 1.0.0 · Dark footer palette
Using the PageSugar MCP server, fetch component cmp_footer_legal_links_01 version 1.0.0 with variant "dark", 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
Dark footer
Version
1.0.0
Digest
Full digest
sha256-f8bc7378c62232f67b5a530eae72d8ef2056c14006e5c4e412f1dae009de6d15
LegalLinks.svelte Svelte · 7.5 KB Raw
<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, #fafafa);
		--_ink: var(--footer-legal-links-ink, #fafafa);
		--_muted: var(--footer-legal-links-muted, #a1a1aa);
		--_hairline: var(--footer-legal-links-hairline, #ffffff47);
	}

	/*
	 * 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>

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.

Suggested location
src/lib/components/footer-legal-links-01
Required props
links

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.

Example

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}
/>

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.

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.

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 and content inputs#

On this page
NameTypeRequiredDefaultDescription
labelstringNo'Legal'Accessible name of the nav landmark.
onOpenCookieSettings() => voidNoNoneSupplied, a Cookie settings button renders after the links and calls it; open your consent manager's preferences here.
cookieSettingsLabelstringNo'Cookie settings'Text of the cookie settings button.
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.
currentHrefstringNoNoneThe current page's path; the matching link gets aria-current="page" and ink colour.

Customization#

On this page

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.

Public CSS variables

VariableToken
--footer-legal-links-accentaccent
--footer-legal-links-inkink
--footer-legal-links-mutedmuted
--footer-legal-links-hairlinehairline

Accessibility#

On this page
  • 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.

Release details#

On this page
Integration
  • Local interaction
  • 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 · 30 September 2026

Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.