# File upload

A place to choose files or drop them, with what is accepted said first, and each chosen file in a row below with its progress and result.

Block: `smbk-file-upload`. Status: experimental. Figma: the Figma file, https://www.figma.com/community/file/1688324308358870630. Desktop and mobile: differ by presentation only.

## Options

| Axis | Values | Default | Meaning |
|---|---|---|---|
| `type` | `drop-zone`, `button-only` | `drop-zone` | A drop zone is a dashed area with a title, what is accepted and a button, that also takes files dropped on it; button only is the button over what is accepted, for narrow places. Figma calls them "Drop zone" and "Button only". |
| `error` | on / off | off | Something is wrong, such as no file chosen when one is required. The zone takes the danger edge; say what is wrong in the message. |
| `dragging` | on / off | off | Files are held over the zone: the brand edge, the sunken surface and "Drop to add", at the same height. The wrapper sets it while dragging; Figma calls it "Drag-over". |

Parts: `smbk-file-upload__zone`, `smbk-file-upload__body`, `smbk-file-upload__icon`, `smbk-file-upload__text`, `smbk-file-upload__title`, `smbk-file-upload__constraints`, `smbk-file-upload__button`, `smbk-file-upload__input`, `smbk-file-upload__message`, `smbk-file-upload__drop`, `smbk-file-upload__files`, `smbk-file-upload__file`, `smbk-file-upload__announcement`

## Markup

```html
<div class="smbk-file-upload smbk-file-upload--type-drop-zone">
  <div class="smbk-file-upload__zone">
    <div class="smbk-file-upload__body">
      <i
        class="ph ph-upload-simple smbk-icon smbk-icon--size-md smbk-file-upload__icon"
        aria-hidden="true"
      ></i>
      <div class="smbk-file-upload__text">
        <p class="smbk-file-upload__title">Choose files or drag them here</p>
        <p
          id="uploadfile-upload-1-constraints"
          class="smbk-file-upload__constraints"
        >PDF or Word, up to 10 MB each, 5 files at most.</p>
      </div>
      <label class="smbk-btn smbk-btn--tone-secondary smbk-btn--size-md smbk-file-upload__button">
        <input
          aria-label="Attachments"
          id="uploadfile-upload-1-input"
          type="file"
          class="smbk-file-upload__input"
          accept=".pdf,.doc,.docx"
          multiple=""
          aria-labelledby="uploadfile-upload-1-input uploadfile-upload-1-button"
          aria-describedby="uploadfile-upload-1-constraints"
        >
        <span
          id="uploadfile-upload-1-button"
          class="smbk-btn__label"
          aria-hidden="true"
        >Browse files</span>
      </label>
    </div>
    <div class="smbk-file-upload__drop" aria-hidden="true">
      <i
        class="ph ph-upload-simple smbk-icon smbk-icon--size-md smbk-file-upload__icon"
        aria-hidden="true"
      ></i>
      <div class="smbk-file-upload__text">
        <p class="smbk-file-upload__title">Drop to add</p>
        <p class="smbk-file-upload__constraints">PDF or Word, up to 10 MB each, 5 files at most.</p>
      </div>
    </div>
  </div>
  <div role="status" class="smbk-file-upload__announcement"></div>
</div>
```

## React

```tsx
import { FileUpload } from './design-system/components/file-upload/file-upload.js';

<FileUpload type="drop-zone" aria-label="Attachments" multiple accept=".pdf,.doc,.docx" constraints="PDF or Word, up to 10 MB each, 5 files at most." />
```

## Stylesheet

