Navigation

v0.0.4
Content

File

One file as a thing with a name: an attachment, an upload or a download.

On this page

A file is one document shown as a thing: a small page with its extension, the name over a quiet line of facts, and one control at the end. It is a flat sheet, so a column of files under a dropzone or inside a dialog reads as a stack of separate objects.

File

<ul class="flex flex-col gap-xs max-w(400) mx-auto">  <li class="file">    <span class="file-page" aria-hidden="true">pdf</span>    <div class="file-body">      <p class="file-name">Q3 infrastructure report.pdf</p>      <p class="file-meta">2.4 MB · PDF document</p>    </div>    <div class="file-end">      <button type="button" class="btn btn-quiet btn-square btn-sm" aria-label="Remove Q3 infrastructure report.pdf">        <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">          <path d="M6 6l12 12M18 6L6 18" />        </svg>      </button>    </div>  </li>  <li class="file">    <img class="file-thumb" src="/demo/moonstone.svg" alt>    <div class="file-body">      <p class="file-name">moonstone-cover.png</p>      <p class="file-meta">1,920 × 1,200 · 840 KB</p>    </div>    <div class="file-end">      <button type="button" class="btn btn-quiet btn-square btn-sm" aria-label="Remove moonstone-cover.png">        <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">          <path d="M6 6l12 12M18 6L6 18" />        </svg>      </button>    </div>  </li>  <li class="file">    <span class="file-page" aria-hidden="true">csv</span>    <div class="file-body">      <p class="file-name">members-export-2026-11.csv</p>      <p class="file-meta">312 rows · 18 KB</p>    </div>    <div class="file-end">      <button type="button" class="btn btn-quiet btn-square btn-sm" aria-label="Remove members-export-2026-11.csv">        <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">          <path d="M6 6l12 12M18 6L6 18" />        </svg>      </button>    </div>  </li></ul>

#The page

file-page is a small raised sheet, 28 by 36, with the extension written at its foot in whatever case you type; it is set in capitals. Keep it to four characters. The name beside it already says the same thing, so hide the page from assistive technology with aria-hidden.

Its folded corner is drawn by the browser: with corner-shape, the top corner at the end is a bevel, and the flap is what that bevel leaves of a small square. A browser without corner-shape shows the page with four round corners and no fold.

When the file is a picture, file-thumb on an <img> takes the page's place.

#Uploading

Put a native <progress> with progress file-progress inside the file. The bar is then the sheet itself: it lies under the words from edge to edge and tints the sheet as far as the upload has come, and the sheet's corner finishes the fill. Without a value the amount is unknown and a band of the same tone travels across.

Write the amount in file-meta as well, and mark the file aria-busy="true" while it is on its way.

Uploading

<ul class="flex flex-col gap-xs max-w(400) mx-auto">  <li class="file" aria-busy="true">    <span class="file-page" aria-hidden="true">zip</span>    <div class="file-body">      <p class="file-name">atlas-build-2026-11-11.zip</p>      <p class="file-meta">Uploading · 64% of 182 MB</p>    </div>    <div class="file-end">      <button type="button" class="btn btn-quiet btn-square btn-sm" aria-label="Cancel the upload of atlas-build-2026-11-11.zip">        <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">          <path d="M6 6l12 12M18 6L6 18" />        </svg>      </button>    </div>    <progress class="progress file-progress" max="100" value="64" aria-label="Upload of atlas-build-2026-11-11.zip">64%</progress>  </li>  <li class="file" aria-busy="true">    <span class="file-page" aria-hidden="true">mp4</span>    <div class="file-body">      <p class="file-name">onboarding-walkthrough.mp4</p>      <p class="file-meta">Scanning for viruses…</p>    </div>    <progress class="progress file-progress" aria-label="Scan of onboarding-walkthrough.mp4"></progress>  </li></ul>

#Failed

file-failed gives the file the danger contour and turns file-meta to the danger ink. Say what went wrong there, and put the way out at the end. The state is a class because ARIA has no attribute for a failed item in a list: the words carry it, and role="alert" on them announces a failure that happens while the reader is on the page.

Failed

