File List

Gallery
  • pic1234.jpg
  • document5678.pdf
  • image5678.png

@use 'sprucecss/scss/spruce' as *;

.file-list {
  @include generate-variables($form-control, $include: ('border-width', 'border-radius'));
  display: flex;
  flex-direction: column;
  gap: spacer('s');

  &__items {
    @include clear-list;
    border: config('border-width', $form-control) solid color('border', 'form');
    border-radius: config('border-radius', $form-control);
    padding: spacer('xs');

    > li + li {
      border-block-start: config('border-width', $form-control) solid color('border');
      margin-block-start: spacer('xs');
      padding-block-start: spacer('xs');
    }
  }
}

.file-list-item {
  align-items: center;
  display: flex;
  gap: spacer('s');
  justify-content: space-between;

  &__icon {
    align-items: center;
    aspect-ratio: 1;
    background-color: color-alpha('primary', 0.08);
    border-radius: radius('sm');
    display: flex;
    inline-size: 2.5rem;
    justify-content: center;

    svg {
      --size: 1rem;
      block-size: var(--size);
      color: color('primary');
      inline-size: var(--size);
    }
  }

  &__column {
    align-items: center;
    display: flex;
    gap: spacer('s');
  }

  &__thumbnail {
    aspect-ratio: 1;
    border-radius: radius('sm');
    inline-size: 2.5rem;
    object-fit: cover;
  }

  &__name {
    @include text-ellipsis(1);
    max-inline-size: 10ch;

    @include breakpoint('xs') {
      max-inline-size: 25ch;
    }
  }

  &__actions {
    align-items: center;
    display: flex;
    gap: spacer('xxs');
  }
}
<div class="form-group">
    <span class="form-label" id="gallery-label">Gallery</span>
    <div class="file-list" data-file-list>
        <button type="button" class="btn btn--primary btn--block">Choose images</button>
        <ul class="file-list__items" aria-labelledby="gallery-label">
            <li class="file-list-item">
                <div class="file-list-item__column">
                    <img class="file-list-item__thumbnail" src="https://picsum.photos/80/80" alt=""/>
                    <span class="file-list-item__name" id="list-item-1">pic1234.jpg</span>
                </div>
                <div class="file-list-item__actions">
                    <button type="button" class="btn btn--light btn--sm btn--icon" aria-describedby="list-item-1" aria-label="Move up" data-file-list-move="-1" disabled>
                        <svg class="btn__icon" aria-hidden="true" fill="none" focusable="false" height="24" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" stroke="currentColor" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
                            <polyline points="18 15 12 9 6 15"></polyline>
                        </svg>
                    </button>
                    <button type="button" class="btn btn--light btn--sm btn--icon" aria-describedby="list-item-1" aria-label="Move down" data-file-list-move="1">
                        <svg class="btn__icon" aria-hidden="true" fill="none" focusable="false" height="24" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" stroke="currentColor" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
                            <polyline points="6 9 12 15 18 9"></polyline>
                        </svg>
                    </button>
                    <button type="button" class="btn btn--soft-danger btn--sm btn--icon" aria-describedby="list-item-1" aria-label="Remove" data-file-list-remove>
                        <svg class="btn__icon" aria-hidden="true" fill="none" focusable="false" height="24" stroke-linecap="round" stroke-linejoin="round" stroke-width="3.5" stroke="currentColor" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
                            <line x1="18" y1="6" x2="6" y2="18"></line>
                            <line x1="6" y1="6" x2="18" y2="18"></line>
                        </svg>
                    </button>
                </div>
            </li>
            <li class="file-list-item">
                <div class="file-list-item__column">
                    <span class="file-list-item__icon">
                        <svg aria-hidden="true" fill="none" focusable="false" height="24" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" stroke="currentColor" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
                            <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
                            <polyline points="14 2 14 8 20 8"></polyline>
                            <line x1="16" y1="13" x2="8" y2="13"></line>
                            <line x1="16" y1="17" x2="8" y2="17"></line>
                            <polyline points="10 9 9 9 8 9"></polyline>
                        </svg>
                    </span>
                    <span class="file-list-item__name" id="list-item-2">document5678.pdf</span>
                </div>
                <div class="file-list-item__actions">
                    <button type="button" class="btn btn--light btn--sm btn--icon" aria-describedby="list-item-2" aria-label="Move up" data-file-list-move="-1">
                        <svg class="btn__icon" aria-hidden="true" fill="none" focusable="false" height="24" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" stroke="currentColor" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
                            <polyline points="18 15 12 9 6 15"></polyline>
                        </svg>
                    </button>
                    <button type="button" class="btn btn--light btn--sm btn--icon" aria-describedby="list-item-2" aria-label="Move down" data-file-list-move="1">
                        <svg class="btn__icon" aria-hidden="true" fill="none" focusable="false" height="24" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" stroke="currentColor" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
                            <polyline points="6 9 12 15 18 9"></polyline>
                        </svg>
                    </button>
                    <button type="button" class="btn btn--soft-danger btn--sm btn--icon" aria-describedby="list-item-2" aria-label="Remove" data-file-list-remove>
                        <svg class="btn__icon" aria-hidden="true" fill="none" focusable="false" height="24" stroke-linecap="round" stroke-linejoin="round" stroke-width="3.5" stroke="currentColor" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
                            <line x1="18" y1="6" x2="6" y2="18"></line>
                            <line x1="6" y1="6" x2="18" y2="18"></line>
                        </svg>
                    </button>
                </div>
            </li>
            <li class="file-list-item">
                <div class="file-list-item__column">
                    <img class="file-list-item__thumbnail" src="https://picsum.photos/81/81" alt=""/>
                    <span class="file-list-item__name" id="list-item-3">image5678.png</span>
                </div>
                <div class="file-list-item__actions">
                    <button type="button" class="btn btn--light btn--sm btn--icon" aria-describedby="list-item-3" aria-label="Move up" data-file-list-move="-1">
                        <svg class="btn__icon" aria-hidden="true" fill="none" focusable="false" height="24" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" stroke="currentColor" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
                            <polyline points="18 15 12 9 6 15"></polyline>
                        </svg>
                    </button>
                    <button type="button" class="btn btn--light btn--sm btn--icon" aria-describedby="list-item-3" aria-label="Move down" data-file-list-move="1" disabled>
                        <svg class="btn__icon" aria-hidden="true" fill="none" focusable="false" height="24" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" stroke="currentColor" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
                            <polyline points="6 9 12 15 18 9"></polyline>
                        </svg>
                    </button>
                    <button type="button" class="btn btn--soft-danger btn--sm btn--icon" aria-describedby="list-item-3" aria-label="Remove" data-file-list-remove>
                        <svg class="btn__icon" aria-hidden="true" fill="none" focusable="false" height="24" stroke-linecap="round" stroke-linejoin="round" stroke-width="3.5" stroke="currentColor" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
                            <line x1="18" y1="6" x2="6" y2="18"></line>
                            <line x1="6" y1="6" x2="18" y2="18"></line>
                        </svg>
                    </button>
                </div>
            </li>
        </ul>
        <p class="sr-only" aria-live="polite" data-file-list-status></p>
    </div>
