Each file from waiting to saved, needs review or failed, in a panel that stays across pages.

The six statuses

UploadList draws each file your app is uploading as a row: an icon, the name, the status in words and, while it moves, a bar. A file is waiting, uploading, being read, saved, needing review or failed. The word carries the status, so its color never carries it alone: slate while the file is on its way, success once it is saved, warning when it needs review and error when it failed, each with the reason in your words after it. A name keeps its extension in view, so a long receipt still says it is a JPG, and once a file has become a document its name links there. Retry, Remove and Cancel show where your app handles them; press them below.

Example

Follows the page theme and corners
'use client';

import { Card, UploadList, type UploadItem } from '@snapshot-ui/react';
import { useState, type CSSProperties, type ReactElement } from 'react';
import { UPLOADS } from './uploads-data';

/**
 * The rows run to the card's sides, so the card names its padding beside them, none, for what reads it. The
 * buttons in its corners, the first and last rows' trailing buttons, keep their own round: circles in Rounded,
 * and clear of the card's smaller curve in Soft.
 */
const HOLDER = {
  width: '100%',
  maxWidth: '28rem',
  paddingInline: 0,
  paddingBlock: 'var(--spacing-part)',
  '--holder-pad': '0px',
} as CSSProperties;

export default function UploadListStatuses(): ReactElement {
  const [items, setItems] = useState<UploadItem[]>(UPLOADS);
  const drop = (id: UploadItem['id']): void => {
    setItems((each) => each.filter((item) => item.id !== id));
  };
  return (
    <Card style={HOLDER}>
      <UploadList
        aria-label="Uploads"
        items={items}
        onRemove={drop}
        onCancel={drop}
        onRetry={(id) => {
          // Retried at once, as a fresh upload: the app would send the file again.
          setItems((each) =>
            each.map((item) =>
              item.id === id
                ? { id, name: item.name, status: 'uploading', progress: 0, progressLabel: '0 of 12 MB' }
                : item,
            ),
          );
        }}
      />
    </Card>
  );
}

The tray

UploadTray holds the list where the reader can always find it. From 768px it is a panel in the bottom end corner, 28rem wide, the same column the toasts use, with a title that counts the running batch ("Uploading 2 of 5"), then names what settled, failures included ("3 uploads complete, 1 failed, 1 needs review"), an error icon before it while any file has failed, a line of your own under it, a button that folds it to its header and a close button. Under 768px it is a pill bar above the tab bar that says the title, with a Clear at its end once everything has settled, and pressing it opens a sheet with the list and Clear or Cancel uploads at its foot; while the bar shows, the page keeps room for it under its content, so it never covers the page's last control. It is not a dialog: the reader goes on working while files upload. The toasts rise above it, so the corner holds every report on background work in one column.

With no items the tray draws only its status host, which is in the page from the first render, so a screen reader is listening before anything is said. Mount it once and give it items as your app adds files.

The Documents recipe

A documents flow with the Next App Router: a Documents page that takes files, a review page for each document, and the uploads in flight, which outlive both. The uploads are the one piece of state that belongs to no page, so they live in a provider in the root layout, beside the shell, and the tray is mounted once, in AppShell's floating slot:

// app/layout.tsx
import { AppShell } from '@snapshot-ui/react';
import '@snapshot-ui/core/styles.css';
import { Nav } from './nav';
import { Tray, UploadsProvider } from './uploads';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <UploadsProvider>
          <AppShell sidebar={<Nav />} floating={<Tray />}>
            {children}
          </AppShell>
        </UploadsProvider>
      </body>
    </html>
  );
}

The provider holds the items and does the uploading: one XMLHttpRequest a file, since fetch reports no upload progress, whose upload.onprogress moves the row's bar and whose answer says whether the server saved the file, needs it reviewed or refused it. The words are yours, as is the size, which your own sizeLabel writes:

// app/uploads.tsx
'use client';

import { UploadTray, type UploadItem } from '@snapshot-ui/react';
import { createContext, use, useRef, useState } from 'react';

const Uploads = createContext<UploadsValue | null>(null);

