Skip to content
Download ZIP

Brand tint palette · 7.2 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_multi_column_01 · version 1.0.0 · Brand tint palette
Using the PageSugar MCP server, fetch component cmp_footer_multi_column_01 version 1.0.0 with variant "brand-tint", 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
Brand tint
Version
1.0.0
Digest
Full digest
sha256-6a365f24a65a83a15b263bdb94377dfd6b5683958dea5b5d1704ffa1e0b617e9
FooterMultiColumn.svelte Svelte · 12.0 KB Raw
<script lang="ts" module>
	export interface FooterLink {
		label: string;
		href: string;
		/** Opens in a new tab and says so, visibly and to assistive technology. */
		external?: boolean;
	}

	export interface FooterColumn {
		heading: string;
		links: FooterLink[];
	}

	export interface FooterBrand {
		/** Organisation name, set beside the logo. */
		name: string;
		/** Where the name links, usually the home page. Omitted, the name is plain text. */
		href?: string;
		/** One or two sentences under the name. */
		tagline?: string;
	}

	export interface FooterContact {
		email?: string;
		/** Shown as written. The tel: link keeps the leading + and the digits before any extension. */
		phone?: string;
		/** The tel: destination, when the displayed number cannot be dialled as written. */
		phoneHref?: string;
		addressLines?: string[];
	}
</script>

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

	interface Props {
		brand: FooterBrand;
		columns: FooterColumn[];
		copyright: string;
		logo?: Snippet;
		contact?: FooterContact;
		legalLinks?: FooterLink[];
		social?: Snippet;
		headingLevel?: 2 | 3 | 4;
		mobileBehaviour?: 'stack' | 'disclosure';
		brandPlacement?: 'start' | 'top';
		navLabel?: string;
		legalLabel?: string;
		newTabLabel?: string;
	}

	let {
		brand,
		columns,
		copyright,
		logo,
		contact,
		legalLinks = [],
		social,
		headingLevel = 2,
		mobileBehaviour = 'stack',
		brandPlacement = 'start',
		navLabel = 'Footer',
		legalLabel = 'Legal',
		newTabLabel = '(opens in new tab)'
	}: Props = $props();

	const uid = $props.id();
	const headingTag = $derived(`h${headingLevel}`);

	const groups = $derived(columns.filter((column) => column.links.length > 0));
	const addressLines = $derived((contact?.addressLines ?? []).filter((line) => line.trim()));
	const email = $derived(contact?.email?.trim() ?? '');
	const phone = $derived(contact?.phone?.trim() ?? '');
	const hasContact = $derived(Boolean(email || phone || addressLines.length));
	/* With no groups to sit beside, the brand spans the width and its contact moves to the end. */
	const beside = $derived(brandPlacement === 'start' && groups.length > 0);
	const disclosure = $derived(mobileBehaviour === 'disclosure');

	/*
	 * Complete static classes for each group count, so every track at lg holds a group. Above the
	 * columns, the brand's grid repeats their tracks and its contact takes the last one, so the
	 * contact starts on the same line as the last column below it.
	 */
	const lgCols: Record<number, string> = {
		1: 'lg:grid-cols-1',
		2: 'lg:grid-cols-2',
		3: 'lg:grid-cols-3',
		4: 'lg:grid-cols-4'
	};
	const topTracks: Record<number, [string, string]> = {
		2: ['lg:grid-cols-2', 'lg:col-span-1'],
		3: ['lg:grid-cols-3', 'lg:col-span-2'],
		4: ['lg:grid-cols-4', 'lg:col-span-3']
	};
	const columnsClass = $derived(lgCols[groups.length] ?? 'lg:grid-cols-5');
	const [topGrid, topSpan] = $derived(
		topTracks[groups.length] ??
			(groups.length > 4
				? ['lg:grid-cols-5', 'lg:col-span-4']
				: ['lg:grid-cols-3', 'lg:col-span-2'])
	);
	/* Below lg the groups pair up; an odd last group takes the whole row and splits its list in two. */
	const isWide = (index: number) => groups.length % 2 === 1 && index === groups.length - 1;

	/* Dial what precedes an extension ("ext. 12", "x12", ";", ","), keeping only + and digits. */
	const telHref = (value: string) => {
		const number = value.split(/[a-z;,]/i)[0].trim();
		return `tel:${number.startsWith('+') ? '+' : ''}${number.replace(/\D/g, '')}`;
	};

	/*
	 * The arrow travels with the label's last word, so a wrapped label never leaves it alone on a
	 * line. A last word too long to hold together is left free to break.
	 */
	const splitLabel = (label: string) => {
		const text = label.trim();
		const at = text.lastIndexOf(' ') + 1;
		return { head: text.slice(0, at), tail: text.slice(at) };
	};

	/* One link style for every list: muted at rest, ink on hover, a 44 px row on a coarse pointer. */
	const linkClass =
		'footer-multi-column__link inline-flex max-w-full min-h-11 min-w-11 items-center rounded-sm text-[var(--_muted)] transition-colors duration-150 ease-[cubic-bezier(.2,0,0,1)] hover:text-[var(--_ink)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--_accent)] pointer-fine:min-h-0 pointer-fine:min-w-0';