```css
/* smbk-file-upload — File upload. Figma "File upload" 7110:345 (Type: Drop zone, Button only × State: Default,
 * Drag-over, Focus, Disabled, Error).
 * A native file input, unseen but in the tab order, inside a label drawn as the secondary button, so the browser's
 * own picker always opens: from a press on the button, or Enter or Space on the focused input. The drop zone is a
 * dashed area round an upload icon, a title over the line that says what is accepted, the button and, in error,
 * the field message; dropping files on it is an extra. Button only is the button over the same line, for narrow
 * places. Focus is the input's, drawn as the focus ring round the zone (round the button alone in the button-only
 * form); disabled is the input's attribute; error and dragging are flags. While files are held over it the zone
 * keeps its height and swaps its body for the drop layer: the upload icon and "Drop to add" over the same line,
 * centred, on the sunken surface inside the brand edge. The chosen files are file rows in a list under it.
 * The edge is drawn inside, so the paddings give its width back, at the hairline and at the emphasis width. */

.smbk-file-upload {
  display: flex;
  flex-direction: column;
  gap: var(--smbk-space-stack-md);
  min-width: 0;
}

/* The block frames the words it hides for screen readers, so they stay inside it wherever it is placed: without a
   frame they hang from the page itself and stretch it, past the end of an area that scrolls. No weight, so a parent
   that places the block keeps its say. */
:where(.smbk-file-upload) {
  position: relative;
}

/* ---- the drop zone ---- */

.smbk-file-upload__zone {
  box-sizing: border-box;
  display: grid;
  min-width: 0;
  padding: calc(var(--smbk-space-inset-2xl) - var(--smbk-border-width-hairline));
  border: var(--smbk-border-width-hairline) dashed var(--smbk-color-border-strong);
  border-radius: var(--smbk-radius-container-md);
  background: var(--smbk-color-surface-default);
  text-align: center;
}

/* The body and the drop layer share one cell, so the zone is as tall as its body whichever shows. */
.smbk-file-upload__body,
.smbk-file-upload__drop {
  grid-area: 1 / 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--smbk-space-stack-lg);
  min-width: 0;
}

.smbk-file-upload__drop {
  justify-content: center;
  visibility: hidden;
}

.smbk-file-upload__icon {
  font-size: var(--smbk-size-icon-lg);
  color: var(--smbk-color-icon-subtle);
}

.smbk-file-upload__text {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--smbk-space-stack-xs);
  min-width: 0;
}

.smbk-file-upload__title {
  margin: 0;
  color: var(--smbk-color-text-default);
  font-family: var(--smbk-typography-body-md-medium-font-family);
  font-size: var(--smbk-typography-body-md-medium-font-size);
  font-weight: var(--smbk-typography-body-md-medium-font-weight);
  line-height: var(--smbk-typography-body-md-medium-line-height);
  letter-spacing: var(--smbk-typography-body-md-medium-letter-spacing);
}

/* What is accepted: the types, the largest size, how many. */
.smbk-file-upload__constraints {
  margin: 0;
  color: var(--smbk-color-text-subtle);
  font-family: var(--smbk-typography-body-xs-font-family);
  font-size: var(--smbk-typography-body-xs-font-size);
  font-weight: var(--smbk-typography-body-xs-font-weight);
  line-height: var(--smbk-typography-body-xs-line-height);
  letter-spacing: var(--smbk-typography-body-xs-letter-spacing);
}

/* The button is a label round the input, carrying the button's classes, so its looks are the button's. */
.smbk-file-upload__button {
  position: relative;
  max-width: 100%;
}

/* The input stays in the page and the tab order, unseen; the button and the zone draw its focus. */
/* .smbk-file-upload__input: hidden from view, kept for screen readers and the keyboard, by the shared rule in visually-hidden/visually-hidden.css. */
.smbk-file-upload__input {
  clip-path: inset(50%);
}

/* The field message across the zone's width, its words from the start edge, as Figma draws it. */
.smbk-file-upload__message {
  align-self: stretch;
  text-align: start;
}

/* Focus: the ring goes round the zone, not the button inside it. */
.smbk-file-upload--type-drop-zone .smbk-file-upload__zone:has(.smbk-file-upload__input:focus-visible) {
  outline: var(--smbk-focus-ring-width) solid var(--smbk-color-border-focus);
  outline-offset: var(--smbk-focus-ring-offset);
}

.smbk-file-upload--type-drop-zone .smbk-file-upload__button:has(> .smbk-file-upload__input:focus-visible) {
  outline: none;
}

/* Error: the danger edge at the emphasis width; its file input marked aria-invalid="true" draws the same. */
.smbk-file-upload--error .smbk-file-upload__zone,
.smbk-file-upload:where(:has([aria-invalid="true"])) .smbk-file-upload__zone {
  padding: calc(var(--smbk-space-inset-2xl) - var(--smbk-border-width-emphasis));
  border-width: var(--smbk-border-width-emphasis);
  border-color: var(--smbk-color-feedback-danger-border);
}

/* Dragging: the brand edge at the emphasis width and the sunken surface, the drop layer in the body's place.
   The button-only form takes no drop, so it has no dragging look. */
.smbk-file-upload--dragging:not(.smbk-file-upload--type-button-only) .smbk-file-upload__zone {
  padding: calc(var(--smbk-space-inset-2xl) - var(--smbk-border-width-emphasis));
  border-width: var(--smbk-border-width-emphasis);
  border-color: var(--smbk-color-border-brand);
  background: var(--smbk-color-surface-sunken);
}

.smbk-file-upload--dragging:not(.smbk-file-upload--type-button-only) .smbk-file-upload__body {
  visibility: hidden;
}

.smbk-file-upload--dragging:not(.smbk-file-upload--type-button-only) .smbk-file-upload__drop {
  visibility: visible;
}

/* Disabled: the icon and the words take the disabled colors; the button takes its own disabled look. */
.smbk-file-upload:has(.smbk-file-upload__input:disabled) .smbk-file-upload__icon {
  color: var(--smbk-color-icon-disabled);
}

.smbk-file-upload:has(.smbk-file-upload__input:disabled) :is(.smbk-file-upload__title, .smbk-file-upload__constraints) {
  color: var(--smbk-color-text-disabled);
}

/* ---- button only: the button over the line, stacked from the start edge, with no zone drawn ---- */

.smbk-file-upload--type-button-only .smbk-file-upload__zone {
  padding: 0;
  border: 0;
  border-radius: 0;
  background: none;
  text-align: start;
}

.smbk-file-upload--type-button-only .smbk-file-upload__body {
  align-items: flex-start;
  gap: var(--smbk-space-inline-md);
}

/* ---- the chosen files ---- */

.smbk-file-upload__files {
  display: flex;
  flex-direction: column;
  gap: var(--smbk-space-stack-md);
  margin: 0;
  padding: 0;
  list-style: none;
}

.smbk-file-upload__file {
  min-width: 0;
}

/* What changed, for a screen reader: in the page, not drawn. */
/* .smbk-file-upload__announcement: hidden from view, kept for screen readers and the keyboard, by the shared rule in visually-hidden/visually-hidden.css. */
.smbk-file-upload__announcement {
  clip-path: inset(50%);
}

/* ---- high contrast (forced colors) ---- */
/* The dragging zone's sunken surface is dropped in Windows high-contrast mode, so its edge says it: Highlight. */

@media (forced-colors: active) {
  .smbk-file-upload--dragging:not(.smbk-file-upload--type-button-only) .smbk-file-upload__zone {
    border-color: Highlight;
  }
}
```

