Muhi Logo Text
AboutBlogWork With Me

Finally, a Custom File Upload that Works Everywhere!

A step-by-step guide to creating a custom and framework agnostic File Upload component

Last updated on July 19, 2021

javascript
web components
Custom File Upload

I love JavaScript and all the amazing engineers who helped to make Web Development simpler and more joyful. But, as a consultant and UI engineer, I always ask myself the same question “why do I have to write the same exact custom element every time I start a new project?” Whether it’s Vue, React, Angular… I always end up writing the same code again and again and again.

Web Components to the rescue!

Upload File Component

I’ve always been a big fan of Web Components since Polymer took the initiative to simplify and make it more declarative. The fact that you can write it once and use it anywhere is just so appealing to me as a UI architect. I’m not saying that Web Components is the way to go and will replace frameworks in the future. I just think it will coexist with other frameworks as a base layer for more generic custom elements such as buttons, checkboxes, file uploaders…etc. It is a perfect solution to build a design system that can be used across the whole organization, even if the tech stack is different.

OK, let’s cut to the chase…

talking john cage GIF

The reason why I’m writing this article is to show you how to create a custom file upload element in vanilla JavaScript that can be used in any library/framework. I decided for the first time to take the step and create a Web Component and successfully use it in multiple different real-world projects without having to write the same code ever again.

Initialize a Web Component

Open your favorite JavaScript editor or simply create a folder with just two Notepad files!

  • file-upload.js file for the File Upload Component.
  • main.html file to consume the web component.

Upload File Component

In file-upload.js, will initialize and define a custom element with a simple file upload element:

  • document.createElement enable us to create a template that will not yet be rendered in the DOM
  • In the constructor, we will call the super class since we are extending and inheriting from HTMLElement
  • this.attachShadow() attaches a shadow DOM tree to the specified element.
  • Finally, will append the template to the shadowRoot

// Create template
const template = document.createElement('template');
template.innerHTML = /*html*/`
  <style>
  </style>
  <input type="file" />
`;

class FileUpload extends HTMLElement {
  constructor() {
    // Inititialize custom component
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.appendChild(template.content.cloneNode(true));
  }
}

window.customElements.define('file-upload', FileUpload);

To understand Custom Elements, Shadow DOM and HTML Templates in more details, please visit MDN Web Docs

Now, we can import the component in main.html and use it.

<!DOCTYPE html>
<html>
    <script src="./file-upload.js"></script>
    <body>
        <file-upload></file-upload>
    </body>
</html>

So far nothing special, just a normal native-looking file upload element:

Upload File Component

Create a Custom File Upload

The easiest way to customize a native file input is to do the following steps:

  • Hide the file input using the hidden attribute.
  • Use a label element as a replacement with a for attribute to indicate which element you’d like to trigger
  • Finally, customize the label element however you like using CSS
// Create template
const template = document.createElement('template');
template.innerHTML = /*html*/`
  <style>
  </style>
  <article>
    <label for="fileUpload">Upload</label>
    <section>
      <span></span><button>Remove</button>
    </section>
  </article>
  <input hidden id="fileUpload" type="file" />
`;

class FileUpload extends HTMLElement {
  constructor() {
    // Inititialize custom component
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.appendChild(template.content.cloneNode(true));
  }
}

window.customElements.define('file-upload', FileUpload);

You’ll notice that we added a span and button elements. Those will be used in the next step to display and remove the currently uploaded file.

Implement event listeners

In this step, we will implement the logic associated with showing and removing the selected file:

  • The file input onchange event listener is used to update the selected file and display the proper name on the screen. Also, we are dispatching a custom event with the deleted file. The developer can subscribe and apply a custom logic if required
  • The delete button onclick event listener is used to delete the selected file and remove it from the DOM. Also, we are dispatching a custom event with the deleted file
  • We added a helper dispatch function and select getter to have a neater syntax.
// Create template
const template = document.createElement('template');
template.innerHTML = /*html*/`
  <style>
  </style>
  <article>
    <label for="fileUpload">Upload</label>
    <section hidden>
      <span></span><button>Remove</button>
    </section>
  </article>
  <input hidden id="fileUpload" type="file" />
`;

class FileUpload extends HTMLElement {
  constructor() {
    // Inititialize custom component
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.appendChild(template.content.cloneNode(true));
    
    // Add event listeners
    this.select('input').onchange = (e) => this.handleChange(e);
    this.select('button').onclick = () => this.handleRemove();
  }

  handleChange(e) {
      const file = e.target.files[0];
      this.select('section').style.display = "block";
      this.select('span').innerText = file.name;
      this.dispatch('change', file);
  }

