Triggering file downloads in the browser
How to trigger downloads with Blob and object URLs and the download attribute, name files clearly, revoke URLs and generate SVG files on the fly.
5 min read
A download button looks simple: a link to a file. It gets trickier when the file doesn't exist on the server, such as an SVG recolored and resized to the user's choices, or when the file lives on another domain. This guide covers how browsers decide between opening a file and saving it, how to create a file in JavaScript and hand it over, and the small details (names, cleanup, mobile) that make downloads dependable.
The download attribute
The simplest download is a link with the download attribute:
<a href="/icons/token/ETH.svg" download="eth.svg">Download SVG</a>
Without the attribute, clicking a link to an SVG opens it in the tab, because browsers can display SVG. With it, the browser saves the file instead and suggests the attribute's value as the file name.
The important limit: browsers honor download only for same-origin URLs and for blob: and data: URLs. For a file on another origin, such as a CDN on a different domain or a logo hosted by a project, the attribute is ignored and the link navigates as usual. That leaves two options: have the server send a Content-Disposition header, or fetch the file and save it as a Blob, which requires the other origin to allow the fetch with CORS.
On the server: Content-Disposition
When you control the server, this response header turns any response into a download, regardless of origin or link markup:
Content-Disposition: attachment; filename="ethereum-mono-64.svg"
For names with non-ASCII characters, add the encoded filename* form next to a plain fallback:
Content-Disposition: attachment; filename="bitcoin-sign.svg"; filename*=UTF-8''%E2%82%BF-sign.svg
For files that already exist, such as a static icon library, this is the most dependable approach, because no script is involved.
In the browser: Blob and object URLs
When the file is generated in the browser, wrap it in a Blob, create a temporary URL for it, and click a link:
export function saveBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.rel = 'noopener'
document.body.append(a)
a.click()
a.remove()
// Give the browser time to start the download before releasing the data.
setTimeout(() => URL.revokeObjectURL(url), 1000)
}
A few details are doing real work here:
URL.createObjectURLreturns ablob:URL pointing at data in memory. The URL, and the Blob behind it, stay alive until you revoke the URL or the document unloads. Creating one per click without revoking it leaks memory in a long-lived page.- Revoke after a delay. The download starts asynchronously, and revoking the URL in the same tick as
click()can break it in some browsers. A short delay is a common, safe choice. - Appending the link to the document isn't needed in current browsers, but some older versions required it, and it costs nothing.
Avoid data: URLs for anything but small files. They need encoding (encodeURIComponent for SVG text, base64 for binary data), they're larger than the file itself, and very long URLs run into practical limits. Blobs have none of those problems.
Generating an SVG on the fly
A typical icon download takes a source SVG and adjusts it: set the export size, recolor a single-color icon, perhaps remove something. Parse it as XML instead of editing strings, then serialize it back:
export async function buildSvg(src: string, size: number, color?: string) {
const text = await fetch(src).then((res) => res.text())
const doc = new DOMParser().parseFromString(text, 'image/svg+xml')
const svg = doc.documentElement
if (svg.nodeName !== 'svg' || doc.querySelector('parsererror')) {
throw new Error('Not a valid SVG file')
}
svg.setAttribute('width', String(size))
svg.setAttribute('height', String(size))
if (color) {
svg.setAttribute('fill', color)
for (const el of svg.querySelectorAll('[fill]:not([fill="none"])')) {
el.setAttribute('fill', color)
}
}
return new XMLSerializer().serializeToString(doc)
}
This recoloring assumes a single-color icon whose shapes use the fill attribute. Icons that use strokes, style attributes or several colors need more care.
Using XMLSerializer matters for another reason. A standalone SVG file must declare xmlns="http://www.w3.org/2000/svg" on its root element, or browsers and design tools won't treat it as SVG when it's opened as a file. Inline SVG in an HTML page doesn't need the attribute, so if you build a file from an element on the page with outerHTML, the declaration can be missing. XMLSerializer writes the namespace for you.
Then save it:
async function onDownload() {
const svg = await buildSvg('/icons/token/mono/ETH.svg', 64, '#ffffff')
saveBlob(new Blob([svg], { type: 'image/svg+xml' }), 'eth-mono-64.svg')
}
Give the Blob the image/svg+xml type and the file an .svg extension; some systems decide how to open a file from its extension alone. If someone needs a PNG instead, the same SVG can be drawn onto a canvas and exported with canvas.toBlob(). The guide on converting SVG to PNG covers sizes and pitfalls.
File names
The download value is only a suggestion. Browsers sanitize it, replacing characters that aren't allowed in file names, and the browser or operating system may add a number such as "(1)" when a file with that name already exists. Make names predictable yourself:
export function fileName(...parts: (string | number)[]) {
return parts
.join('-')
.toLowerCase()
.replace(/[^a-z0-9.-]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
}
fileName('USD Coin', 'mono', 64) + '.svg' // 'usd-coin-mono-64.svg'
Include whatever distinguishes one version from another: the icon, the style and the size. Someone who downloads three versions of a logo should be able to tell them apart in their Downloads folder without opening them.
Mobile and in-app browsers
Downloads work in current mobile browsers, but the experience differs from desktop. iOS Safari typically asks before downloading and saves to the Files app, and Android browsers vary in how they show progress. In-app browsers, such as the dApp browsers built into mobile wallets or links opened inside social apps, may not handle downloads at all. If your users arrive that way, test there and offer an alternative, such as a button that copies the SVG code. Copying has its own rules, covered in copy to clipboard pitfalls.
The File System Access API's showSaveFilePicker() lets a page ask the user where to save, but it's only available in Chromium-based browsers. Treat it as an enhancement, not the main path.
Give feedback
In some browsers a download has no visible effect on the page, so confirm it: switch the button label to "Downloaded" for a moment, the way a copy button says "Copied". If the file is generated after a fetch, show a pending state while it loads, so a slow connection doesn't look like a broken button. And if one click starts several downloads, some browsers ask the user for permission to download multiple files; a zip file, or one file per click, avoids the prompt.