</div>
document.querySelectorAll('[data-file-list]').forEach((fileList) => {
  const list = fileList.querySelector('ul');
  const status = fileList.querySelector('[data-file-list-status]');
  const nameOf = (item) => item.querySelector('.file-list-item__name').textContent.trim();

  // The first item can't move up, the last can't move down.
  const update = () => {
    const items = [...list.children];

    items.forEach((item, index) => {
      item.querySelector('[data-file-list-move="-1"]').disabled = index === 0;
      item.querySelector('[data-file-list-move="1"]').disabled = index === items.length - 1;
    });
  };

  list.addEventListener('click', (event) => {
    const button = event.target.closest('button');
    const item = button?.closest('li');

    if (!item) return;

    if (button.hasAttribute('data-file-list-remove')) {
      const next = item.nextElementSibling ?? item.previousElementSibling;

      item.remove();
      status.textContent = `${nameOf(item)} removed.`;
      next?.querySelector('[data-file-list-remove]').focus();
    } else if (button.dataset.fileListMove) {
      const up = button.dataset.fileListMove === '-1';

      up ? item.previousElementSibling.before(item) : item.nextElementSibling.after(item);
      update();

      // Keep the focus on the same button; if it got disabled at the edge, use the other one.
      (button.disabled ? item.querySelector(`[data-file-list-move="${up ? 1 : -1}"]`) : button).focus();
      status.textContent = `${nameOf(item)} moved to position ${[...list.children].indexOf(item) + 1} of ${list.children.length}.`;
    }

    update();
  });

  update();
});

File List

A list of selected files for multi-file fields (galleries, attachments): thumbnails and names, with buttons to reorder and remove each file.

Anatomy

  • .file-list holds the "Choose images" button and the list (__items), labelled by the field label.
  • .file-list-item has a thumbnail (__thumbnail) or an icon (__icon), the file name (__name, truncated with an ellipsis) and the actions (__actions).

Accessibility

  • Every action button has a short name ("Move up", "Remove") and is described by the file name (aria-describedby), so a screen reader says "Remove, pic1234.jpg".
  • Reordering with buttons works for everyone, unlike drag and drop alone. After a move, the focus stays on the button (or switches to the other arrow at the edge), and a live region announces the new position: "pic1234.jpg moved to position 2 of 3."
  • After a removal, the focus moves to the next file's remove button.
  • The first item can't move up and the last can't move down; those buttons are disabled.

Technical Details

  • The script only manages the DOM; sync the order and the removals with your form data (like hidden inputs or a DataTransfer) or your API.
  • The colors come from the tokens; the old media color group isn't needed. The remove buttons use the btn--soft-danger variant of Spruce.
  • Combine it with the Dropzone for drag-and-drop selection.

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.