  handleRemove() {
    const el = this.select('input');
    const file = el.files[0];
    el.value = "";
    this.select('section').style.display = "none";
    this.dispatch('change', file);
  }

  dispatch(event, arg) {
    this.dispatchEvent(new CustomEvent(event, {detail: arg}));
  }

  get select() {
      return this.shadowRoot.querySelector.bind(this.shadowRoot);
  }
}

window.customElements.define('file-upload', FileUpload);

If we run main.html, we should get the following results:

Upload File Component

Add default styling

Although the next step will cover how we can enable developers to apply custom styling, we would still need to offer a basic look and feel:

  • The label element will look like a default native button
  • The remove button will be replaced with an ‘X’ icon. We can use some of the built-in HTML characters
  • And the rest is just layout tweaks and improvements.
// Create template
const template = document.createElement('template');
template.innerHTML = /*html*/`
  <style>
      :host {
        font-size: 13px;
        font-family: arial;
      }
      article {
          display: flex;
          align-items: center;
      }
      label {
        background-color: rgb(239, 239, 239);
        border: 1px solid rgb(118, 118, 118);
        padding: 2px 6px 2px 6px;
        border-radius: 2px;
        margin-right: 5px;
      }
      button {
          border:0;
          background: transparent;
          cursor: pointer;
      }
      button::before {
          content: '\\2716';
      }
  </style>
  <article>
    <label for="fileUpload">Upload</label>
    <section hidden>
      <span></span><button></button>
    </section>
  </article>
  <input hidden id="fileUpload" type="file" />
`;

class FileUpload extends HTMLElement {
  constructor() {
    // Inititialize custom component
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.appendChild(template.content.cloneNode(true));
    
    // Add event listeners
    this.select('input').onchange = (e) => this.handleChange(e);
    this.select('button').onclick = () => this.handleRemove();
  }

  handleChange(e) {
      const file = e.target.files[0];
      this.select('section').style.display = "block";
      this.select('span').innerText = file.name;
      this.dispatch('change', file);
  }

  handleRemove() {
    const el = this.select('input');
    const file = el.files[0];
    el.value = "";
    this.select('section').style.display = "none";
    this.dispatch('change', file);
  }

  dispatch(event, arg) {
    this.dispatchEvent(new CustomEvent(event, {detail: arg}));
  }

  get select() {
      return this.shadowRoot.querySelector.bind(this.shadowRoot);
  }
}

window.customElements.define('file-upload', FileUpload);

image 7 1024x550

Enable custom styling

Since we are using the shadow DOM, the classes we defined are encapsulated and can’t be modified directly. The only way to enable custom styling is through CSS “part”, which allows developers to style CSS properties on an element inside of a shadow tree.

Below, we will add part="upload-button" to the label element to enable CSS customization:

// Create template
const template = document.createElement('template');
template.innerHTML = /*html*/`
  <style>
      :host {
        font-size: 13px;
        font-family: arial;
      }
      article {
          display: flex;
          align-items: center;
      }
      label {
        background-color: rgb(239, 239, 239);
        border: 1px solid rgb(118, 118, 118);
        padding: 2px 6px 2px 6px;
        border-radius: 2px;
        margin-right: 5px;
      }
      button {
          border:0;
          background: transparent;
          cursor: pointer;
      }
      button::before {
          content: '\\2716';
      }
  </style>
  <article>
    <label part="upload-button" for="fileUpload">Upload</label>
    <section hidden>
      <span></span><button></button>
    </section>
  </article>
  <input hidden id="fileUpload" type="file" />
`;

class FileUpload extends HTMLElement {
  constructor() {
    // Inititialize custom component
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.appendChild(template.content.cloneNode(true));
    
    // Add event listeners
    this.select('input').onchange = (e) => this.handleChange(e);
    this.select('button').onclick = () => this.handleRemove();
  }

  handleChange(e) {
      const file = e.target.files[0];
      this.select('section').style.display = "block";
      this.select('span').innerText = file.name;
      this.dispatch('change', file);
  }

  handleRemove() {
    const el = this.select('input');
    const file = el.files[0];
    el.value = "";
    this.select('section').style.display = "none";
    this.dispatch('change', file);
  }

  static get observedAttributes() {
    return ['upload-label'];
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'upload-label') {
        if (newValue && newValue !== '') {
            this.select('label').innerText = newValue;
        }
    }
  }

  dispatch(event, arg) {
    this.dispatchEvent(new CustomEvent(event, {detail: arg}));
  }

  get select() {
    return this.shadowRoot.querySelector.bind(this.shadowRoot);
  }
}

