Forms
Upload
Sends files and shows each one's progress, with cancel and retry.
Installation
npx sinaui add upload-list upload-trayCopy src/lib/ui/uploads.svelte.ts, UploadList.svelte, UploadTray.svelte, RollingNumber.svelte, announce.ts, Icon.svelte and tokens.css; FileDrop.svelte too for dragging
files in. No other dependencies.
createUploads(send) keeps the queue. For send, use xhrSend('/your/upload/url'), or your own function that reports progress and stops
when its signal aborts. Throw an UploadError to show your own message; anything else
shows a general one.
Examples
Inline
Each file uploads as soon as it's added. A failed upload can be tried again; one in progress can be canceled.
Drag files here, or
Name a file “damaged” to see a failed upload.
<script lang="ts">
import FileDrop from '#lib/ui/FileDrop.svelte';
import UploadList from '#lib/ui/UploadList.svelte';
import { createUploads } from '#lib/ui/uploads.svelte.js';
import { pretendSend } from './pretend-send';
// In an app: createUploads(xhrSend('/api/scans'))
const uploads = createUploads(pretendSend);
</script>
<div class="stack">
<FileDrop
label="Manuscript scans"
multiple
hint="Name a file “damaged” to see a failed upload."
onfiles={(files) => uploads.add(files)}
/>
<UploadList {uploads} />
</div>
<style>
.stack {
display: grid;
gap: 0.75rem;
inline-size: 100%;
max-inline-size: 28rem;
}
</style>Floating tray
Stays in the bottom corner while people carry on with the page. Hide it to its heading, and close it once nothing is still uploading.
<script lang="ts">
import Button from '#lib/ui/Button.svelte';
import UploadTray from '#lib/ui/UploadTray.svelte';
import { createUploads } from '#lib/ui/uploads.svelte.js';
import { pretendSend } from './pretend-send';
const uploads = createUploads(pretendSend);
let input: HTMLInputElement;
// Sample files, so the tray can be tried without choosing any.
const sample = (name: string, kb: number) =>
new File([new Uint8Array(kb * 1000)], name, { type: 'image/png' });
</script>
<div class="row">
<Button onclick={() => input.click()}>Upload scans</Button>
<Button
variant="secondary"
onclick={() =>
uploads.add([
sample('folio-1r.png', 2400),
sample('folio-1v.png', 1800),
sample('folio-2r-damaged.png', 3100),
sample('binding.png', 900)
])}
>
Try with sample files
</Button>
<input
bind:this={input}
type="file"
multiple
hidden
onchange={(e) => e.currentTarget.files && uploads.add(e.currentTarget.files)}
/>
</div>
<!-- Fixed to the bottom-right of the window, over the page. -->
<UploadTray {uploads} />
<style>
.row {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.5rem;
}
</style>Profile photo
One image, shown straight away while it uploads behind. Change or remove it after.
Ibn Sina
JPEG, PNG or WebP, up to 5 MB.
<script lang="ts">
import { Camera01Icon } from '@hugeicons/core-free-icons';
import { announce } from '#lib/ui/announce.js';
import Button from '#lib/ui/Button.svelte';
import Icon from '#lib/ui/Icon.svelte';
import Progress from '#lib/ui/Progress.svelte';
import { pretendSend } from './pretend-send';
const MAX = 5_000_000;
let src = $state<string>();
let progress = $state<number>();
let error = $state('');
let input: HTMLInputElement;
async function choose(file: File | undefined) {
if (!file) return;
error = '';
if (!file.type.startsWith('image/')) return (error = 'Choose a photo (JPEG, PNG or WebP).');
if (file.size > MAX) return (error = 'That photo is over 5 MB; choose a smaller one.');
// Shown at once from the file itself, while it uploads behind.
if (src) URL.revokeObjectURL(src);
src = URL.createObjectURL(file);
progress = 0;
try {
await pretendSend(file, {
onprogress: (p) => (progress = p),
signal: new AbortController().signal
});
announce('Photo saved');
} catch {
error = "The photo didn't upload. Try again.";
} finally {
progress = undefined;
}
}
</script>
<div class="avatar-upload">
<button
type="button"
class="photo"
aria-label={src ? 'Change photo' : 'Add a photo'}
onclick={() => input.click()}
>
{#if src}<img {src} alt="" />{:else}<span class="initials">IS</span>{/if}
<span class="overlay" aria-hidden="true"><Icon icon={Camera01Icon} size={22} /></span>
</button>
<div class="side">
<p class="name">Ibn Sina</p>
{#if progress !== undefined}
<Progress label="Uploading photo" value={progress} />
{:else}
<div class="actions">
<Button variant="secondary" size="sm" onclick={() => input.click()}>
{src ? 'Change photo' : 'Add a photo'}
</Button>
{#if src}
<Button
variant="ghost"
size="sm"
onclick={() => {
URL.revokeObjectURL(src!);
src = undefined;
announce('Photo removed');
}}>Remove</Button
>
{/if}
</div>
{/if}
{#if error}<p class="error" role="alert">{error}</p>{:else}<p class="hint">
JPEG, PNG or WebP, up to 5 MB.
</p>{/if}
</div>
<input
bind:this={input}
type="file"
accept="image/*"
hidden
onchange={(e) => {
choose(e.currentTarget.files?.[0]);
e.currentTarget.value = '';
}}
/>
</div>
<style>
.avatar-upload {
display: flex;
align-items: center;
gap: 1.25rem;
}
.photo {
position: relative;
display: grid;
place-items: center;
flex: none;
inline-size: 5.5rem;
block-size: 5.5rem;
padding: 0;
overflow: hidden;
border: 0;
border-radius: 50%;
background: color-mix(in srgb, var(--ui-accent) 16%, var(--ui-surface));
color: var(--ui-accent);
cursor: pointer;
}
.photo img {
inline-size: 100%;
block-size: 100%;
object-fit: cover;
animation: in 300ms var(--ui-ease-out);
}
@keyframes in {
from {
opacity: 0;
transform: scale(1.06);
}
}
.initials {
font: 600 1.5rem/1 var(--ui-font);
}
/* Pointing at the photo shows it can be changed. */
.overlay {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: rgb(0 0 0 / 0.45);
color: #fff;
opacity: 0;
transition: opacity var(--ui-dur) ease;
}
.photo:hover .overlay,
.photo:focus-visible .overlay {
opacity: 1;
}
.photo:focus-visible {
outline: var(--ui-ring-width) solid var(--ui-ring);
outline-offset: var(--ui-ring-offset);
}
.side {
display: grid;
gap: 0.5rem;
min-inline-size: 12rem;
}
.name {
margin: 0;
font-weight: 600;
}
.actions {
display: flex;
gap: 0.25rem;
}
.hint,
.error {
margin: 0;
color: var(--ui-muted);
font-size: 0.8125rem;
}
.error {
color: var(--ui-danger);
}
</style>Gallery
Drop images on the gallery or add them; each tile fills a ring as it uploads. Drag tiles to put them in order.
Press Space to pick up, arrow keys to move, Space again to drop, Escape to cancel.
<script lang="ts">
import { Cancel01Icon, ImageAdd01Icon } from '@hugeicons/core-free-icons';
import Icon from '#lib/ui/Icon.svelte';
import Sortable from '#lib/ui/Sortable.svelte';
import { createUploads } from '#lib/ui/uploads.svelte.js';
import { pretendSend } from './pretend-send';
const uploads = createUploads(pretendSend);
// Previews by upload id, made once per file.
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- a URL cache, never read reactively
const previews = new Map<number, string>();
const preview = (id: number, file: File) =>
previews.get(id) ?? previews.set(id, URL.createObjectURL(file)).get(id)!;
let input: HTMLInputElement;
let dragging = $state(false);
const add = (files: FileList | null | undefined) =>
files && uploads.add([...files].filter((f) => f.type.startsWith('image/')));
</script>
<!-- Drop images anywhere on the gallery; drag tiles to put them in order. -->
<div
class={['gallery', dragging && 'over']}
role="group"
aria-label="Gallery"
ondragover={(e) => {
if (!e.dataTransfer?.types.includes('Files')) return;
e.preventDefault();
dragging = true;
}}
ondragleave={(e) => !e.currentTarget.contains(e.relatedTarget as Node) && (dragging = false)}
ondrop={(e) => {
e.preventDefault();
dragging = false;
add(e.dataTransfer?.files);
}}
>
<Sortable
bind:items={() => uploads.items, (next) => uploads.items.splice(0, Infinity, ...next)}
key={(u) => u.id}
itemLabel={(u) => u.file.name}
label="Photos, in order"
layout="grid"
class="tiles"
>
{#snippet children(u, handle)}
<div class="tile">
<button type="button" class="image" data-sortable-item {...handle}>
<img src={preview(u.id, u.file)} alt="" />
</button>
{#if u.status === 'uploading'}
<!-- A ring fills as it uploads; transform-only, so it stays smooth. -->
<svg class="dial" viewBox="0 0 36 36" aria-hidden="true">
<circle
cx="18"
cy="18"
r="15"
pathLength="1"
style:stroke-dashoffset={1 - u.progress}
/>
</svg>
{:else if u.status === 'failed'}
<button type="button" class="failed" onclick={() => uploads.retry(u.id)}>Retry</button>
{/if}
<button
type="button"
class="remove"
aria-label="Remove {u.file.name}"
onclick={() => {
const url = previews.get(u.id);
uploads.remove(u.id);
// Freed after the tile has faded out, so it doesn't blank mid-exit.
setTimeout(() => url && URL.revokeObjectURL(url), 400);
}}><Icon icon={Cancel01Icon} size={12} strokeWidth={2.5} /></button
>
</div>
{/snippet}
</Sortable>
<button type="button" class="add" onclick={() => input.click()}>
<Icon icon={ImageAdd01Icon} size={22} />
<span>Add photos</span>
</button>
<input
bind:this={input}
type="file"
accept="image/*"
multiple
hidden
onchange={(e) => {
add(e.currentTarget.files);
e.currentTarget.value = '';
}}
/>
</div>
<style>
.gallery {
display: grid;
gap: 0.5rem;
inline-size: min(28rem, 100%);
padding: 0.5rem;
border-radius: 1.25rem;
transition: background var(--ui-dur) ease;
}
.over {
background: color-mix(in srgb, var(--ui-accent) 10%, transparent);
}
.gallery :global(.tiles) {
--sortable-columns: 3;
}
.tile {
position: relative;
aspect-ratio: 1;
}
.image {
display: block;
inline-size: 100%;
block-size: 100%;
padding: 0;
overflow: hidden;
border: 0;
border-radius: 0.75rem;
background: var(--ui-subtle);
}
.image img {
inline-size: 100%;
block-size: 100%;
object-fit: cover;
pointer-events: none;
}
.image:focus-visible,
.remove:focus-visible,
.add:focus-visible {
outline: var(--ui-ring-width) solid var(--ui-ring);
outline-offset: var(--ui-ring-offset);
}
.dial {
position: absolute;
inset: 50% auto auto 50%;
inline-size: 2.5rem;
translate: -50% -50%;
rotate: -90deg;
pointer-events: none;
}
.dial circle {
fill: rgb(0 0 0 / 0.35);
stroke: #fff;
stroke-width: 3;
stroke-dasharray: 1;
transition: stroke-dashoffset 200ms linear;
}
.failed {
position: absolute;
inset: auto 0.375rem 0.375rem;
padding: 0.25rem;
border: 0;
border-radius: 0.5rem;
background: var(--ui-danger);
color: var(--ui-on-danger);
font: 600 0.75rem/1.2 var(--ui-font);
cursor: pointer;
}
.remove {
position: absolute;
inset-block-start: -0.375rem;
inset-inline-end: -0.375rem;
display: grid;
place-items: center;
inline-size: 1.375rem;
block-size: 1.375rem;
padding: 0;
border: 0;
border-radius: 50%;
background: var(--ui-fg);
color: var(--ui-surface);
cursor: pointer;
}
.add {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
block-size: 3rem;
border: 0;
border-radius: 0.75rem;
background: var(--ui-subtle);
color: var(--ui-muted);
font: 500 0.875rem/1 var(--ui-font);
cursor: pointer;
transition: background var(--ui-dur) ease;
}
.add:hover {
background: var(--ui-hover);
color: var(--ui-fg);
}
</style>Attachments on a message
A compact button and a chip per file, with progress along its edge. Send waits until everything is up. Name a file “damaged” to see it fail.
<script lang="ts">
import { flip } from 'svelte/animate';
import { scale } from 'svelte/transition';
import { Attachment01Icon, Cancel01Icon } from '@hugeicons/core-free-icons';
import Button from '#lib/ui/Button.svelte';
import Icon from '#lib/ui/Icon.svelte';
import Textarea from '#lib/ui/Textarea.svelte';
import { createUploads } from '#lib/ui/uploads.svelte.js';
import { pretendSend } from './pretend-send';
const uploads = createUploads(pretendSend);
const size = new Intl.NumberFormat('en', {
style: 'unit',
unit: 'kilobyte',
notation: 'compact',
maximumFractionDigits: 1
});
let input: HTMLInputElement;
</script>
<!-- Attachments on a message: a button, and a chip per file with its progress along the bottom. -->
<div class="compose">
<Textarea
label="Message to the librarian"
rows={3}
placeholder="Here are the scans I mentioned…"
/>
<ul class="chips" aria-label="Attachments">
{#each uploads.items as u (u.id)}
<li
class={['chip', u.status]}
animate:flip={{
duration: matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 200
}}
out:scale={{
start: 0.9,
duration: matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 160
}}
>
<span class="file">{u.file.name}</span>
<span class="meta">
{u.status === 'failed' ? 'Failed' : size.format(u.file.size / 1000)}
</span>
{#if u.status === 'failed'}
<button type="button" class="retry" onclick={() => uploads.retry(u.id)}>Retry</button>
{/if}
<button
type="button"
class="x"
aria-label="Remove {u.file.name}"
onclick={() => uploads.remove(u.id)}
><Icon icon={Cancel01Icon} size={12} strokeWidth={2.5} /></button
>
{#if u.status === 'uploading'}
<span class="bar" style:transform="scaleX({u.progress})" aria-hidden="true"></span>
{/if}
</li>
{/each}
</ul>
<div class="row">
<Button variant="ghost" size="sm" onclick={() => input.click()}>
<Icon icon={Attachment01Icon} size={16} /> Attach files
</Button>
<Button size="sm" disabled={uploads.active > 0}>{uploads.active ? 'Uploading…' : 'Send'}</Button
>
</div>
<input
bind:this={input}
type="file"
multiple
hidden
onchange={(e) => {
if (e.currentTarget.files) uploads.add(e.currentTarget.files);
e.currentTarget.value = '';
}}
/>
</div>
<style>
.compose {
display: grid;
/* minmax(0): a long file name ellipsizes in its chip instead of widening the form. */
grid-template-columns: minmax(0, 1fr);
gap: 0.75rem;
inline-size: min(28rem, 100%);
}
/* Stays in the page while the last chip leaves, then takes no room. */
.chips:empty {
display: none;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0;
padding: 0;
list-style: none;
}
.chip {
position: relative;
display: flex;
align-items: center;
gap: 0.5rem;
max-inline-size: 100%;
padding-block: 0.375rem;
padding-inline: 0.75rem 0.375rem;
overflow: hidden;
border-radius: var(--ui-radius);
background: var(--ui-subtle);
font-size: 0.8125rem;
animation: in 200ms var(--ui-ease-out);
}
@keyframes in {
from {
opacity: 0;
transform: scale(0.96);
}
}
.chip.failed {
background: color-mix(in srgb, var(--ui-danger) 10%, transparent);
}
.file {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta {
flex: none;
color: var(--ui-muted);
font-variant-numeric: tabular-nums;
}
.failed .meta {
color: var(--ui-danger);
}
.retry {
padding: 0;
border: 0;
background: none;
color: var(--ui-fg);
font: 600 0.8125rem/1 var(--ui-font);
text-decoration: underline;
cursor: pointer;
}
.x {
display: grid;
flex: none;
place-items: center;
inline-size: 1.25rem;
block-size: 1.25rem;
padding: 0;
border: 0;
border-radius: 50%;
background: none;
color: var(--ui-muted);
cursor: pointer;
}
.x:hover {
background: var(--ui-hover);
color: var(--ui-fg);
}
:is(.x, .retry):focus-visible {
outline: var(--ui-ring-width) solid var(--ui-ring);
outline-offset: 1px;
}
/* Progress as a hairline along the chip's bottom edge. */
.bar {
position: absolute;
inset: auto 0 0;
block-size: 2px;
background: var(--ui-accent);
transform-origin: left;
transition: transform 200ms linear;
}
.row {
display: flex;
justify-content: space-between;
}
@media (prefers-reduced-motion: reduce) {
.chip {
animation: none;
}
}
</style>API
| createUploads(send) returns | What it does |
|---|---|
items | Each upload: file, progress (0–1), status, error |
add(files) | Queues files; each starts at once |
cancel(id), retry(id), remove(id) | Per upload |
clear() | Removes every upload that isn't running |
active, progress | How many are running, and overall progress by size |
Accessibility
- Each file has a progress bar named after it, and cancel, retry and remove buttons named after it too.
- The tray is a labeled region; screen readers hear when all uploads are done or some failed, not every percent.
- Hiding the tray keeps its heading and overall progress in view.