export function UploadsProvider({ children }: { children: React.ReactNode }) {
  const [items, setItems] = useState<UploadItem[]>([]);
  const requests = useRef(new Map<string, XMLHttpRequest>());
  const put = (id: string, patch: Partial<UploadItem>) =>
    setItems((current) => current.map((item) => (item.id === id ? { ...item, ...patch } : item)));

  const send = (id: string, file: File) => {
    const request = new XMLHttpRequest();
    requests.current.set(id, request);
    request.upload.onprogress = (event) => {
      if (!event.lengthComputable) return;
      put(id, { status: 'uploading', progress: event.loaded / event.total, progressLabel: sizeLabel(event.loaded, event.total) });
    };
    // Every byte is sent: the server is reading the file now, and the row's bar slides.
    request.upload.onload = () => put(id, { status: 'reading', progress: undefined });
    request.onload = () => {
      requests.current.delete(id);
      const answer = JSON.parse(request.responseText) as { status: 'saved' | 'review' | 'failed'; reason?: string; documentId?: string };
      put(id, { status: answer.status, reason: answer.reason, href: answer.documentId && `/documents/${answer.documentId}` });
    };
    request.onerror = () => put(id, { status: 'failed', reason: 'The connection dropped' });
    request.open('POST', '/api/documents');
    request.send(file);
  };

  const add = (files: File[]) => {
    const added = files.map((file) => ({ id: crypto.randomUUID(), name: file.name, status: 'waiting' as const, file }));
    setItems((current) => [...added.map(({ file, ...item }) => item), ...current]);
    for (const { id, file } of added) send(id, file);
  };
  const cancel = (id: UploadItem['id']) => {
    requests.current.get(String(id))?.abort();
    setItems((current) => current.filter((item) => item.id !== id));
  };
  // retry, remove, clear and cancelAll follow the same pattern.
  return <Uploads value={{ items, add, cancel /* ... */ }}>{children}</Uploads>;
}

// The tray in the shell's floating slot, and the pages' Upload buttons and page zones, read the provider.
export function Tray() {
  const uploads = use(Uploads)!;
  return (
    <UploadTray
      items={uploads.items}
      onRetry={uploads.retry}
      onRemove={uploads.remove}
      onCancel={uploads.cancel}
      onClear={uploads.clear}
      onCancelAll={uploads.cancelAll}
    />
  );
}

The library never reads or sends a file, so the check in the drop zone and the button is a courtesy to the reader. Check every file again on your server: its size, and its type by its content rather than its name or the type the browser sent, since a renamed file passes any check by its name (OWASP's File Upload Cheat Sheet). Store it under a name of your own, out of the web root.

  • The Documents page puts a PageHeader at its top with an Upload FileButton in its actions, and a PageDropZone at its root, so a file dropped anywhere on the page joins the tray; the button is the drop's keyboard twin. Both call the provider's add.
  • A row's href, once the server has read the file, is its document's page, so the name links there through your RouterProvider. A client navigation keeps the root layout, and with it the shell, the tray, its items, its scroll and whether it is folded.
  • A document's page is its own URL, so a review can be shared and reloaded. It holds a review grid, a DatePicker, a NumberField and a TextField for each line, and a SaveBar under it. The bar's action saves, moves on to the next document with router.push and gives focus to that document's heading, so a keyboard or screen reader user starts the next review at its title. Its Cancel asks before throwing the edits away while they are unsaved.
  • The tray warns before the tab closes or reloads while files are waiting or uploading; moving between documents is a client navigation, and needs no warning.

The example below is the same flow in one client component, since this site is static and makes no request: timers stand in for the request and for the server's reading, the two pages are ?doc= on this page's URL, and the tray sits at the example's root, outside both, as the shell's floating slot does. Press Upload and choose a few PDFs or photos: each waits, uploads, is read, and ends saved. A file over 10 MB fails ("Over 10 MB"), and Retry starts it again; a file whose name holds "blurry" needs review ("The total could not be read"). Open a document from its row or from the list, change a line, and confirm: the next document opens with focus on its heading, and the tray stays as it was. Turn the switch on and drop files anywhere on the page.

Example

Changed
Follows the page theme and corners
'use client';

import {
  Breadcrumb,
  Breadcrumbs,
  DatePicker,
  FileButton,
  Link,
  NumberField,
  PageColumn,
  PageDropZone,
  PageHeader,
  RouterProvider,
  SaveBar,
  Switch,
  Text,
  TextField,
  UploadTray,
  type FileRejection,
  type UploadItem,
} from '@snapshot-ui/react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import {
  Suspense,
  useCallback,
  useEffect,
  useRef,
  useState,
  type CSSProperties,
  type ReactElement,
  type RefObject,
} from 'react';
import {
  dayLabel,
  DOCUMENTS,
  documentOf,
  outcomeOf,
  sizeLabel,
  type DocumentLine,
  type DocumentRecord,
} from './documents-data';

