Dropzone

    @use 'sprucecss/scss/spruce' as *;
    
    .dropzone {
      @include layout-stack('s');
    
      &__input {
        @include visually-hidden;
      }
    
      &__area {
        align-items: center;
        background-color: color('surface-muted');
        border: 2px dashed color('border-strong');
        border-radius: radius('lg');
        cursor: pointer;
        display: flex;
        flex-direction: column;
        gap: spacer('xs');
        padding: spacer('l') spacer('m');
        text-align: center;
        @include transition($property: 'background-color, border-color');
    
        &:hover,
        &.is-dragging {
          background-color: color-alpha('primary', 0.06);
          border-color: color('primary');
        }
      }
    
      &__input:focus-visible + &__area {
        outline: 2px solid color('focus-ring');
        outline-offset: 2px;
      }
    
      &__icon {
        block-size: 2.5rem;
        color: color('primary');
        inline-size: 2.5rem;
      }
    
      &__title {
        color: color('heading');
    
        strong {
          color: color('primary');
        }
      }
    
      &__hint {
        color: color('muted');
        font-size: config('font-size-sm', $typography);
      }
    
      &__files {
        list-style: none;
        padding: 0;
        @include layout-stack('xs');
    
        &:empty {
          display: none;
        }
      }
    
      &__file {
        align-items: center;
        background-color: color('surface');
        border: config('border-width-base', $display) solid color('border');
        border-radius: radius('md');
        display: flex;
        gap: spacer('s');
        padding: spacer('xs') spacer('xs') spacer('xs') spacer('s');
      }
    
      &__name {
        color: color('heading');
        flex-grow: 1;
        min-inline-size: 0;
        @include text-ellipsis;
      }
    
      &__size {
        color: color('muted');
        flex-shrink: 0;
        font-size: config('font-size-sm', $typography);
      }
    }
    <div class="dropzone" data-dropzone>
        <input class="dropzone__input" id="dropzone-input" type="file" multiple accept="image/*,.pdf" data-dropzone-input>
        <label class="dropzone__area" for="dropzone-input" data-dropzone-area>
            <svg class="dropzone__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m17 8-5-5-5 5"/><path d="M12 3v12"/></svg>
            <span class="dropzone__title"><strong>Click to upload</strong> or drag and drop</span>
            <span class="dropzone__hint" id="dropzone-hint">Images or PDF, up to 10 MB each</span>
        </label>
        <ul class="dropzone__files" role="list" aria-label="Selected files" data-dropzone-files></ul>
        <p class="sr-only" aria-live="polite" data-dropzone-status></p>
    </div>
    (() => {
      const formatSize = (bytes) => (bytes < 1024 * 1024 ? `${Math.max(1, Math.round(bytes / 1024))} KB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`);
    
      document.querySelectorAll('[data-dropzone]').forEach((dropzone) => {
        const input = dropzone.querySelector('[data-dropzone-input]');
        const area = dropzone.querySelector('[data-dropzone-area]');
        const list = dropzone.querySelector('[data-dropzone-files]');
        const status = dropzone.querySelector('[data-dropzone-status]');
    
        const render = () => {
          list.replaceChildren(...[...input.files].map((file, index) => {
            const item = document.createElement('li');
            const name = document.createElement('span');
            const size = document.createElement('span');
            const remove = document.createElement('button');
    
            item.className = 'dropzone__file';
            name.className = 'dropzone__name';
            name.textContent = file.name;
            size.className = 'dropzone__size';
            size.textContent = formatSize(file.size);
            remove.className = 'btn btn--ghost-danger btn--sm';
            remove.type = 'button';
            remove.textContent = 'Remove';
            remove.setAttribute('aria-label', `Remove ${file.name}`);
            remove.addEventListener('click', () => {
              const transfer = new DataTransfer();
              [...input.files].forEach((other, otherIndex) => otherIndex !== index && transfer.items.add(other));
              input.files = transfer.files;
              status.textContent = `${file.name} removed.`;
              render();
              input.focus();
            });
    
            item.append(name, size, remove);
            return item;
          }));
        };
    
        input.addEventListener('change', () => {
          status.textContent = `${input.files.length} ${input.files.length === 1 ? 'file' : 'files'} selected.`;
          render();
        });
    
        ['dragenter', 'dragover'].forEach((type) => area.addEventListener(type, (event) => {
          event.preventDefault();
          area.classList.add('is-dragging');
        }));
    
        ['dragleave', 'drop'].forEach((type) => area.addEventListener(type, () => area.classList.remove('is-dragging')));
    
        area.addEventListener('drop', (event) => {
          event.preventDefault();
          const transfer = new DataTransfer();
          [...input.files, ...event.dataTransfer.files].forEach((file) => transfer.items.add(file));
          input.files = input.multiple ? transfer.files : event.dataTransfer.files;
          input.dispatchEvent(new Event('change'));
        });
      });
    })();

    Dropzone

    On this page

    A drag-and-drop file upload area with a list of the selected files.

    Technical Details

    • It is a real (visually hidden) input type="file" with its label as the drop area: clicking, keyboard focus (Enter/Space on the input) and native validation keep working. The focus ring is drawn on the area.
    • Dropped files are added to the input through a DataTransfer object, so they are submitted with the form like picked files.
    • The list shows the name and size of each file with a remove button, and a polite live region announces the changes.
    • accept and multiple work as usual; check the size and type on the server too.

    Documentation

    Learn about Spruce CSS through our extensive documentation.

    Components

    Explore our UI library of accessible components built with Spruce CSS.

    Blocks and Pages

    Full-width page blocks and complete page templates, ready to copy.

    Find us on GitHub

    Did you find a bug? Have an idea or a question? Please open an issue to help us develop the project.