## Tokens it reads

```json
{
  "space": {
    "stack": {
      "md": {
        "$type": "dimension",
        "$value": "{{space.12}}",
        "$description": "Gap between things stacked one above another, medium."
      },
      "lg": {
        "$type": "dimension",
        "$value": "{{space.16}}",
        "$description": "Gap between things stacked one above another, large."
      },
      "xs": {
        "$type": "dimension",
        "$value": "{{space.4}}",
        "$description": "Gap between things stacked one above another, extra small."
      }
    },
    "inset": {
      "2xl": {
        "$type": "dimension",
        "$value": "{{space.24}}",
        "$description": "Padding inside a component, 2× large."
      }
    },
    "inline": {
      "md": {
        "$type": "dimension",
        "$value": "{{space.8}}",
        "$description": "Gap between things side by side, medium."
      }
    }
  },
  "border-width": {
    "hairline": {
      "$type": "dimension",
      "$value": "{{border-width.1}}",
      "$description": "Thin lines: dividers and almost every edge."
    },
    "emphasis": {
      "$type": "dimension",
      "$value": "{{border-width.2}}",
      "$description": "Two-pixel edges for emphasis: a chosen card, a focus ring."
    }
  },
  "color": {
    "border": {
      "strong": {
        "$type": "color",
        "$value": "{{color.grey.400}}",
        "$description": "Stronger edges and separators."
      },
      "focus": {
        "$type": "color",
        "$value": "{{color.red.600}}",
        "$description": "Keyboard focus ring on controls."
      },
      "brand": {
        "$type": "color",
        "$value": "{{color.red.600}}",
        "$description": "Brand-red edge; also the logo’s mark."
      }
    },
    "surface": {
      "default": {
        "$type": "color",
        "$value": "{{color.grey.100}}",
        "$description": "Standard surface: cards, panels, the header."
      },
      "sunken": {
        "$type": "color",
        "$value": "{{color.alpha.grey-800.5}}",
        "$description": "Recessed wells: fields, tracks, code. See-through, so a well always shows one step below what it sits on: the page, a card or a panel."
      }
    },
    "icon": {
      "subtle": {
        "$type": "color",
        "$value": "{{color.grey.700}}",
        "$description": "Quiet icons."
      },
      "disabled": {
        "$type": "color",
        "$value": "{{color.grey.400}}",
        "$description": "Icons in unavailable controls."
      }
    },
    "text": {
      "default": {
        "$type": "color",
        "$value": "{{color.grey.900}}",
        "$description": "Main text."
      },
      "subtle": {
        "$type": "color",
        "$value": "{{color.grey.700}}",
        "$description": "Secondary text: descriptions, metadata."
      },
      "disabled": {
        "$type": "color",
        "$value": "{{color.grey.500}}",
        "$description": "Text in unavailable controls."
      }
    },
    "feedback": {
      "danger": {
        "border": {
          "$type": "color",
          "$value": "{{color.red.500}}",
          "$description": "Danger messages: edge; the edge of a field in error (3:1)."
        }
      }
    }
  },
  "radius": {
    "container": {
      "md": {
        "$type": "dimension",
        "$value": "{{radius.12}}",
        "$description": "Corners of cards, panels and dialogs, medium."
      }
    }
  },
  "size": {
    "icon": {
      "lg": {
        "$type": "dimension",
        "$value": "{{size.24}}",
        "$description": "Icon box, large."
      }
    }
  },
  "typography": {
    "body-md-medium": {
      "$type": "typography",
      "$value": {
        "fontFamily": "Geist, Geist Fallback, system-ui, sans-serif",
        "fontSize": "16px",
        "fontWeight": 500,
        "lineHeight": 1.5,
        "letterSpacing": "0px"
      },
      "$description": "Body MD one weight step bolder, for the current item, so the current item is not shown by colour alone."
    },
    "body-xs": {
      "$type": "typography",
      "$value": {
        "fontFamily": "Geist, Geist Fallback, system-ui, sans-serif",
        "fontSize": "12px",
        "fontWeight": 400,
        "lineHeight": 1.5,
        "letterSpacing": "0px"
      }
    }
  },
  "focus": {
    "ring": {
      "width": {
        "$type": "dimension",
        "$value": "{{border-width.2}}",
        "$description": "Thickness of the focus ring."
      },
      "offset": {
        "$type": "dimension",
        "$value": "{{space.2}}",
        "$description": "Gap between a control and its focus ring."
      }
    }
  }
}
```