/** What the Upload button, the page zone and the server take: list an extension beside each MIME type. */
const TYPES = ['application/pdf', '.pdf', 'image/jpeg', '.jpg', '.jpeg', 'image/png', '.png', '.heic'];

/** A row of the tray, with the size the app keeps to write its progress words. */
interface Upload extends UploadItem {
  id: string;
  size: number;
}

/** The uploads provider an app keeps in its root layout: the files in flight and the documents they became. */
interface Uploads {
  items: Upload[];
  documents: DocumentRecord[];
  add: (files: File[]) => void;
  retry: (id: UploadItem['id']) => void;
  remove: (id: UploadItem['id']) => void;
  cancel: (id: UploadItem['id']) => void;
  clear: () => void;
  cancelAll: () => void;
  save: (record: DocumentRecord) => void;
}

/**
 * The provider's state and its upload code. In an app each file goes to the server with `XMLHttpRequest`, whose
 * `upload.onprogress` moves the bar; here timers stand in for the request and for the server's reading, since the
 * docs make no request.
 */
function useUploads(hrefOf: (id: string) => string): Uploads {
  const [items, setItems] = useState<Upload[]>([]);
  const [documents, setDocuments] = useState<DocumentRecord[]>(() => [...DOCUMENTS]);
  const timers = useRef(new Map<string, number[]>());
  const count = useRef(0);

  const stop = useCallback((id: string): void => {
    for (const timer of timers.current.get(id) ?? []) window.clearTimeout(timer);
    timers.current.delete(id);
  }, []);
  useEffect(() => {
    const all = timers.current;
    return () => {
      for (const list of all.values()) for (const timer of list) window.clearTimeout(timer);
      all.clear();
    };
  }, []);

  const put = useCallback((next: Upload): void => {
    setItems((current) => current.map((item) => (item.id === next.id ? next : item)));
  }, []);

  /** Sends one file: waiting, then uploading in five steps, then read by the server, then its outcome. */
  const run = useCallback(
    (id: string, name: string, size: number, delay: number): void => {
      stop(id);
      const steps: [number, () => void][] = [];
      for (let step = 0; step <= 5; step += 1) {
        const progress = step / 5;
        steps.push([
          delay + step * 200,
          () => {
            put({
              id,
              name,
              size,
              status: 'uploading',
              progress,
              progressLabel: sizeLabel(size * progress, size),
            });
          },
        ]);
      }
      steps.push([
        delay + 1200,
        () => {
          put({ id, name, size, status: 'reading' });
        },
      ]);
      steps.push([
        delay + 2400,
        () => {
          timers.current.delete(id);
          const outcome = outcomeOf({ name, size });
          if (outcome.status === 'failed') {
            put({ id, name, size, status: 'failed', reason: outcome.reason });
            return;
          }
          // The file is a document now: it joins the list, and its row links there.
          setDocuments((current) => [
            documentOf(id, name, outcome),
            ...current.filter((each) => each.id !== id),
          ]);
          put(
            outcome.status === 'review'
              ? { id, name, size, status: 'review', reason: outcome.reason, href: hrefOf(id) }
              : { id, name, size, status: 'saved', href: hrefOf(id) },
          );
        },
      ]);
      timers.current.set(
        id,
        steps.map(([ms, step]) => window.setTimeout(step, ms)),
      );
    },
    [hrefOf, put, stop],
  );

  const add = (files: File[]): void => {
    const added = files.map((file): Upload => {
      count.current += 1;
      return { id: `upload-${String(count.current)}`, name: file.name, size: file.size, status: 'waiting' };
    });
    setItems((current) => [...added, ...current]);
    // Two at a time would be a real app's queue; here each starts a moment after the one before.
    added.forEach((item, index) => {
      run(item.id, item.name, item.size, 400 + index * 400);
    });
  };
  const drop = (ids: readonly string[]): void => {
    for (const id of ids) stop(id);
    setItems((current) => current.filter((item) => !ids.includes(item.id)));
  };
  const settled = (item: Upload): boolean =>
    item.status === 'saved' || item.status === 'review' || item.status === 'failed';
  return {
    items,
    documents,
    add,
    retry: (id) => {
      const item = items.find((each) => each.id === id);
      if (item === undefined) return;
      put({ id: item.id, name: item.name, size: item.size, status: 'waiting' });
      run(item.id, item.name, item.size, 400);
    },
    remove: (id) => {
      drop([String(id)]);
    },
    // A cancelled upload is aborted (`request.abort()`) and leaves the list.
    cancel: (id) => {
      drop([String(id)]);
    },
    clear: () => {
      drop(items.filter(settled).map((item) => item.id));
    },
    cancelAll: () => {
      drop(
        items
          .filter((item) => item.status === 'waiting' || item.status === 'uploading')
          .map((item) => item.id),
      );
    },
    save: (record) => {
      setDocuments((current) => current.map((each) => (each.id === record.id ? record : each)));
    },
  };
}