<div class="file file-failed max-w(400) mx-auto">  <span class="file-page" aria-hidden="true">mov</span>  <div class="file-body">    <p class="file-name">walkthrough-final-v2.mov</p>    <p class="file-meta" role="alert">Larger than the 100 MB limit</p>  </div>  <div class="file-end">    <button type="button" class="btn btn-sm">Replace</button>  </div></div>

#Compact

file-sm is one line, 32px tall and as wide as its name: an attachment above a composer, or a file mentioned in a row. It is as tall as a small button, so the two share a row. Its page is too small to carry the extension, so the name and the facts beside it do. The control at its end is 24px, 4px inside the sheet; on touch screens the file is 44px and the control 36px.

Compact

<form class="composer max-w(480) mx-auto">  <div class="composer-attachments">    <span class="file file-sm">      <span class="file-page" aria-hidden="true"></span>      <span class="file-body">        <span class="file-name">report.pdf</span>        <span class="file-meta">2.4 MB</span>      </span>      <span class="file-end">        <button type="button" class="btn btn-quiet btn-square btn-sm" aria-label="Remove report.pdf">          <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">            <path d="M6 6l12 12M18 6L6 18" />          </svg>        </button>      </span>    </span>    <span class="file file-sm">      <img class="file-thumb" src="/demo/moonstone.svg" alt>      <span class="file-body">        <span class="file-name">cover.png</span>      </span>      <span class="file-end">        <button type="button" class="btn btn-quiet btn-square btn-sm" aria-label="Remove cover.png">          <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">            <path d="M6 6l12 12M18 6L6 18" />          </svg>        </button>      </span>    </span>  </div>  <textarea class="composer-field" rows="1" placeholder="Add a note about these files" aria-label="Message"></textarea>  <div class="composer-toolbar">    <div class="composer-end">      <button type="submit" class="btn btn-primary btn-sm">Send</button>    </div>  </div></form>

#A file that opens

When the whole file is a link or a button, add file-interactive: it stands raised, lifts under the pointer and sinks when pressed, as a button does. Use <span> for the parts inside a link.

A file that opens

<div class="grid grid-cols(2) gap-sm max-w(520) mx-auto">  <a class="file file-interactive" href="#" download>    <span class="file-page" aria-hidden="true">docx</span>    <span class="file-body">      <span class="file-name">Master services agreement.docx</span>      <span class="file-meta">48 KB · Download</span>    </span>  </a>  <a class="file file-interactive" href="#" download>    <span class="file-page" aria-hidden="true">xlsx</span>    <span class="file-body">      <span class="file-name">Pricing schedule.xlsx</span>      <span class="file-meta">22 KB · Download</span>    </span>  </a></div>

#Accessibility

  • Several files are a list: use <ul> and put file on each <li>.
  • A control's name says which file it acts on: "Remove report.pdf", not "Remove".
  • Label the <progress>, and keep the percentage in the text too: the tint is a drawing of it.
  • A long name is cut at the end. Put the full name in title when the ending matters, as with an extension.

#Classes

ClassDescription
fileThe flat sheet that holds the page, the words and the control
file-failedAn upload that failed: the contour and file-meta take the danger ink; say the reason in file-meta. With file
file-smOne line, 32px tall like a small button and as wide as its name; the page is too small to carry the extension and a button inside is 24px, 4px in. With file
file-interactiveThe whole file is a link or a button: it stands raised, lifts under the pointer and sinks when pressed. With file
file-pageThe small page, 28 by 36, with the extension written at its foot in capitals
file-thumbA picture in the page's place, when the file is one
file-bodyThe name and the line of facts under it
file-nameThe file's name, on one line that ends in an ellipsis
file-metaSize, kind, how far the upload is, or why it failed
file-endThe control at the end: remove, retry or download
file-progressWith progress on a progress element: the bar lies under the words and tints the sheet as far as the upload has come

#Custom properties

PropertyDefaultDescription
--file-padding.625remInset of the sheet; .375rem in a small file
--file-radiusvar(--radius-control-lg)Corner of the sheet; var(--radius-control) in a small file
--file-page-width1.75remWidth of the page; 1rem in a small file
--file-page-height2.25remHeight of the page; 1.25rem in a small file
--file-fold.5625remSize of the folded corner; .3125rem in a small file

#Source