## Purpose

Lets the reader add files to a form or a library: by pressing a button that opens the browser's own file picker, or by dropping files on a zone. It says what is accepted before anyone chooses, and shows each chosen file in a row under it with its progress, then whether it went through. A plain file input shows none of that.

## Anatomy

- The block, a column: the zone, then the list of files, then the announcement part.
- The zone part. In a drop zone it is a dashed area at the medium container corners; in the button-only form it draws nothing of its own. It holds the body part and, in a drop zone, the drop part, in one place, so the zone is as tall as its body whichever shows.
- The body part. In a drop zone: the icon part (an upload arrow), the text part with the title part over the constraints part, the button part, and in error the message part. In the button-only form: the button, the constraints and the message, stacked from the start edge.
- The button part, a label round the input carrying the button's secondary classes at the medium size, so its looks are the button's.
- The input part, the native file input inside the button: in the page and the tab order, unseen.
- The constraints part: what is accepted, said before anyone chooses: the types, the largest size, how many.
- The message part, a field message of the error type: what is wrong and what to do.
- The drop part, shown in the body's place while files are held over the zone: the icon, "Drop to add" and the constraints, centred. It is hidden from assistive technology.
- The files part, a list of the chosen files; each file part holds one file row.
- The announcement part, a status region that is in the page but not drawn.
- The type is a modifier: `--type-drop-zone`, `--type-button-only`. The `--error` and `--dragging` flags are modifiers too.
- The label is not the upload's: put it in a field.

## When to use

- A form that takes documents, such as a claim with its receipts or an application with its papers.
- A library or a workspace where files are added and uploaded one by one.
- A single file, such as a picture for a profile or a signed contract: set it to take one.

## When not to use

- A choice among files already on the server, such as a picture from a library: that is a picker of its own.
- Text that could be typed or pasted into a [text area](text-area.md).
- A narrow place such as a phone, for the drop zone: nothing is dragged there. Use the button-only form.