/** Gives focus to the heading a view opened with, once, after the save bar's action moved to it. */
function useHeadingFocus(header: RefObject<HTMLElement | null>, focusHeadingRef: RefObject<boolean>): void {
  useEffect(() => {
    if (!focusHeadingRef.current) return;
    focusHeadingRef.current = false;
    const heading = header.current?.querySelector<HTMLElement>('.page-header-title');
    if (heading == null) return;
    // A heading that takes focus from a script only, as a side panel's does.
    heading.tabIndex = -1;
    heading.focus();
  }, [header, focusHeadingRef]);
}

interface ViewProps {
  uploads: Uploads;
  listHref: string;
  hrefOf: (id: string) => string;
  focusHeadingRef: RefObject<boolean>;
}

/** The Documents page: its header with the Upload button, the page zone behind a switch, and the documents. */
function DocumentsView({ uploads, hrefOf, focusHeadingRef }: ViewProps): ReactElement {
  const header = useRef<HTMLElement>(null);
  useHeadingFocus(header, focusHeadingRef);
  const [isOn, setOn] = useState(false);
  const [refused, setRefused] = useState<string[]>([]);
  const take = (accepted: File[], rejected: FileRejection[]): void => {
    uploads.add(accepted);
    setRefused(rejected.map(({ file }) => file.name));
  };
  return (
    <>
      <PageHeader
        ref={header}
        // An example on a docs page, which has its own h1; a page's own header keeps the default, 1.
        headingLevel={2}
        breadcrumbs={
          <Breadcrumbs>
            <Breadcrumb href="/docs">Home</Breadcrumb>
            <Breadcrumb>Documents</Breadcrumb>
          </Breadcrumbs>
        }
        title="Documents"
        description="Receipts and statements, read and filed. Review each one the server has read."
        actions={
          // The page zone's keyboard twin, beside the title it uploads to.
          <FileButton acceptedFileTypes={TYPES} allowsMultiple onFiles={take}>
            Upload
          </FileButton>
        }
      />
      <PageColumn className="stack">
        <Switch isSelected={isOn} onChange={setOn}>
          Drop anywhere on this page
        </Switch>
        {refused.length === 0 ? null : (
          <Text size="small" tone="muted" className="documents-refused">
            {`Not added: ${refused.join(', ')}`}
          </Text>
        )}
        <ul className="stack" style={{ margin: 0, padding: 0, listStyle: 'none' }} aria-label="Documents">
          {uploads.documents.map((record) => (
            <li key={record.id} style={{ display: 'grid', gap: '0.25rem' }}>
              <Link href={hrefOf(record.id)}>{record.name}</Link>
              <Text size="small" tone="muted">
                {`${record.isReviewed ? 'Reviewed' : 'To review'}, ${String(record.lines.length)} ${
                  record.lines.length === 1 ? 'line' : 'lines'
                }`}
              </Text>
            </li>
          ))}
        </ul>
      </PageColumn>
      {/* In an app, mount the page zone at the page's root, beside its header: it draws its overlay in the shell. */}
      {isOn ? (
        <PageDropZone
          title="Drop files to upload"
          description="To Documents"
          acceptedFileTypes={TYPES}
          allowsMultiple
          onFiles={take}
        />
      ) : null}
    </>
  );
}

