Skip to content
Download ZIP

Neutral palette · 7.6 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_download_link_01 · version 1.0.0 · Neutral palette
Using the PageSugar MCP server, fetch component cmp_download_link_01 version 1.0.0 with variant "neutral", 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
Neutral
Version
1.0.0
Digest
Full digest
sha256-ec2e8d3d0d9f8b6bde0137855ad4dd50cc0c93e262dc402d10644f05f950debb
DownloadLink.svelte Svelte · 12.3 KB Raw
<!--
	A download link: a native <a download> that names the file, its format and its size before the
	click. A document-shaped tile carries the format in small capitals and sits level with the
	title, so a stack of rows reads as one list of files you can judge at a glance. Three
	treatments share the anchor: a row (stacks into a divided list inside a <ul>), a card with a
	description, and a filled button. Nothing here runs in the browser; the link works with no
	script.
-->
<script lang="ts" module>
	export type DownloadLinkVariant = 'row' | 'button' | 'card';
</script>

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

	type Passthrough = Omit<
		HTMLAnchorAttributes,
		'href' | 'title' | 'download' | 'children' | 'class' | 'target' | 'rel'
	>;

	interface Props extends Passthrough {
		/** File URL. Same-origin for the download attribute to take effect. */
		href: string;
		/** Human-readable name, such as "2026 product brochure". */
		title: string;
		/** Suggested saved filename for the download attribute. */
		filename?: string;
		/** Display format, such as "PDF". Shown in the tile and the meta line. */
		format?: string;
		/** Pre-formatted size, such as "2.4 MB". */
		size?: string;
		/** One line under the title (row and card; the button ignores it). */
		description?: string;
		/** row for lists, card for a featured file, button for a single primary action. */
		variant?: DownloadLinkVariant;
		/** Open the file in a new tab instead of downloading it, for previewable PDFs. */
		newTab?: boolean;
		/** The words that announce a new tab, shown in the meta line and read with the name. */
		newTabText?: string;
		/** Extra rel tokens; noopener and noreferrer are added for a new tab. */
		rel?: string;
		/** Extra classes for placement. */
		class?: string;
	}

	let {
		href,
		title,
		filename,
		format,
		size,
		description,
		variant = 'row',
		newTab = false,
		newTabText = 'Opens in new tab',
		rel,
		class: className,
		...rest
	}: Props = $props();

	const uid = $props.id();
	const descriptionId = `${uid}-description`;
	const metaId = `${uid}-meta`;

	const fmt = $derived(format?.trim() || undefined);
	const sz = $derived(size?.trim() || undefined);
	/** Only the parts that were supplied, so no empty separator is ever drawn or read. */
	const facts = $derived([fmt, sz].filter((part): part is string => Boolean(part)));
	const tabNote = $derived(newTabText.trim() || 'Opens in new tab');
	const spoken = $derived([...facts, ...(newTab ? [tabNote] : [])]);
	const hasMeta = $derived(spoken.length > 0);
	const showDescription = $derived(Boolean(description?.trim()) && variant !== 'button');
	/** Formats longer than four characters (XLSX fits, TAR.GZ does not) step down a size in the tile. */
	/** The trailing glyph is muted at rest and takes the accent while its link is hovered or focused. */
	const glyphTone =
		'text-(--_muted) transition-colors duration-150 ease-(--_ease) group-hover:text-(--_accent) group-focus-visible:text-(--_accent) motion-reduce:transition-none';
	const longFormat = $derived((fmt?.length ?? 0) > 4);
	/* A consumer's aria-label or aria-labelledby replaces the content as the name, so the format,
	   size and new-tab note are carried into whichever one they set. Their own aria-describedby
	   is kept beside the description. */
	const ariaLabel = $derived(
		hasMeta && rest['aria-label']
			? `${rest['aria-label']}, ${spoken.join(', ')}`
			: rest['aria-label']
	);
	const ariaLabelledby = $derived(
		hasMeta && rest['aria-labelledby']
			? `${rest['aria-labelledby']} ${metaId}`
			: rest['aria-labelledby']
	);
	const ariaDescribedby = $derived(
		[showDescription && descriptionId, rest['aria-describedby']].filter(Boolean).join(' ') ||
			undefined
	);
	const relValue = $derived.by(() => {
		const tokens = (rel ?? '').split(/\s+/).filter(Boolean);
		if (newTab) tokens.push('noopener', 'noreferrer');
		return tokens.length ? [...new Set(tokens)].join(' ') : undefined;
	});