## Behavior

The button opens the browser's own file picker, with or without script. Pressing it, or Enter or Space when the input has focus, opens the picker. A drop zone also takes files dropped anywhere on it. While files are held over it, its edge turns the brand color at the emphasis width, its surface turns sunken and its words turn to "Drop to add", and it keeps its height. A file dropped on a disabled upload is ignored, and is not opened in the page's place. The button-only form takes no drop.

The React wrapper uploads nothing. It hands what was chosen or dropped to `onAdd`, and draws a file row for each file in `files`, which the page keeps and updates as each upload goes. It passes each row's buttons on as `onRemove`, `onCancel`, `onRetry` and `onDelete`, with the file's id. With `accept`, a file of another type is not added: it is passed to `onAdd` in the refused list with the reason `type`, so the page can show it as not added and say why. Without `multiple`, the upload takes one file, and a second one dropped at the same time is refused with the reason `count`. The input is emptied after each choice, so the same file can be chosen again after it is removed. It is required only while no file is chosen, so a form with a required upload is not sent empty.

In error the zone draws the danger edge at the emphasis width; `errorMessage` shows the message in the zone, under the button, or under the constraints in the button-only form. Give the message to the upload, not to the field around it: the field would draw it a second time under the zone. The upload's `error` turns on the error look without a message. Disabled, the icon and the words take the disabled colors and the button its own disabled look. Read-only (`readOnly`, which a field passes on), the upload shows its files and takes no more: a browser has no read-only file input, so the button and the zone are drawn and behave as disabled, and the rows show without their buttons. A ref given to the React wrapper reaches the native input.

## Accessibility

### What it does for you

- The input is named by the field's label, then the button's words ("Attachments Browse files"), so a reader using voice control can say either. The constraints and the error message are its description, read before anyone chooses.
- Tab reaches the input, which the focus ring round the zone shows (round the button alone in the button-only form), then each row's buttons. Enter or Space opens the picker.
- Dropping is an extra: the button always works.
- Each row's buttons are named with the file: "Remove quarterly-report.pdf", "Cancel upload of quarterly-report.pdf", "Try again to upload quarterly-report.pdf", "Delete quarterly-report.pdf".
- A file's change of status, a file added and a file removed are read out once each, with the file's name, in one polite status region: "quarterly-report.pdf: Uploaded". The progress in between is not read out.
- When the button that had focus goes, as when a file is removed, focus moves to the next row's first button, else to the last row's, else to the input.
- Disabled or read-only, the zone carries `aria-disabled` beside the input's `disabled`, so its faint title and line are read as part of a control out of reach.

### What you must do

- Put the upload in a field, so the input has a name, or give it an `aria-label`.
- Say what is accepted in the constraints. `accept` and the constraints are hints for the reader; the server decides what it takes.
- Change the words for another language: `title`, `dropTitle`, `buttonLabel`, `addedLabel`, `removedLabel`, the row's button names and `sizeLabel`, and `locale` for the sizes.
- In markup written by hand, add `aria-disabled` and `disabled` together when it is disabled or read-only, and leave out the rows' buttons when read-only.

### How to test

- With the keyboard only, Tab to the upload: the focus ring shows round the zone (or the button). Press Enter, then Space: each opens the picker.
- Add a file, then Tab to its row's buttons and remove it: focus moves to the next row's first button, else the last row's, else the input.
- With a screen reader, the input is read by the field's label and the button's words, with the constraints; each row's buttons are read with the file's name.
- Add, finish and remove a file: each change is read out once, and the progress in between is not.
- With text at 200% and the page 320 pixels wide, use the button-only form: the button and the rows stay whole.
- In a forced-color mode, the zone's edge, the focus ring and the buttons are still visible.

### Keyboard

| Key | What it does |
|---|---|
| Tab | Moves focus to the input, then to each row's buttons. |
| Enter or Space | On the input, opens the picker. On a row's button, presses it. |

## Usage rules

### Do

- Say what is accepted in the constraints: the types, the largest size, how many.
- Put it in a field, so the input is named.
- Say why a file was not added, in its row: "Not added: it must be a PDF."
- Use the button-only form in narrow places.

### Don't

- Make dropping the only way to add a file.
- Trust the file's extension or type: the server checks what it takes.
- Read out every percent of an upload.