const head: CSSProperties = { textAlign: 'start', fontWeight: 500, paddingBlockEnd: 4 };

/** A document's review: a grid of fields per line and a save bar that moves on to the next document. */
function DocumentView({
  uploads,
  listHref,
  hrefOf,
  focusHeadingRef,
  record,
}: ViewProps & { record: DocumentRecord }): ReactElement {
  const router = useRouter();
  const header = useRef<HTMLElement>(null);
  useHeadingFocus(header, focusHeadingRef);
  const [lines, setLines] = useState(record.lines);
  const [problem, setProblem] = useState('');
  const change = (id: string, patch: Partial<DocumentLine>): void => {
    setLines((current) => current.map((line) => (line.id === id ? { ...line, ...patch } : line)));
  };
  const savedOf = (id: string): DocumentLine | undefined => record.lines.find((line) => line.id === id);
  const changes = lines.reduce((sum, line) => {
    const saved = savedOf(line.id);
    return sum + (['date', 'amount', 'memo'] as const).filter((key) => saved?.[key] !== line[key]).length;
  }, 0);
  const invalid = lines.filter((line) => line.date === null || line.amount <= 0).length;
  const others = uploads.documents.filter((each) => !each.isReviewed && each.id !== record.id);
  const next = others[0];
  return (
    <>
      <PageHeader
        ref={header}
        headingLevel={2}
        breadcrumbs={
          <Breadcrumbs>
            <Breadcrumb href="/docs">Home</Breadcrumb>
            <Breadcrumb href={listHref}>Documents</Breadcrumb>
            <Breadcrumb>{record.name}</Breadcrumb>
          </Breadcrumbs>
        }
        title={record.name}
        description={record.isReviewed ? 'Reviewed.' : 'Check each line against the document, then confirm.'}
      />
      <PageColumn>
        {/* The holder of the grid and its save bar, whose last child the bar is. */}
        <section aria-label="Review" style={{ width: '100%', minWidth: 0 }}>
          <div style={{ width: '100%', overflowX: 'auto' }}>
            <table style={{ borderCollapse: 'separate', borderSpacing: '8px 12px', marginInline: -8 }}>
              <thead>
                <tr>
                  {['Line', 'Date', 'Amount', 'Memo'].map((name) => (
                    <th key={name} id={`${record.id}-${name.toLowerCase()}`} scope="col" style={head}>
                      {name}
                    </th>
                  ))}
                </tr>
              </thead>
              <tbody>
                {lines.map((line, index) => {
                  const saved = savedOf(line.id);
                  // Each field is named by its column's header and its line: "Date Line 1".
                  const named = (column: string): string => `${record.id}-${column} ${record.id}-${line.id}`;
                  return (
                    <tr key={line.id}>
                      <th id={`${record.id}-${line.id}`} scope="row" style={{ ...head, fontWeight: 450 }}>
                        {`Line ${String(index + 1)}`}
                      </th>
                      <td style={{ minWidth: '12rem' }}>
                        <DatePicker
                          aria-labelledby={named('date')}
                          value={line.date}
                          valueLabel={line.date === null ? undefined : dayLabel(line.date)}
                          today="2026-10-16"
                          isChanged={line.date !== saved?.date}
                          isInvalid={line.date === null}
                          errorMessage="Enter the date on the document."
                          onChange={(date) => {
                            change(line.id, { date });
                          }}
                        />
                      </td>
                      <td style={{ minWidth: '9rem' }}>
                        <NumberField
                          aria-labelledby={named('amount')}
                          money={{ symbol: '$' }}
                          value={line.amount}
                          isChanged={line.amount !== saved?.amount}
                          isInvalid={line.amount <= 0}
                          errorMessage="Enter the amount paid."
                          onChange={(amount) => {
                            change(line.id, { amount: amount ?? 0 });
                          }}
                        />
                      </td>
                      <td style={{ minWidth: '12rem' }}>
                        <TextField
                          aria-labelledby={named('memo')}
                          value={line.memo}
                          isChanged={line.memo !== saved?.memo}
                          onChange={(memo) => {
                            change(line.id, { memo });
                          }}
                        />
                      </td>
                    </tr>
                  );
                })}
              </tbody>
            </table>
          </div>
          <SaveBar
            message={
              changes === 0
                ? `${record.name}, ${String(lines.length)} ${lines.length === 1 ? 'line' : 'lines'}`
                : `${String(changes)} ${changes === 1 ? 'change' : 'changes'} to ${record.name}`
            }
            isDirty={changes > 0}
            actionLabel={
              next === undefined ? 'Confirm' : `Confirm and review next (${String(others.length)} left)`
            }
            // Never disabled: a press with an invalid field is answered in the status.
            onAction={async () => {
              if (invalid > 0) {
                setProblem(`${String(invalid)} ${invalid === 1 ? 'line needs' : 'lines need'} attention.`);
                return;
              }
              setProblem('');
              // Your request goes here; the example waits a moment instead.
              await new Promise((resolve) => setTimeout(resolve, 500));
              uploads.save({ ...record, lines, isReviewed: true });
              // The next document's heading takes focus once its view is drawn.
              focusHeadingRef.current = true;
              router.push(next === undefined ? listHref : hrefOf(next.id), { scroll: false });
            }}
            onCancel={() => {
              setProblem('');
              setLines(record.lines);
            }}
            status={problem}
            statusTone="error"
          />
        </section>
      </PageColumn>
    </>
  );
}