window.customElements.define('file-upload', FileUpload);

In the main.html file, we can test it and add custom styles to two different instances of the upload-file component:

<!DOCTYPE html>
<html>
    <script src="./file-upload.js"></script>
    <style>
        file-upload::part(upload-button) {
            border: none;
            color: white;
            padding: 9px 22px;
            font-size: 14px;
            margin-bottom: 10px;
        }
        .upload1::part(upload-button) {background-color: #4CAF50;} /* Green */
        .upload2::part(upload-button) {background-color: #555555;} /* Black */
    </style>
    <body>
        <file-upload class="upload1"></file-upload>
        <file-upload class="upload2"></file-upload>
    </body>
</html>

Upload File Component

Enable custom attributes

Just like Angular, Vue, or other libraries, we can pass props to a Web Component from the parent to trigger a certain custom behavior. In this example, we are adding a new upload-label attribute to allow the developer to change the text of the upload button.

  • observedAttributes getter will enable a watcher on specified attributes
  • attributeChangedCallback function will update the DOM with the new value
// Create template
const template = document.createElement('template');
template.innerHTML = /*html*/`
  <style>
      :host {
        font-size: 13px;
        font-family: arial;
      }
      article {
          display: flex;
          align-items: center;
      }
      label {
        background-color: rgb(239, 239, 239);
        border: 1px solid rgb(118, 118, 118);
        padding: 2px 6px 2px 6px;
        border-radius: 2px;
        margin-right: 5px;
      }
      button {
          border:0;
          background: transparent;
          cursor: pointer;
      }
      button::before {
          content: '\\2716';
      }
  </style>
  <article>
    <label part="upload-button" for="fileUpload">Upload</label>
    <section hidden>
      <span></span><button></button>
    </section>
  </article>
  <input hidden id="fileUpload" type="file" />
`;

class FileUpload extends HTMLElement {
  constructor() {
    // Inititialize custom component
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.appendChild(template.content.cloneNode(true));
    
    // Add event listeners
    this.select('input').onchange = (e) => this.handleChange(e);
    this.select('button').onclick = () => this.handleRemove();
  }

  handleChange(e) {
      const file = e.target.files[0];
      this.select('section').style.display = "block";
      this.select('span').innerText = file.name;
      this.dispatch('change', file);
  }

  handleRemove() {
    const el = this.select('input');
    const file = el.files[0];
    el.value = "";
    this.select('section').style.display = "none";
    this.dispatch('change', file);
  }

  static get observedAttributes() {
    return ['upload-label'];
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'upload-label') {
        if (newValue && newValue !== '') {
            this.select('label').innerText = newValue;
        }
    }
  }

  dispatch(event, arg) {
    this.dispatchEvent(new CustomEvent(event, {detail: arg}));
  }

  get select() {
    return this.shadowRoot.querySelector.bind(this.shadowRoot);
  }
}

window.customElements.define('file-upload', FileUpload);

Let’s update main.html file and try it out:

<!DOCTYPE html>
<html>
    <script src="./file-upload.js"></script>
    <style>
        file-upload::part(upload-button) {
            border: none;
            color: white;
            padding: 9px 22px;
            font-size: 14px;
            margin-bottom: 10px;
        }
        .upload1::part(upload-button) {background-color: #4CAF50;} /* Green */
        .upload2::part(upload-button) {background-color: #555555;} /* Black */
    </style>
    <body>
        <file-upload upload-label="Browse..." class="upload1"></file-upload>
        <file-upload upload-label="Upload Files" class="upload2"></file-upload>
    </body>
</html>

Upload File Component

Conclusion

In this article, I wanted to demonstrate how you can use vanilla JavaScript to write a custom and framework agnostic file uploader that works everywhere. In most cases, I would typically use libraries such as Lit or Stencil. They provide a more declarative style of writing Web Components which will scale better and speed up the development process. 

You can access the complete repository here.

Bye for now 👋

If you enjoyed this post, I regularly share similar content on Twitter. Follow me @muhimasri to stay up to date, or feel free to message me on Twitter if you have any questions or comments. I'm always open to discussing the topics I write about!

Recommended Reading

Learn how to replace multiple words and characters using regular expressions and replaceAll function in JavaScript

javascript

Discussion

Upskill Your Frontend Development Techniques 🌟

Subscribe to stay up-to-date and receive quality front-end development tutorials straight to your inbox!

No spam, sales, or ads. Unsubscribe anytime you wish.

© 2024, Muhi Masri