File Group

Upload a product image

  • JPG or PNG, max. 5 MB
  • At least 1200 × 1200 pixels

product-photo.png

  • JPG or PNG, max. 5 MB
  • At least 1200 × 1200 pixels

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

.file-group-container {
  container: file-group-container / inline-size;
}

.file-group {
  $this: &;
  @include generate-variables($form-control, $include: ('border-width', 'border-radius'));
  align-items: center;
  border: config('border-width', $form-control) solid color('border', 'form');
  border-radius: config('border-radius', $form-control);
  display: flex;
  gap: spacer('m');
  overflow: hidden;
  padding: spacer('s');

  @container file-group-container (inline-size < 30rem) {
    flex-direction: column;
  }

  + .file-group,
  .form-group + .form-group & {
    margin-block-start: 0;
  }

  // An image is selected: show the remove button instead of the input.
  &:has([style*='background-image']) {
    #{$this}__remove {
      display: inline-flex;
    }

    #{$this}__input {
      display: none;
    }
  }

  &__preview {
    align-items: center;
    aspect-ratio: 1;
    background-color: color-alpha('primary', 0.08);
    background-position: center;
    background-size: cover;
    border-radius: config('border-radius', $form-control);
    display: flex;
    flex-shrink: 0;
    flex-wrap: wrap;
    inline-size: 9rem;
    justify-content: center;

    @container file-group-container (inline-size < 30rem) {
      aspect-ratio: 16 / 9;
      inline-size: 100%;
    }

    &[style*='background-image'] #{$this}__icon {
      display: none;
    }
  }

  &__body {
    inline-size: 100%;
    @include layout-stack('xxs');
  }

  &__icon {
    --size: 2rem;
    block-size: var(--size);
    color: color('primary');
    inline-size: var(--size);
  }

  &__title {
    color: color('heading');
    font-weight: 700;
  }

  &__meta {
    list-style: none;
    padding-inline-start: 0;

    > * + * {
      margin-block-start: 0;
    }
  }

  &__action {
    display: flex;
    flex-wrap: wrap;
    gap: spacer('xs');
    margin-block-start: spacer('xs');
  }

  &__remove {
    display: none;
  }

  &__input {
    flex: 1;
  }
}
<div class="form-group">
    <label class="form-label" for="product-image">Product image</label>
    <div class="file-group-container">
        <div class="file-group" data-file-group>
            <div class="file-group__preview" data-file-group-preview>
                <svg class="file-group__icon" aria-hidden="true" focusable="false" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg>
            </div>
            <div class="file-group__body">
                <p class="file-group__title" data-file-group-name>Upload a product image</p>
                <ul class="file-group__meta" id="product-image-help">
                    <li>JPG or PNG, max. 5 MB</li>
                    <li>At least 1200 × 1200 pixels</li>
                </ul>
                <div class="file-group__action">
                    <input class="form-file file-group__input" id="product-image" type="file" name="product-image" accept="image/png, image/jpeg" aria-describedby="product-image-help" data-file-group-input>
                    <button class="btn btn--soft-danger btn--sm file-group__remove" type="button" data-file-group-remove>
                        <svg class="btn__icon" aria-hidden="true" fill="none" focusable="false" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M18 6 6 18M6 6l12 12"/></svg>
                        Remove image
                    </button>
                </div>
            </div>
        </div>
    </div>
</div>
<div class="form-group">
    <label class="form-label" for="product-image-filled">Product image (with an image)</label>
    <div class="file-group-container">
        <div class="file-group" data-file-group>
            <div class="file-group__preview" style="background-image: url('/img/blog/about-modern-sass.png')" data-file-group-preview>
                <svg class="file-group__icon" aria-hidden="true" focusable="false" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg>
            </div>
            <div class="file-group__body">
                <p class="file-group__title" data-file-group-name>product-photo.png</p>
                <ul class="file-group__meta" id="product-image-filled-help">
                    <li>JPG or PNG, max. 5 MB</li>
                    <li>At least 1200 × 1200 pixels</li>
                </ul>
                <div class="file-group__action">
                    <input class="form-file file-group__input" id="product-image-filled" type="file" name="product-image-filled" accept="image/png, image/jpeg" aria-describedby="product-image-filled-help" data-file-group-input>
                    <button class="btn btn--soft-danger btn--sm file-group__remove" type="button" data-file-group-remove>
                        <svg class="btn__icon" aria-hidden="true" fill="none" focusable="false" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M18 6 6 18M6 6l12 12"/></svg>
                        Remove image
                    </button>
                </div>
            </div>
        </div>
    </div>
</div>
<p class="sr-only" aria-live="polite" data-file-group-status></p>
(() => {
  const status = document.querySelector('[data-file-group-status]');

  document.querySelectorAll('[data-file-group]').forEach((group) => {
    const input = group.querySelector('[data-file-group-input]');
    const preview = group.querySelector('[data-file-group-preview]');
    const name = group.querySelector('[data-file-group-name]');
    const remove = group.querySelector('[data-file-group-remove]');
    const emptyText = 'Upload a product image';
    let url = null;

    input.addEventListener('change', () => {
      const [file] = input.files;

      if (!file) return;

      // A local object URL shows the image right away, without uploading it.
      if (url) URL.revokeObjectURL(url);
      url = URL.createObjectURL(file);
      preview.style.backgroundImage = `url('${url}')`;
      name.textContent = file.name;
      status.textContent = `${file.name} selected.`;
      remove.focus();
    });

    remove.addEventListener('click', () => {
      if (url) URL.revokeObjectURL(url);
      url = null;
      input.value = '';
      preview.style.removeProperty('background-image');
      name.textContent = emptyText;
      status.textContent = 'Image removed.';
      // The input appears again; move the focus there.
      requestAnimationFrame(() => input.focus());
    });
  });
})();

File Group

A single image upload with a preview: the empty state shows an icon and the requirements, a selected image shows a thumbnail, its file name and a remove button.

Anatomy

  • .file-group-container creates a container query context; below 30rem, the preview goes above the text.
  • .file-group with the preview (__preview, an __icon when empty) and the body: __title, __meta (the requirements) and __action (the file input and the remove button).

Accessibility

  • The native file input keeps its label and keyboard support; the requirements are linked to it with aria-describedby, so they're read before choosing a file.
  • After choosing an image, the focus moves to the "Remove image" button (the input is hidden then), and after removing it, back to the input. A polite live region announces both.
  • The remove button has a visible text, not just an icon.

Technical Details

  • When the preview has a background-image (set inline, by your back end or by the script), :has() swaps the input for the remove button and hides the icon. So the server-rendered "has image" state needs no JavaScript.
  • The script previews the chosen file with URL.createObjectURL() (no upload, no FileReader) and revokes the URL when it changes.
  • For drag and drop and multiple files, use the Dropzone and the File List.

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.