@master/ui/components/file.css
/* * File: one file as a thing with a name: an attachment, an upload, a download. * A flat sheet holds a small page with the file's extension written on it, the name over a quiet line of * facts, and one control at the end. The page is a sheet of its own, 28 by 36, whose top corner is folded: * where the browser has corner-shape that corner is a bevel and the flap is what the bevel leaves of a small * square; elsewhere the page keeps four round corners and no flap. * While it uploads, a native <progress> lies under the words and tints the sheet as far as the upload has * come. A file that failed is file-failed: its contour and its facts take the danger ink. That state is a * class because ARIA has no word for it on a list item; the reason, in words, is in the facts. */@layer components {  .file {    --file-padding: .625rem;    --file-radius: var(--radius-control-lg);    --file-page-width: 1.75rem;    --file-page-height: 2.25rem;    --file-fold: .5625rem;    --surface-color: var(--color-surface-raised);    position: relative;    display: flex;    align-items: center;    gap: .75rem;    min-width: 0;    margin: 0;    padding: var(--file-padding);    padding-inline-end: calc(var(--file-padding) + .125rem);    border: 0;    border-radius: calc(var(--file-radius) * var(--corner-scale));    corner-shape: var(--corner-shape);    /* The progress fill is finished by the sheet's own corner. */    overflow: clip;    background: var(--color-surface-raised);    color: var(--color-text-body);    box-shadow: var(--shadow-flat);    font: 400 .875rem/1.4 var(--font-family-sans);    font-feature-settings: inherit;    text-align: start;    text-decoration: none;    /* Failed: the edge says so before the words do. */    &.file-failed {      box-shadow: 0 0 0 .5px color-mix(in oklab, var(--color-danger) 60%, transparent), 0 0 0 3.5px color-mix(in oklab, var(--color-danger) 9%, transparent);      .file-meta {        color: var(--color-danger);      }    }    /* One line, 32px, as tall as a small button: an attachment above a composer, or a file named in a row. */    &.file-sm {      --file-padding: .25rem;      --file-radius: var(--radius-control-sm);      --file-page-width: 1rem;      --file-page-height: 1.25rem;      --file-fold: .3125rem;      display: inline-flex;      gap: .5rem;      max-width: 100%;      min-height: 2rem;      padding-inline: .625rem var(--file-padding);      font-size: .8125rem;      vertical-align: middle;      &:not(:has(.file-end)) {        padding-inline-end: .75rem;      }      .file-body {        display: flex;        align-items: baseline;        gap: .5rem;      }      /* The page is too small to carry its extension; the facts beside the name do. */      .file-page {        padding: 0;        color: transparent;        font-size: 0;      }      /* Its control is 24px, 4px inside the sheet, with the concentric corner. */      .btn {        --btn-height: 1.5rem;        --btn-radius: calc(var(--file-radius) - var(--file-padding) / var(--corner-scale));        --icon-size: .875rem;        min-height: var(--btn-height);        &.btn-square {          min-width: var(--btn-height);        }      }      /* A picture is the same 24px square at the other end, 4px in. */      .file-thumb {        width: 1.5rem;        height: 1.5rem;        margin-inline-start: calc(var(--file-padding) - .625rem);        border-radius: calc(var(--file-radius) * var(--corner-scale) - var(--file-padding));      }      /* On touch it is 44px, and its control keeps the 4px inset. */      @media (pointer: coarse) {        min-height: 2.75rem;        .btn {          --btn-height: 2.25rem;        }        .file-thumb {          width: 2.25rem;          height: 2.25rem;        }      }    }    /* A file that opens or downloads: a link or a button. It stands raised, lifts under the pointer and sinks when pressed. */    &.file-interactive {      --file-lift: calc(-1 * var(--lift));      @apply --surface-sheet;      box-shadow: var(--shadow-raised);      -webkit-tap-highlight-color: transparent;      appearance: none;      cursor: pointer;      transition-property: box-shadow, translate;      transition-duration: var(--duration-quick);      transition-timing-function: var(--easing-glide);      @media (hover: hover) {        &:hover {          translate: 0 var(--file-lift);          box-shadow: var(--shadow-raised-hover);        }      }      &:active {        translate: 0 0;        background: var(--color-surface-inset);        box-shadow: var(--shadow-pressed);        transition-duration: var(--duration-press);      }      &:focus-visible {        @apply --focus-ring;      }      @media (prefers-reduced-motion: reduce) {        --file-lift: 0px;      }    }    @media (forced-colors: active) {      outline: 1px solid CanvasText;      outline-offset: -1px;      &.file-failed {        outline-color: Mark;      }    }  }  /*   * The page: a small raised sheet with the extension at its foot. Its contour is one step firmer than a   * surface's, since it is paper on paper.   */  .file-page {    position: relative;    display: flex;    flex: none;    align-items: flex-end;    justify-content: center;    width: var(--file-page-width);    height: var(--file-page-height);    padding-bottom: .3125rem;    border-radius: calc(.1875rem * var(--corner-scale));    corner-shape: var(--corner-shape);    overflow: clip;    @apply --surface-sheet;    color: var(--color-text-muted);    box-shadow: inset 0 .5px 0 var(--color-highlight), 0 0 0 .5px var(--color-line-strong), 0 1px 2px -1px var(--color-shade-1), 0 2px 4px -2px var(--color-shade-2);    font: 600 .5rem/1 var(--font-family-sans);    font-feature-settings: inherit;    letter-spacing: .02em;    text-transform: uppercase;    user-select: none;    -webkit-user-select: none;    @supports (corner-shape: bevel) {      border-start-end-radius: var(--file-fold);      corner-start-end-shape: bevel;      /* The flap: a square in the folded corner, of which the bevel keeps the half that lies on the page. */      &::after {        content: '';        position: absolute;        inset-block-start: 0;        inset-inline-end: 0;        width: var(--file-fold);        height: var(--file-fold);        border-end-start-radius: 1.5px;        background: var(--color-surface-tray);        box-shadow: 0 0 0 .5px var(--color-line-strong), -1px 1px 2px -.5px var(--color-shade-1);      }    }    @media (forced-colors: active) {      outline: 1px solid CanvasText;      outline-offset: -1px;    }  }  /* A picture stands in for the page when the file is one. Its corner is the sheet's less the padding, and never under 3px. */  .file-thumb {    display: block;    flex: none;    width: 2.25rem;    height: 2.25rem;    border-radius: max(.1875rem, calc(var(--file-radius) * var(--corner-scale) - var(--file-padding)));    corner-shape: var(--corner-shape);    object-fit: cover;    background: var(--color-surface-chip);    outline: 1px solid var(--color-line-image);    outline-offset: -1px;  }  /* The name and the facts: 20px and 16px lines, together as tall as the page beside them. */  .file-body {    display: grid;    flex: 1 1 0;    min-width: 0;  }  .file-name {    margin: 0;    overflow: hidden;    color: var(--color-text-strong);    font-weight: var(--font-weight-control);    line-height: 1.25rem;    text-overflow: ellipsis;    white-space: nowrap;  }  /* Size, kind, how far the upload is, or why it failed. */  .file-meta {    margin: 0;    overflow: hidden;    color: var(--color-text-muted);    font-size: .78125rem;    font-variant-numeric: tabular-nums;    line-height: 1rem;    text-overflow: ellipsis;    white-space: nowrap;  }  /* One control at the end: remove, retry or download. */  .file-end {    display: inline-flex;    flex: none;    align-items: center;    gap: .25rem;  }  /*   * On a <progress class="progress">: the bar is the sheet itself. It lies under the words from edge to edge   * and fills with a chip tone, so an upload reads as the sheet being tinted from its start, and the sheet's   * own corner finishes the fill. Without a value a band of the same tone travels across.   */  .file-progress {    --progress-color: var(--color-surface-chip);    position: absolute;    inset: 0;    height: 100%;    border-radius: 0;    background: transparent;    box-shadow: none;    pointer-events: none;    &::-webkit-progress-value {      border-radius: 0;      box-shadow: .5px 0 0 var(--color-line-contour);    }    &::-moz-progress-bar {      border-radius: 0;    }    &:indeterminate {      background: linear-gradient(90deg, transparent, var(--progress-color) 30%, var(--progress-color) 70%, transparent) 0 0 / 40% 100% no-repeat;    }  }  /* What the sheet holds stands over the bar. */  .file-page, .file-thumb, .file-body, .file-end {    position: relative;    z-index: 1;  }}