/** The view the URL names: a document under `?doc=`, or the Documents page. */
function Views({ params, ...props }: ViewProps & { params: URLSearchParams }): ReactElement {
  const record = props.uploads.documents.find((each) => each.id === params.get('doc'));
  return record === undefined ? (
    <DocumentsView {...props} />
  ) : (
    // Keyed by the document, so another one starts its review afresh.
    <DocumentView key={record.id} {...props} record={record} />
  );
}

function ViewsFromUrl(props: ViewProps): ReactElement {
  const params = useSearchParams();
  return <Views {...props} params={new URLSearchParams(params)} />;
}

/** The first state: no parameters, as the static page's server renders it. */
const FIRST = new URLSearchParams();

export default function DocumentsRecipe(): ReactElement {
  const router = useRouter();
  const pathname = usePathname();
  const hrefOf = useCallback((id: string): string => `${pathname}?doc=${encodeURIComponent(id)}`, [pathname]);
  // In an app this is the provider in the root layout, above every page.
  const uploads = useUploads(hrefOf);
  const focusHeadingRef = useRef(false);
  const props: ViewProps = { uploads, listHref: pathname, hrefOf, focusHeadingRef };
  return (
    // An app wraps its whole tree once; the example wraps only itself.
    <RouterProvider
      navigate={(href) => {
        router.push(href, { scroll: false });
      }}
    >
      <div style={{ width: '100%', minWidth: 0 }}>
        {/* The page is static, so the server renders the fallback: the Documents page. */}
        <Suspense fallback={<Views {...props} params={FIRST} />}>
          <ViewsFromUrl {...props} />
        </Suspense>
        {/* Outside both views, as AppShell's floating slot is outside every page: a navigation keeps it. */}
        <UploadTray
          items={uploads.items}
          onRetry={uploads.retry}
          onRemove={uploads.remove}
          onCancel={uploads.cancel}
          onClear={uploads.clear}
          onCancelAll={uploads.cancelAll}
        />
      </div>
    </RouterProvider>
  );
}

Usage