</script>

{#snippet anchor(link: FooterLink)}
	<a
		href={link.href}
		class={linkClass}
		target={link.external ? '_blank' : undefined}
		rel={link.external ? 'noopener noreferrer' : undefined}
	>
		<span class="min-w-0 [overflow-wrap:anywhere]">
			{#if link.external}
				{@const { head, tail } = splitLabel(link.label)}
				{head}<span class={tail.length <= 24 ? 'whitespace-nowrap' : undefined}
					>{tail}<svg
						class="ms-0.5 inline size-3.5 align-[-0.125em] rtl:-scale-x-100"
						viewBox="0 0 16 16"
						fill="none"
						stroke="currentColor"
						stroke-width="1.75"
						stroke-linecap="round"
						stroke-linejoin="round"
						aria-hidden="true"><path d="M4.5 11.5l7-7M6 4.5h5.5V10" /></svg
					></span
				><span class="sr-only">&nbsp;{newTabLabel}</span>
			{:else}
				{link.label}
			{/if}
		</span>
	</a>
{/snippet}

{#snippet list(column: FooterColumn, labelId: string, wide = false)}
	<ul
		role="list"
		aria-labelledby={labelId}
		class={[
			'grid text-sm leading-5 pointer-fine:gap-y-3',
			wide && 'grid-cols-2 gap-x-8 lg:grid-cols-1'
		]}
	>
		{#each column.links as link, index (index)}
			<li class="min-w-0">{@render anchor(link)}</li>
		{/each}
	</ul>
{/snippet}

<footer
	class="footer-multi-column border-t border-[var(--_hairline)] bg-[var(--_surface)] px-4 pt-16 pb-8 text-start sm:px-6 sm:pt-24 lg:px-8 lg:pt-32"
>
	<div class="mx-auto max-w-6xl">
		<div class={['grid gap-x-8 gap-y-12', beside && 'lg:grid-cols-3']}>
			<!-- The brand hangs from the same rule as the columns, so every group starts on one line. -->
			<div
				class={[
					'min-w-0 border-t border-[var(--_rule)] pt-4',
					!beside && ['lg:grid lg:gap-x-8', topGrid]
				]}
			>
				<div class={['min-w-0', !beside && topSpan]}>
					{#if brand.href}
						<a
							href={brand.href}
							class="footer-multi-column__tracked inline-flex min-h-11 max-w-full items-center gap-2 rounded-sm text-lg leading-[1.3] font-semibold tracking-[-0.015em] text-[var(--_ink)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--_accent)] pointer-fine:min-h-0"
						>
							{#if logo}{@render logo()}{/if}
							<span class="min-w-0 [overflow-wrap:anywhere]">{brand.name}</span>
						</a>
					{:else}
						<p
							class="footer-multi-column__tracked flex items-center gap-2 text-lg leading-[1.3] font-semibold tracking-[-0.015em] text-[var(--_ink)]"
						>
							{#if logo}{@render logo()}{/if}
							<span class="min-w-0 [overflow-wrap:anywhere]">{brand.name}</span>
						</p>
					{/if}
					{#if brand.tagline}
						<p
							class="mt-4 max-w-sm text-base leading-6 text-pretty [overflow-wrap:anywhere] text-[var(--_muted)]"
						>
							{brand.tagline}
						</p>
					{/if}
				</div>
				{#if hasContact || social}
					<div class={['min-w-0', beside ? 'mt-8' : 'mt-8 lg:mt-0']}>
						{#if hasContact}
							<address class="grid text-sm leading-5 text-[var(--_muted)] not-italic">
								{#if addressLines.length}
									<span class="block pb-3">
										{#each addressLines as line, index (index)}
											<span class="block [overflow-wrap:anywhere]">{line}</span>
										{/each}
									</span>
								{/if}
								{#if email}
									<span class="block min-w-0">
										<a href="mailto:{email}" class={linkClass}>
											<span dir="ltr" class="min-w-0 [overflow-wrap:anywhere]">{email}</span>
										</a>
									</span>
								{/if}
								{#if phone}
									<span class="block min-w-0 pointer-fine:pt-3">
										<a
											href={contact?.phoneHref?.trim() || telHref(phone)}
											class={[linkClass, 'tabular-nums']}
										>
											<span dir="ltr" class="min-w-0 [overflow-wrap:anywhere]">{phone}</span>
										</a>
									</span>
								{/if}
							</address>
						{/if}
						{#if social}
							<div class={hasContact ? 'mt-8' : undefined}>{@render social()}</div>
						{/if}
					</div>
				{/if}
			</div>

			{#if groups.length}
				<nav aria-label={navLabel} class={['min-w-0', beside && 'lg:col-span-2']}>
					<div
						class={[
							'grid-cols-2 items-start gap-x-8 gap-y-12',
							columnsClass,
							disclosure ? 'hidden sm:grid' : 'grid'
						]}
					>
						{#each groups as column, index (index)}
							<div
								class={[
									'min-w-0 border-t border-[var(--_rule)] pt-4',
									isWide(index) && 'col-span-2 lg:col-span-1'
								]}
							>
								<svelte:element
									this={headingTag}
									id="{uid}-heading-{index}"
									class="text-sm leading-5 font-semibold text-balance [overflow-wrap:anywhere] text-[var(--_ink)]"
								>
									{column.heading}
								</svelte:element>
								<div class="mt-4 pointer-coarse:mt-0">
									{@render list(column, `${uid}-heading-${index}`, isWide(index))}
								</div>
							</div>
						{/each}
					</div>

					{#if disclosure}
						<!-- Below sm each group folds into a native disclosure, so it opens without JavaScript. -->
						<div class="border-t border-[var(--_rule)] sm:hidden">
							{#each groups as column, index (index)}
								<!-- A real heading for each group, read before its disclosure; the summary shows the same text. -->
								<svelte:element this={headingTag} id="{uid}-fold-{index}" class="sr-only">
									{column.heading}
								</svelte:element>
								<details class="group border-b border-[var(--_hairline)] last:border-b-0">
									<summary
										class="flex min-h-12 cursor-pointer list-none items-center justify-between gap-4 rounded-sm text-sm leading-5 font-semibold text-[var(--_ink)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--_accent)] [&::-webkit-details-marker]:hidden"
									>
										<span class="min-w-0 [overflow-wrap:anywhere]">{column.heading}</span>
										<svg
											class="size-4 shrink-0 text-[var(--_muted)] transition-transform duration-200 ease-[cubic-bezier(.2,0,0,1)] group-open:rotate-180 motion-reduce:transition-none"
											viewBox="0 0 16 16"
											fill="none"
											stroke="currentColor"
											stroke-width="1.75"
											stroke-linecap="round"
											stroke-linejoin="round"
											aria-hidden="true"
										>
											<path d="M4 6l4 4 4-4" />
										</svg>
									</summary>
									<div class="pb-3">
										{@render list(column, `${uid}-fold-${index}`)}
									</div>
								</details>
							{/each}
						</div>
					{/if}
				</nav>
			{/if}
		</div>

		<div
			class="mt-12 flex flex-col gap-3 border-t border-[var(--_hairline)] pt-8 text-[0.8125rem] leading-5 text-[var(--_muted)] sm:flex-row sm:items-start sm:justify-between sm:gap-8"
		>
			<p class="max-w-[30em] min-w-0 text-pretty [overflow-wrap:anywhere] tabular-nums">
				{copyright}
			</p>
			{#if legalLinks.length}
				<ul
					role="list"
					aria-label={legalLabel}
					class="flex flex-wrap gap-x-8 sm:justify-end pointer-fine:gap-y-3"
				>
					{#each legalLinks as link, index (index)}
						<li class="min-w-0">{@render anchor(link)}</li>
					{/each}
				</ul>
			{/if}
		</div>
	</div>
</footer>

<style>
	/* Public tokens: set --footer-multi-column-* on this footer or any ancestor to retone it. */
	.footer-multi-column {
		--_accent: var(--footer-multi-column-accent, #1d4ed8);
		--_on-accent: var(--footer-multi-column-on-accent, #ffffff);
		--_ink: var(--footer-multi-column-ink, #18181b);
		--_muted: var(--footer-multi-column-muted, #52525b);
		--_rule: var(--footer-multi-column-rule, #1e3a8a);
		--_hairline: var(--footer-multi-column-hairline, #1e3a8a1f);
		--_surface: var(--footer-multi-column-surface, #eff6ff);
	}

	/* Selected text takes the accent pair, so a copied address or email reads in the brand's colours. */
	.footer-multi-column ::selection {
		background-color: var(--_accent);
		color: var(--_on-accent);
	}

	/* Arabic and Hebrew are never letter-spaced; tracked text resets under right-to-left. */
	.footer-multi-column__tracked:dir(rtl) {
		letter-spacing: 0;
	}
</style>

Presentational only: every heading, link, contact line and small-print string is rendered as supplied. It does not include a newsletter form, supply social profiles, check that links resolve or work out the copyright year; pass the complete copyright text. No runtime packages beyond Svelte and Tailwind CSS.

Suggested location
src/lib/components/footer-multi-column-01
Required props
brandcolumnscopyright

Limitations

  • Holds five columns a row at lg; a sixth column wraps to a second row.
  • Columns with no links are skipped. With an empty columns array the navigation is omitted and the brand, contact and small print still render.
  • In disclosure mode the links are rendered twice, once as disclosures below 640 px and once as headed lists from 640 px; CSS hides one copy, so assistive technology meets them once, but the markup is larger.
  • Light appearance by default. The dark and brand-tint palettes retone it through its variables; no dark mode is declared or selected automatically.

Example

Svelte
<script lang="ts">
	import FooterMultiColumn, {
		type FooterColumn
	} from '$lib/components/footer-multi-column-01/FooterMultiColumn.svelte';

	const columns: FooterColumn[] = [
		{
			heading: 'Product',
			links: [
				{ label: 'Features', href: '/features' },
				{ label: 'Pricing', href: '/pricing' }
			]
		},
		{
			heading: 'Company',
			links: [
				{ label: 'About', href: '/about' },
				{ label: 'Status', href: 'https://status.example.com', external: true }
			]
		}
	];
</script>

<FooterMultiColumn
	brand={{ name: 'Example Co', href: '/', tagline: 'What you do, in one plain sentence.' }}
	{columns}
	contact={{ email: 'hello@example.com', phone: '+44 20 7946 0000' }}
	legalLinks={[{ label: 'Privacy', href: '/privacy' }, { label: 'Terms', href: '/terms' }]}
	copyright="© 2026 Example Co Ltd."
	mobileBehaviour="disclosure"
/>

Filling it in#

Copy FooterMultiColumn.svelte into src/lib/components/footer-multi-column-01/. The types FooterBrand, FooterColumn, FooterLink and FooterContact are exported from its module script.

  • brand is { name, href?, tagline? }. Pass your mark through the logo snippet; it renders inside the brand link, before the name, so keep it aria-hidden.
  • columns is the link groups in display order. Two to five read best. A link with external: true opens a new tab, shows an arrow and reads newTabLabel to screen readers; nothing else opens a new tab.
  • contact renders in an <address>: address lines first, then the email and phone as mailto: and tel: links. Leave it out and no address renders.
  • copyright is the whole string, year included. The component never computes the year, so the server and the browser always render the same text.
  • social is your own snippet, rendered under the contact details. Give its list a name such as "Follow us".

Layout#

Width brandPlacement="start" (default) brandPlacement="top"
under 1024 Brand above, columns two to a row Brand above, columns two to a row
1024 and up Brand on the start third, columns share the rest Brand across the width, contact on the end third, columns share the full row

Every group, the brand block included, hangs from one rule, so columns of different lengths still start on the same line. With mobileBehaviour="disclosure", below 640 px each column folds into a native <details> with the heading as its summary; from 640 px the plain headed lists show instead.

Retoning#

Variable Default Draws
--footer-multi-column-ink #18181b Brand name, headings, link hover
--footer-multi-column-muted #52525b Tagline, links, contact, small print
--footer-multi-column-rule the ink value The rule each group hangs from
--footer-multi-column-hairline rgb(0 0 0 / 0.08) Top edge, small-print divider, disclosure rows
--footer-multi-column-accent #18181b Focus ring, selection fill
--footer-multi-column-on-accent #ffffff Selected text
--footer-multi-column-surface #ffffff Background

A dark band:

Svelte
<div
	style="
		--footer-multi-column-surface: #09090b;
		--footer-multi-column-ink: #fafafa;
		--footer-multi-column-muted: #a1a1aa;
		--footer-multi-column-rule: #fafafa;
		--footer-multi-column-hairline: rgb(255 255 255 / 0.1);
		--footer-multi-column-accent: #fafafa;
		--footer-multi-column-on-accent: #18181b;
	"
>
	<FooterMultiColumn {brand} {columns} {copyright} />
</div>

Props and content inputs#

On this page
NameTypeRequiredDefaultDescription
brandFooterBrandYesNone{ name, href?, tagline? }. The name links to href when given; the tagline sits under it at a readable measure.
columnsFooterColumn[]YesNoneLink groups in display order, each { heading, links: { label, href, external? }[] }. Two to five read best; groups with no links are skipped.
contactFooterContactNoNone{ email?, phone?, phoneHref?, addressLines? } rendered in an address element with mailto: and tel: links. The tel: link dials the leading + and the digits before any extension unless phoneHref is given. Blank fields are skipped; with none left, no address renders.
socialSnippetNoNoneYour own social links group, rendered under the contact details.
headingLevel2 | 3 | 4No2Heading level for the column headings.
mobileBehaviour'stack' | 'disclosure'No'stack'Below 640 px, stack the columns two to a row, or fold each into a native details/summary disclosure.
brandPlacement'start' | 'top'No'start'Brand block on the start third beside the columns at lg, or across the width above them with the contact on the end third.
navLabelstringNo'Footer'Accessible name of the navigation landmark holding the columns.
legalLabelstringNo'Legal'Accessible name of the legal links list.
newTabLabelstringNo'(opens in new tab)'Visually hidden text after each external link's label; translate it with the rest of the page.

Customization#

On this page

Change content through props, retone the footer through seven --footer-multi-column-* variables (accent, on-accent, ink, muted, rule, hairline, surface), and edit Tailwind classes in the source for spacing or column counts.

  • Text: --footer-multi-column-ink sets the brand name, column headings and link hover; --footer-multi-column-muted sets the tagline, links, contact and small print. Keep muted at 4.5:1 against the surface.
  • Rules: --footer-multi-column-rule draws the line every group hangs from (ink by default); --footer-multi-column-hairline draws the footer's top edge, the small-print divider and the disclosure dividers.
  • Accent: --footer-multi-column-accent is the focus ring and the text-selection fill, with --footer-multi-column-on-accent as the selected text. Keep the accent at 3:1 against the surface and on-accent at 4.5:1 against the accent.
  • Surface: --footer-multi-column-surface is the footer background.
  • Dark band: surface #09090b, ink #fafafa, muted #a1a1aa, rule #fafafa, hairline #ffffff1a, accent #fafafa, on-accent #18181b. usage.md has the snippet.
  • Brand tint: surface #eff6ff, rule #1e3a8a, hairline #1e3a8a1f, accent #1d4ed8, on-accent #ffffff; ink and muted stay zinc.
  • Columns: the besideCols and topCols maps at the top of the script decide the lg column count; below lg two columns share a row.
  • Headings: set headingLevel so the column headings fit your page outline.
  • Copyright: pass the full string, including the year; the component does not compute it, so server and client renders always agree.

Public CSS variables

VariableToken
--footer-multi-column-accentaccent
--footer-multi-column-on-accentonAccent
--footer-multi-column-inkink
--footer-multi-column-mutedmuted
--footer-multi-column-rulerule
--footer-multi-column-hairlinehairline
--footer-multi-column-surfacesurface

Accessibility#

On this page
  • The root is a footer element; the link columns sit in a nav landmark named by navLabel ("Footer"), so it is distinct from the header's navigation.
  • Column headings are real headings at headingLevel, and each list is labelled by its heading through aria-labelledby.
  • In disclosure mode below 640 px each group is a native details/summary that opens with Enter or Space and works without JavaScript. A visually hidden heading at headingLevel sits before each disclosure and labels its list, so the heading outline is the same at every width.
  • Contact details render in an address element with mailto: and tel: links; nothing renders when contact is omitted or empty.
  • Only links with external: true open a new tab. They carry rel="noopener noreferrer", a decorative arrow kept on the line with the label's last word, and visually hidden newTabLabel text.
  • 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.
  • On a coarse pointer every link row is at least 44 px tall; disclosure summaries are 48 px.
  • The layout uses logical properties and mirrors under dir="rtl"; the external-link arrow flips and the brand name's tracking resets.
  • IDs come from $props.id(), so two footers on one page stay unique.

Known limitations

  • Contrast ratios are computed for the shipped palettes; re-check any retoned token (4.5:1 for text, 3:1 for the focus ring).
  • The social snippet is yours: give it an accessible name and text or labelled icons.

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 · 30 September 2026

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