</script>

<a
	{...rest}
	{href}
	download={newTab ? undefined : (filename ?? '')}
	target={newTab ? '_blank' : undefined}
	rel={relValue}
	aria-label={ariaLabel}
	aria-labelledby={ariaLabelledby}
	aria-describedby={ariaDescribedby}
	data-variant={variant}
	class={[
		'download-link group relative max-w-full cursor-pointer text-start no-underline select-none',
		'transition-[background-color,box-shadow,scale] duration-150 ease-(--_ease) active:scale-(--_press) active:duration-[80ms] motion-reduce:transition-[background-color,box-shadow] motion-reduce:active:scale-100',
		'outline-(--_accent) focus-visible:outline-2',
		variant === 'row' &&
			'download-link--row flex w-full items-start gap-3 rounded-lg p-3 text-(--_ink) -outline-offset-2 hover:bg-(--_fill-hover) active:bg-(--_fill-active)',
		variant === 'card' &&
			'download-link--card flex h-full w-full flex-col rounded-xl bg-(--_surface) p-5 text-(--_ink) shadow-(--_edge) outline-offset-2 hover:shadow-(--_edge-hover)',
		variant === 'button' &&
			'download-link--button inline-flex min-h-10 items-start gap-2 rounded-lg bg-(--_accent) px-4 py-2 text-(--_on-accent) shadow-(--_raised) outline-offset-2 hover:bg-(--_accent-hover) pointer-coarse:min-h-11',
		className
	]}
