Skip to content

Progress Ring File Download

A data-state attribute on the card drives every visual state from CSS. Setting value to null spins the ring while connecting, and the data-percentage child fills in once the transfer begins. The file icon and the ring share a fixed grid cell, so swapping them causes no layout shift.

photos.zip

24 MB

Code

The following example assumes that you have imported the component and set up the theme.

File Download
<div class="download-card" data-state="idle">
19 collapsed lines
<div class="file-slot">
<svg class="file-icon" aria-hidden="true" viewBox="0 0 24 24">
<path d="M5 20h14v-2H5v2zM19 9h-4V3H9v6H5l7 7 7-7z" />
</svg>
<sv-progress-ring aria-labelledby="download-name">
<span data-percentage="0"></span>
<svg class="done-icon" aria-hidden="true" viewBox="0 0 24 24">
<path d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
</sv-progress-ring>
</div>
<div class="download-heading">
<h3 id="download-name">photos.zip</h3>
<p class="download-subtitle">24 MB</p>
</div>
<button type="button" class="download-button">Download</button>
</div>
<script type="application/x-typescript" module>
46 collapsed lines
const card = document.querySelector<HTMLElement>(".download-card")!;
const ring = card.querySelector<ProgressRing>("sv-progress-ring")!;
const subtitle = card.querySelector(".download-subtitle")!;
const button = card.querySelector(".download-button")!;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
button.addEventListener("click", () => {
const active = card.dataset.state === "connecting" || card.dataset.state === "downloading";
active ? cancel() : start();
});
function start() {
card.dataset.state = "connecting";
ring.value = null;
subtitle.textContent = "Connecting";
button.textContent = "Cancel";
timeoutId = setTimeout(download, 1500);
}
function cancel() {
clearTimeout(timeoutId);
card.dataset.state = "cancelled";
subtitle.textContent = "Cancelled";
button.textContent = "Download";
}
function download() {
if (card.dataset.state === "connecting") {
card.dataset.state = "downloading";
ring.value = 0;
subtitle.textContent = "Downloading";
}
const current = ring.value ?? 0;
if (current >= ring.max) {
card.dataset.state = "complete";
subtitle.textContent = "Download complete";
button.textContent = "Download";
return;
}
ring.value = current + 1;
timeoutId = setTimeout(download, Math.random() < 0.1 ? 600 : 60);
}
</script>
<style>
104 collapsed lines
.download-card {
--done-color: #22c55e;
display: grid;
grid-template-columns: auto 1fr auto;
gap: 1rem;
align-items: center;
max-inline-size: 24rem;
padding: 1rem 1.25rem;
margin: auto;
background-color: var(--sv-gray-7);
border: 1px solid var(--sv-gray-5);
border-radius: var(--sv-bdr-rad-lg);
}
.file-slot {
display: grid;
place-items: center;
inline-size: 3rem;
block-size: 3rem;
> * {
grid-area: 1 / 1;
transition: 200ms ease;
transition-property: opacity, visibility;
}
.file-icon {
inline-size: 1.75rem;
fill: var(--sv-gray-3);
}
sv-progress-ring {
--sv-progress-ring-track-thickness: 2px;
--sv-progress-ring-fill-thickness: 4px;
inline-size: 100%;
span {
font-family: monospace;
font-size: 0.7rem;
}
.done-icon {
display: none;
inline-size: 1.1rem;
fill: var(--done-color);
}
}
}
.download-card:is([data-state="idle"], [data-state="cancelled"]) sv-progress-ring {
visibility: hidden;
opacity: 0;
}
.download-card:not([data-state="idle"], [data-state="cancelled"]) .file-icon {
visibility: hidden;
opacity: 0;
}
.download-card[data-state="complete"] sv-progress-ring {
--sv-progress-ring-fill-clr: var(--done-color);
span {
display: none;
}
.done-icon {
display: block;
}
}
.download-heading {
display: grid;
gap: 0.2rem;
h3 {
margin: 0;
font-size: 1rem;
}
p {
margin: 0;
font-size: 0.8rem;
color: var(--sv-gray-3);
}
}
.download-button {
min-inline-size: 6.5rem;
padding: 0.5em 1em;
font: inherit;
color: var(--sv-accent-content);
cursor: pointer;
background-color: var(--sv-accent);
border: none;
border-radius: var(--sv-bdr-rad-md);
&:focus-visible {
outline: 2px solid var(--sv-accent);
outline-offset: 2px;
}
}
</style>