The library never uploads. Your app holds the files in a provider in its root layout, sends each one, and gives the tray each file's status, progress and words; the tray draws and announces. Mount <UploadTray> in AppShell's floating slot from that layout, so a client navigation keeps it, with its scroll and its folded state, while the reader opens the documents it links to.

  • waiting, uploading and reading are in flight: the file is queued, being sent, or being read by your server. saved, review and failed have settled. A file being read is on your server already, so it has no Cancel, Cancel all does not count it and it does not hold the page open.
  • progress runs from 0 to 1 while a file uploads, and progressLabel says it in your words, such as "1.8 of 4 MB". Without progress an uploading row draws a bar that slides, rather than one that looks stuck. A reading row always slides.
  • reason is required for failed: say why, in the reader's words, such as "Over 10 MB". A review row's reason says what to check.
  • href links the name to the document the file became, through your RouterProvider.
  • onRetry shows Retry on failed rows, onRemove shows Remove on waiting and settled rows, and onCancel shows Cancel on uploading rows. Each gets the row's id. When a row leaves, focus moves to the same button on the next row; when only its button leaves, as after Retry or when an upload finishes on its own, focus stays on the row itself, never on a button the reader did not choose. Focus the reader has moved elsewhere, the page's empty space included, is never pulled back.
  • Once nothing is in flight, the close button, and on a phone the sheet's footer button, which then reads "Clear", and the bar's Clear, call onClear, and your app drops its settled items. While files are waiting or uploading, the close button and the footer's "Cancel uploads" ask "Cancel 3 uploads?" first ("Cancel 1 upload?" for one) and call onCancelAll on yes; without onCancelAll they show only once everything has settled, and with neither handler there are none. When the tray leaves with focus inside, focus goes to <main>; when only the close button leaves, focus goes to the fold button.
  • warnsOnUnload, on by default, asks the browser to warn before the tab closes or reloads while any file is waiting or uploading. The browser shows its own words, and iOS Safari shows none, so the tray is the reader's real warning. A client navigation keeps the shell and needs none.
  • Corners follow the shared rule for rounded parts inside rounded parts. The panel and the bar name their corner, edge and padding as holders. The bar's button and its clear X fill it, so they take its radius less the gap. A button in the panel keeps its own round ends and moves out of the panel's corner instead: Close, or the fold when Close is not shown, sits below the corner, and so does the last row's trailing button, so both stay on the end edge of the other rows' buttons. Folded to its header, the panel is one row, and the trailing button moves inward instead. The sheet rounds only its top, which no row reaches. UploadList reads no holder: in a rounded box of your own, such as a Card, keep its buttons clear of the box's corners as you would any control of your own.
  • formatCount writes the counts in the title and the announcements, such as in Persian digits, and labels replaces every word the tray writes.
  • The labels that write a count take one of two shapes. title, started and settled say several counts, so they get them all and formatCount itself, (counts, format), and call format on each count they say. cancelAllTitle, as FileDropZone's rejected, says one count, so it gets that count and the count already written by formatCount, (count, formatted): use count to choose the plural and formatted in the words.
import { AppShell, UploadTray } from '@snapshot-ui/react';

// app/layout.tsx, inside your uploads provider.
<AppShell
  sidebar={<Nav />}
  floating={
    <UploadTray
      items={uploads.items}
      onRetry={uploads.retry}
      onRemove={uploads.remove}
      onCancel={uploads.cancel}
      onClear={uploads.clear}
      onCancelAll={uploads.cancelAll}
    />
  }
>
  {children}
</AppShell>;

Props

UploadTray takes the list's items and handlers. className goes on both of its faces; on UploadList, className and ref go on the list.

className
string

Goes on both faces, the wide panel and the phone's bar.

defaultCollapsed
boolean

description
string

A line under the title in slate, the app's: "About a minute left".

formatCount
((count: number) => string)

Default: String

isCollapsed
boolean

Folded to its header on a wide screen. Controlled and uncontrolled.

Default: false

items
readonly UploadItem[]

labels
Partial<UploadTrayLabels>

onCancel
((id: UploadItem['id']) => void)

Shows Cancel on uploading rows.

onCancelAll
(() => void)

The close button, and the phone's Cancel uploads, while files are waiting or uploading, after a confirm. Without it they show only once all have settled.

onClear
(() => void)

The close button, and the phone's Clear, once nothing is in flight: the app clears its settled items.

onCollapsedChange
((isCollapsed: boolean) => void)

onRemove
((id: UploadItem['id']) => void)

Shows Remove on waiting, failed, saved and needs-review rows.

onRetry
((id: UploadItem['id']) => void)

Shows Retry on failed rows.

warnsOnUnload
boolean

Warns before the page unloads while anything is in flight.

Default: true

aria-label
string

aria-labelledby
string

className
string

items
readonly UploadItem[]

labels
Partial<UploadListLabels>

onCancel
((id: UploadItem['id']) => void)

Shows Cancel on uploading rows.

onRemove
((id: UploadItem['id']) => void)

Shows Remove on waiting, failed, saved and needs-review rows.

onRetry
((id: UploadItem['id']) => void)

Shows Retry on failed rows.

href
string

The document the file became. The name links there, through the router.

id
string | number

Stable across updates.

name
string

The file's name, as the reader chose it.

progress
number

From 0 to 1 while uploading. Without it an uploading row draws an indeterminate bar.

