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.
<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>