>
	{#if variant === 'button'}
		<!-- One line-tall box, so the glyph stays level with the first line of a wrapped label. -->
		<span class="grid h-6 w-4 shrink-0 place-items-center" aria-hidden="true">
			{@render glyph('size-4')}
		</span>
		<span class="flex min-w-0 flex-wrap items-baseline gap-x-2">
			<span class="min-w-0 text-[15px] leading-6 font-medium text-pretty break-words">{title}</span
			>{#if hasMeta}<span
					class="min-w-0 text-[13px] leading-6 font-normal text-pretty break-words text-(--_on-accent) tabular-nums"
					aria-hidden="true">{@render metaLine()}</span
				>{/if}
		</span>
		{@render spokenMeta()}
	{:else if variant === 'card'}
		{@render tile()}
		<span
			class="mt-4 block text-base leading-6 font-semibold tracking-[-0.011em] text-pretty break-words rtl:tracking-normal"
			>{title}</span
		>{@render spokenMeta()}
		{#if showDescription}
			<span
				id={descriptionId}
				class="mt-1 block text-sm leading-5 text-pretty text-(--_muted)"
				aria-hidden="true">{description}</span
			>
		{/if}
		<!-- mt-auto pins the meta line to the foot, so cards in one grid row share it. -->
		<span class="mt-auto flex items-center justify-between gap-3 pt-4">
			<span class="min-w-0 text-[13px] leading-5 text-(--_muted) tabular-nums" aria-hidden="true"
				>{@render metaLine()}</span
			>
			<span class={['grid size-5 shrink-0 place-items-center', glyphTone]} aria-hidden="true">
				{@render glyph('size-5')}
			</span>
		</span>
	{:else}
		{@render tile()}
		<!-- Tile, title and glyph share a top edge, so however far the title wraps, the marks stay
		     level with its first line. With a meta line the text fills the tile's 44 px exactly. -->
		<span class="flex min-w-0 flex-1 flex-col">
			<span
				class="block text-base leading-6 font-semibold tracking-[-0.011em] text-pretty break-words rtl:tracking-normal"
				>{title}</span
			>{@render spokenMeta()}
			{#if hasMeta}
				<span class="block text-[13px] leading-5 text-(--_muted) tabular-nums" aria-hidden="true"
					>{@render metaLine()}</span
				>
			{/if}
			{#if showDescription}
				<span
					id={descriptionId}
					class="mt-1 block text-sm leading-5 text-pretty text-(--_muted)"
					aria-hidden="true">{description}</span
				>
			{/if}
		</span>
		<span class={['grid h-6 w-5 shrink-0 place-items-center', glyphTone]} aria-hidden="true">
			{@render glyph('size-5')}
		</span>
	{/if}
</a>

<!-- The visible meta line is hidden from assistive technology, because a middle dot is read
     aloud as "dot". This copy joins the facts with commas inside the link's name instead:
     "2026 product brochure, PDF, 2.4 MB". -->
{#snippet spokenMeta()}{#if hasMeta}<span id={metaId} class="sr-only"
			>{`, ${spoken.join(', ')}`}</span
		>{/if}{/snippet}

<!-- Each part is isolated, so in right-to-left text "1.3 ميغابايت" stays one unit and the parts
     keep their order instead of reshuffling around the Latin format name. -->
{#snippet metaLine()}{#each spoken as part, i (i)}{#if i > 0}<span class="mx-1">·</span>{/if}<bdi
			>{part}</bdi
		>{/each}{/snippet}

<!-- The tile is decorative: the format is already in the name. Without a format it shows text
     lines instead of a blank page. -->
{#snippet tile()}
	<span class="relative block h-11 w-8 shrink-0 text-(--_tile-edge)" aria-hidden="true">
		<svg class="block size-full" viewBox="0 0 32 44" fill="none">
			<path
				d="M4 .5h16.8L31.5 11.2V40a3.5 3.5 0 0 1-3.5 3.5H4A3.5 3.5 0 0 1 .5 40V4A3.5 3.5 0 0 1 4 .5Z"
				fill="var(--_surface)"
				stroke="currentColor"
			/>
			<path d="M20.5.5V8a3.5 3.5 0 0 0 3.5 3.5h7.5" stroke="currentColor" />
			{#if !fmt}
				<path d="M7 20.5h18M7 25.5h18M7 30.5h11" stroke="currentColor" stroke-linecap="round" />
			{/if}
		</svg>
		{#if fmt}
			<span
				class={[
					'download-link__format absolute inset-x-0 bottom-2 text-center font-mono leading-none font-semibold text-(--_accent) uppercase',
					longFormat ? 'text-[7px]' : 'text-[9px]'
				]}>{fmt}</span
			>
		{/if}
	</span>
{/snippet}

<!-- A tray arrow for a download, an out-of-page arrow for a new tab. The download arrow drops
     2 px on hover; the new-tab arrow stays still, as it points out of the page, not along it. -->
{#snippet glyph(box: string)}
	{#if newTab}
		<svg class={['block', box]} viewBox="0 0 20 20" fill="none">
			<path
				d="M8 4.75H5.75a1 1 0 0 0-1 1v8.5a1 1 0 0 0 1 1h8.5a1 1 0 0 0 1-1V12M11.25 4.75h4v4M15 5l-5.5 5.5"
				stroke="currentColor"
				stroke-width="1.5"
				stroke-linecap="round"
				stroke-linejoin="round"
			/>
		</svg>
	{:else}
		<svg
			class={[
				'block transition-transform duration-150 ease-(--_ease) motion-safe:group-hover:translate-y-0.5 motion-reduce:transition-none',
				box
			]}
			viewBox="0 0 20 20"
			fill="none"
		>
			<path
				d="M10 3.75v9M6.25 9.25 10 13l3.75-3.75M4.25 16.25h11.5"
				stroke="currentColor"
				stroke-width="1.5"
				stroke-linecap="round"
				stroke-linejoin="round"
			/>
		</svg>
	{/if}
{/snippet}

<style>
	/* Public tokens: set --download-link-* on the link or any ancestor to retone it. */
	.download-link {
		--_accent: var(--download-link-accent, #18181b);
		--_on-accent: var(--download-link-on-accent, #ffffff);
		--_ink: var(--download-link-ink, #18181b);
		--_muted: var(--download-link-muted, #52525b);
		--_hairline: var(--download-link-hairline, rgb(0 0 0 / 0.08));
		--_surface: var(--download-link-surface, #ffffff);

		/* Formulas, read by the utilities in the markup. Every tint is mixed from a token, so a
		   retoned accent or a dark page carries through without editing a class. */
		--_ease: cubic-bezier(0.2, 0, 0, 1);
		--_press: 0.99;
		--_fill-hover: color-mix(in oklab, var(--_ink) 4%, transparent);
		--_fill-active: color-mix(in oklab, var(--_ink) 7%, transparent);
		--_tile-edge: color-mix(in oklab, var(--_ink) 24%, transparent);
		--_accent-hover: color-mix(in oklab, var(--_accent) 88%, var(--_on-accent));
		--_edge: 0 0 0 1px var(--_hairline), 0 1px 2px rgb(0 0 0 / 0.04);
		--_edge-hover:
			0 0 0 1px color-mix(in oklab, var(--_ink) 18%, transparent), 0 1px 2px rgb(0 0 0 / 0.06);
		--_raised: inset 0 1px 0 rgb(255 255 255 / 0.12), 0 1px 2px rgb(0 0 0 / 0.08);
	}

	/* A row is a column of a list: scaling it would shift its tile and title off the shared edge,
	   so its press is a deeper fill instead. A button is a smaller object, so it takes the
	   standard 0.98. */
	.download-link--row {
		--_press: 1;
	}

	.download-link--button {
		--_press: 0.98;
	}

	/* Rows inside a <ul> draw a hairline between them, inset to the content edge. The line
	   steps aside next to a hovered or focused row, so the fill never butts against it. */
	:global(li + li) > .download-link--row::before {
		content: '';
		position: absolute;
		inset-inline: 12px;
		top: 0;
		border-top: 1px solid var(--_hairline);
		transition: border-color 150ms var(--_ease);
	}
	:global(li + li) > .download-link--row:focus-visible::before,
	:global(li:has(> .download-link--row:focus-visible) + li) > .download-link--row::before {
		border-top-color: transparent;
	}
	@media (hover: hover) {
		:global(li + li) > .download-link--row:hover::before,
		:global(li:has(> .download-link--row:hover) + li) > .download-link--row::before {
			border-top-color: transparent;
		}
	}
	@media (prefers-reduced-motion: reduce) {
		:global(li + li) > .download-link--row::before {
			transition: none;
		}
	}
</style>

Pass href and a human-readable title, plus the format and size you already know. It renders one native <a download>, so the browser's own download handling, link copying and context menu all work with no script. It does not measure the file, detect its format, check that the URL exists, gate the download behind a form or track it. Wrap several rows in a <ul> with one <li> each for a divided list.

Suggested location
src/lib/components/download-link-01
Required props
hreftitle

Limitations

  • Browsers honour the download attribute (and filename) only for same-origin URLs, blob: and data: URLs. A cross-origin file, such as one on a CDN or object storage, opens or navigates as a normal link unless its server sends Content-Disposition: attachment.
  • format and size are shown as given. Pass a pre-formatted size such as '2.4 MB', not a byte count.
  • The divided list needs each row to be the direct child of an <li>; the <ul> and any panel around it are yours to style.
  • The button treatment ignores description. Use one button per view; use rows for several files.
  • Formats longer than six characters overflow the tile; the meta line still shows them in full.

Example

Svelte
<script lang="ts">
	import DownloadLink from '$lib/components/download-link-01/DownloadLink.svelte';
</script>

<ul>
	<li>
		<DownloadLink
			href="/files/product-brochure-2026.pdf"
			title="2026 product brochure"
			filename="product-brochure-2026.pdf"
			format="PDF"
			size="2.4 MB"
		/>
	</li>
	<li>
		<DownloadLink href="/files/price-list.xlsx" title="Price list" format="XLSX" size="48 KB" />
	</li>
</ul>

<DownloadLink
	href="/files/price-list.pdf"
	title="Download the price list"
	format="PDF"
	size="640 KB"
	variant="button"
/>

The source imports its own files by relative path only. Examples import from $lib/...; SvelteKit 3 projects have no $lib alias by default, so import from your #lib subpath (with the .svelte extension) or a relative path instead.

Use it wherever a page offers a file: a brochure, a whitepaper, a price list, release installers or the forms a customer prints before a visit. The point is that the visitor can judge the file (what it is, what format, how large) before they commit to the download.

Choosing a treatment#

  • row (default): one file per row, in a resources panel or a list of release assets. Wrap several in a <ul>, one <li> each, and hairlines appear between them.
  • card: two or three featured files in a grid, each with a one-line description.
  • button: the single download a section exists to offer. Its label is the title, so word it as an action: "Download the price list".

Same-origin only#

The download attribute, and the filename it suggests, only take effect for same-origin, blob: and data: URLs. A file on a CDN or object storage opens or navigates as an ordinary link unless its server sends:

Plain text
Content-Disposition: attachment; filename="product-brochure-2026.pdf"

Either set that header on the bucket or route the file through your own origin.

Supplying format and size#

Format and size are shown exactly as passed. Read them from your CMS or build step and pass them pre-formatted: format="PDF", size="2.4 MB". Leave either out when you do not know it; the meta line and the accessible name drop it without leaving a stray separator.

Previewable files#

Set newTab for a PDF the visitor will want to read before saving. The link loses its download attribute, opens in a new tab with noopener noreferrer, shows an out-of-page arrow, and says "Opens in new tab" in both the meta line and the accessible name. Translate newTabText with the page.

Props and content inputs#

On this page
NameTypeRequiredDefaultDescription
hrefstringYesNoneFile URL. Same-origin for the download attribute to take effect.
titlestringYesNoneHuman-readable name, such as '2026 product brochure'. Wraps; never truncated.
filenamestringNoNoneSuggested saved filename, set as the download attribute's value. Omitted, the attribute is present and empty, so the browser names the file.
formatstringNoNoneDisplay format, such as 'PDF'. Shown in the tile and the meta line, and read in the link's name.
sizestringNoNonePre-formatted size, such as '2.4 MB'. Shown in the meta line and read in the link's name.
descriptionstringNoNoneOne line under the title in the row and card treatments, attached to the link with aria-describedby. The button ignores it.
variant'row' | 'button' | 'card'No'row'row for lists, card for a featured file in a grid, button for one primary download.
newTabbooleanNofalseOpen the file in a new tab instead of downloading it, for previewable PDFs. Drops the download attribute, adds target="_blank" with noopener and noreferrer, and shows and reads newTabText.
newTabTextstringNo'Opens in new tab'The words that announce a new tab. Translate it with the page. A blank value falls back to the default.
relstringNoNonerel tokens, kept alongside the noopener and noreferrer a new tab adds.
classstringNoNoneExtra classes for placement.

Customization#

On this page

Pick a treatment per context and retone through six --download-link-* CSS variables. Hover fills, the tile outline and the button's hover are mixed from them, so they follow any palette.

  • Treatment: row in lists and resource panels, card for two or three featured files in a grid, button for the single download a section exists to offer.
  • Accent: --download-link-accent colours the format in the tile, the glyph on hover, the focus ring and the button fill. Keep it at 4.5:1 on the page; --download-link-on-accent is the button's label.
  • Neutrals: --download-link-ink is the title, --download-link-muted the meta line, the description and the glyph at rest, --download-link-hairline the list dividers and card edge, --download-link-surface the card and tile fill.
  • Worked retone for a #09090b band: --download-link-accent: #fafafa; --download-link-on-accent: #09090b; --download-link-ink: #fafafa; --download-link-muted: #a1a1aa; --download-link-hairline: rgb(255 255 255 / 0.1); --download-link-surface: #18181b.
  • A divided list: <ul> with one <li> per row. Put the list in a panel with 4 px of padding and a 12 px radius so the rows' 8 px hover fill nests concentrically.
  • Cards in a grid: give each <li> display: flex (or the grid stretch) so cards in one row share a height and their meta lines align at the foot.
  • Cross-origin files: serve them with Content-Disposition: attachment; filename="…", or proxy them through your own origin, if they must download rather than open.

Public CSS variables

VariableToken
--download-link-accentaccent
--download-link-on-accentonAccent
--download-link-inkink
--download-link-mutedmuted
--download-link-hairlinehairline
--download-link-surfacesurface

Accessibility#

On this page
  • A native <a href download>: Enter follows it, it sits in the tab order, and the browser's download handling and context menu work without script.
  • The accessible name is the title followed by the format and size, comma-separated, such as '2026 product brochure, PDF, 2.4 MB'. Parts not supplied are left out, so no empty separator is read.
  • The visible meta line (joined with middle dots) and the format in the tile are aria-hidden, so the format is not announced twice.
  • If you set aria-label or aria-labelledby, the format, size and new-tab note are appended to it, so an override never drops them. Your own aria-describedby is kept beside the description.
  • description is attached with aria-describedby rather than added to the name, so the name stays short in a list of links.
  • newTab adds newTabText to both the meta line and the name, so a new tab is announced in words, not only by the glyph.
  • Focus shows a two-pixel accent ring with a two-pixel offset that follows each treatment's radius. Rows are at least 68 px tall and the button grows to 44 px on touch screens.
  • Default colours: ink #18181b and muted #52525b clear 7:1 on white. The button's label and meta line are on-accent at full strength, so they clear 4.5:1 on the neutral and blue accents at rest and on hover. Keep a retoned accent at 4.5:1 on the page and 3:1 for the focus ring.
  • Write titles that make sense out of context ('2026 product brochure', not 'Download'), because a screen reader's links list reads them alone.

Known limitations

  • The download attribute is ignored for cross-origin URLs, which then open or navigate as ordinary links; the component cannot detect this.

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 · 22 October 2026

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