progressLabel
string

The progress in the app's words: "1.2 of 4 MB".

Default: the percentage, "45%"

reason
string

Why it failed or what needs review, in the app's words: "Over 10 MB", "The total could not be read". Required for 'failed'.

status
UploadStatus

attention
(counts: { failed: number; review: number; }, format: FormatCount) => string

Said for rows that turned failed or needs review, gathered over QUIET_MS.

Default: "1 upload failed"

cancel
(name: string) => string

cancelAll
string

The sheet's footer button while files are in flight, which asks first.

Default: 'Cancel uploads'

cancelAllConfirm
string

cancelAllDescription
string

cancelAllKeep
string

cancelAllTitle
(count: number, formatted: string) => string

The question Cancel all asks, given how many files it would stop (waiting or uploading) and that count as formatCount writes it.

Default: 'Cancel 1 upload?', 'Cancel 3 uploads?', then 'Files still uploading will not be saved.', 'Cancel uploads', 'Keep uploading'

clear
string

The phone's Clear once nothing is in flight: the sheet's footer button and the bar's X.

Default: 'Clear'

close
string

The wide panel's close button, which clears or cancels all.

Default: 'Close'

closeSheet
string

The phone's sheet's own close button, which only closes the sheet.

Default: 'Close'

details
string

The fold button's name, the same open or folded; aria-expanded says which.

Default: 'Upload details'

region
string

The landmark name of the phone's bar, whose one button says the title.

Default: 'Uploads'

remove
(name: string) => string

retry
(name: string) => string

The row buttons' names, given the file's name.

Default: (name) => `Retry ${name}`, `Remove ${name}`, `Cancel ${name}`

settled
(counts: UploadCounts, format: FormatCount) => string

Said once every row of the batch has settled.

Default: "5 uploads finished: 3 saved, 1 needs review, 1 failed"

started
(counts: UploadCounts, format: FormatCount) => string

Said once as a batch starts, with the counts of the files in it.

Default: "Uploading 5 files"

status
Record<UploadStatus, string>

The status words.

Default: 'Waiting', 'Uploading', 'Reading', 'Saved', 'Needs review', 'Failed'

title
(counts: UploadCounts, format: (count: number) => string) => string

The heading, from the counts as formatCount writes them. While anything is in flight the counts are the running batch's rows, so rows settled before it do not swell "Uploading 2 of 5"; once all have settled, every row's.

Default: "Uploading 2 of 5", then "3 uploads complete, 1 failed, 1 needs review"

Accessibility

The panel is a region named by its title, which a screen reader reaches with its landmark keys; on a phone the bar is a region named "Uploads" holding the button that opens a dialog titled by the same title, and once everything has settled a Clear; the dialog's own close button is named for closing it, apart from Clear and Cancel uploads at its foot. The list is a plain list in the page's tab order: each row reads its name or its link, its status in words with the reason, its bar while it moves, and its buttons, named for their file, such as "Retry invoice.pdf".

The tray never hides what has focus. Once the page has loaded, a control on the page that focus lands on under the panel, open or folded, or under the phone's bar, is scrolled 16px clear of it, by Tab, Shift+Tab or a script's focus(); a control beside the panel, not under it, stays where it is.

The tray says three things politely, and nothing else:

  • once, as a batch starts, how many files it holds ("Uploading 5 files");
  • rows that failed or need review, gathered over a short pause, so five failures in a burst are one sentence ("2 uploads failed");
  • a summary once every file has settled ("5 uploads finished: 3 saved, 1 needs review, 1 failed").

It never says a percentage, or a file turning saved on its own: the summary says it. A retry that starts at once hears no summary first.

TabShift+TabThrough the header's buttons, then each row's link and buttons, in order; on a phone, the bar's button and its Clear.
EnterSpaceOn the fold button, folds or unfolds the list; on a link, opens the document; on Retry, Remove or Cancel, does it.
EscapeIn the phone's sheet, closes it, and focus returns to the bar. The panel is not a dialog and takes no Escape.

Design rules

What keeps uploads in the system.

Say why a file failed, in the reader's words.

Link a saved file to its document.

Mount one tray, in the shell, from the root layout.

Never say a percentage in words.

Never put uploads in a modal.

Never draw a row in signal orange or clay.

esc

↑ ↓ to move↵ to open