How to Create a Custom File Input with jQuery, CSS3 and PHP

We all know that file inputs are very limited in terms of customization and while there are many convoluted plugins with dozens of fallbacks that let you customize them, it's still a challenge sometimes to get it working. This tutorial will guide you through the process of building a jQuery plugin to replace that ugly looking input with support for multiple files and a simple fallback for old browsers, ahem, IE9-8.

How to create a Custom File Input with jQuery, CSS3 and PHP


How to Create a Custom File Input with jQuery, CSS3 and PHP

Setting up the Project

First let's create a folder customFile and 3 files, jquery.customFile.js, jquery.customFile.css and customFile.html. To get started quickly, grab this HTML template and copy/paste it into your project.

Now that we have everything that we need let's open our HTML file and add a container and a file input with its label:

<div class="customfile-container">
  <label>File: </label>
  <input type="file" id="file" name="myfiles[]" multiple />
</div>

Also make sure to give it an id and an array name, such as myfiles[] so the server can retrieve all filenames with the IE fallback that will be covered later.

Next open jquery.customFile.js and setup a basic jQuery plugin boilerplate:

;(function( $ ) {

  $.fn.customFile = function() {

    return this.each(function() {

      var $file = $(this).addClass('customfile'); // the original file input

      // code here

    });

  };

}( jQuery ));

Finally, call the plugin in your markup:

<script>$('input[type=file]').customFile()</script>

How it Works

To build the custom replacement we'll need a simple markup structure:

Concept

Clicking the "open" button will trigger a "click" event on the original file input. After choosing a file the original input triggers a "change" event, where we'll set the value of the input by accessing the file array if the file API is supported, or by accessing the original value otherwise.

Building the Plugin

First we need to test the browser for multiple support. The easiest way is to create an input and check if it has a multiple property available otherwise the browser doesn't support multiple files. We also need to check if the browser is IE for some fixes later on. This code can be moved outside of the plugin since it doesn't depend on the element itself.

// Browser supports HTML5 multiple file?
var multipleSupport = typeof $('<input/>')[0].multiple !== 'undefined',
    isIE = /msie/i.test( navigator.userAgent ); // simple but not super secure...

Now let's create the elements needed for the replacement. IE has tough security measures that prevent the filename from being retrieved if the input is triggered externally so we'll be using a label instead of a button. By triggering the event on the label we can work around this issue.

var $wrap = $('<div class="customfile-wrap">'),
    $input = $('<input type="text" class="customfile-filename" />'),
    $button = $('<button type="button" class="customfile-upload">Open</button>');
    $label = $('<label class="customfile-upload" for="'+ $file[0].id +'">Open</label>');

The type="button" attribute is needed for consistency, to prevent some browsers from submitting the form.

Next, let's "get rid" of the original input. Instead of hiding it, let's remove from the viewport by shifting it to the left, that way we can still use it even if it's not visible; this is useful to trigger events that may be problematic if the input is literally hidden.

$file.css({
  position: 'absolute',
  left: '-9999px'
});

Finally let's append our new elements to the DOM:

$wrap.insertAfter( $file ).append( $file, $input, ( isIE ? $label : $button ) );

At this point you should have something that looks like this in a decent browser; we'll take care of IE later.

Step 1

Attaching the Events

The very first thing we need to do is to prevent the original input from gaining focus, as well as the newly created button. Only the text input should be able to receive focus.

$file.attr('tabIndex', -1);
$button.attr('tabIndex', -1);

Let's trigger the click event on the button to open the dialog. In IE, since there's no real button, the label should already trigger the dialog without extra work.

$button.click(function () {
  $file.focus().click(); // Open dialog
});

The focus event needs to be triggered on some browsers so the click event works properly. If you try clicking "open" in your browser at this point it should open the file dialog.

Now we can use the change event that is triggered after choosing a file to fill the value of the text input with the chosen file(s).

$file.change(function() {

    var files = [], fileArr, filename;

    // If multiple is supported then extract
    // all filenames from the file array
    if ( multipleSupport ) {
      fileArr = $file[0].files;
      for ( var i = 0, len = fileArr.length; i < len; i++ ) {
        files.push( fileArr[i].name );
      }
      filename = files.join(', ');

    // If not supported then take the value
    // and remove the path to show only the filename
    } else {
      filename = $file.val().split('\\').pop();
    }

    $input.val( filename ) // Set the value
      .attr('title', filename) // Show filename in title tootlip
      .focus(); // Regain focus

});

Improving Usability

Everything is working well so far but there are some things that we can do to improve the usability and overall behavior of our new replacement.

  • Trigger the blur event on the original input when the replacement loses focus.
  • Open the dialog when user presses "enter" on the text input except IE (due to security limitations it won't work).
  • Delete files with "backspace" or "delete", otherwise the user is forced to open the dialog and press "cancel" to clear the input.

In other words:

$input.on({
  blur: function() { $file.trigger('blur'); },
  keydown: function( e ) {
    if ( e.which === 13 ) { // Enter
      if ( !isIE ) { $file.trigger('click'); }
    } else if ( e.which === 8 || e.which === 46 ) { // Backspace & Del
      // On some browsers the value is read-only
      // with this trick we remove the old input and add
      // a clean clone with all the original events attached
      $file.replaceWith( $file = $file.clone( true ) );
      $file.trigger('change');
      $input.val('');
    } else if ( e.which === 9 ){ // TAB
      return;
    } else { // All other keys
      return false;
    }
  }
});

Fallback for Old Browsers

The easiest fallback to allow multiple files is to create multiple inputs. When a file is chosen we create a new input and when the input is cleared, we remove it.

The following code goes after the plugin since it's meant to be applied to all custom file inputs. Here we need to use on to delegate the event for future inputs that don't exist yet.

if ( !multipleSupport ) {
  $( document ).on('change', 'input.customfile', function() {

    var $this = $(this),
        // Create a unique ID so we
        // can attach the label to the input
        uniqId = 'customfile_'+ (new Date()).getTime();
        $wrap = $this.parent(),

        // Filter empty input
        $empty = $wrap.siblings().find('.customfile-filename')
          .filter(function(){ return !this.value }),

        $file = $('<input type="file" id="'+ uniqId +'" name="'+ $this.attr('name') +'"/>');

    // 1ms timeout so it runs after all other events
    // that modify the value have triggered
    setTimeout(function() {
      // Add a new input
      if ( $this.val() ) {
        // Check for empty field to prevent
        // creating new inputs when changing files
        if ( !$empty.length ) {
          $wrap.after( $file );
          $file.customFile();
        }
      // Remove and reorganize inputs
      } else {
        $empty.parent().remove();
        // Move the input so it's always last on the list
        $wrap.appendTo( $wrap.parent() );
        $wrap.find('input').focus();
      }
    }, 1);

  });
}

Customizing the Appearance

Everything must be working at this point so let's spice it up with some styles:

/* It's easier to calculate widths 
 * with border-box layout */
.customfile-container * {
  box-sizing: border-box;
  -moz-box-sizing: border-box;
  -webkit-box-sizing: border-box;
  font: normal 14px Arial, sans-serif; /* Global font to use ems with precision */
}

.customfile-container {
  width: 300px;
  background: #FFF2B8;
  padding: 1em;
}

.customfile-container label:first-child {
  width: 100px;
  display: block;
  margin-bottom: .5em;
  font: bold 18px Arial, sans-serif;
  color: #333;
}

.customfile-wrap {
  position: relative;
  padding: 0;
  margin-bottom: .5em;
}

.customfile-filename,
.customfile-upload { 
  margin: 0;
  padding: 0;
}

.customfile-filename {
  width: 230px;
  padding: .4em .5em;
  border: 1px solid #A8A49D;
  border-radius: 2px 0 0 2px;
  box-shadow: inset 0 1px 2px rgba(0,0,0,.2);
}
.customfile-filename:focus { 
  outline: none;
}

.customfile-upload {
  display: inline-block;
  width: 70px;
  padding: .4em 1em;
  border: 1px solid #A8A49D;
  background: #ddd;
  border-radius: 0 2px 2px 0;
  margin-left: -1px; /* align with input */
  cursor: pointer;
  background: #fcfff4;
  background: -moz-linear-gradient(top, #fcfff4 0%, #e9e9ce 100%);
  background: -webkit-linear-gradient(top, #fcfff4 0%, #e9e9ce 100%);
  background: -o-linear-gradient(top, #fcfff4 0%, #e9e9ce 100%);
  background: -ms-linear-gradient(top, #fcfff4 0%, #e9e9ce 100%);
  background: linear-gradient(to bottom, #fcfff4 0%, #e9e9ce 100%);
}

.customfile-upload:hover {
  background: #fafafa;
  box-shadow: 0 0 2px rgba(0,0,0,.2);
}
.customfile-upload::-moz-focus-inner { /* Fix firefox padding */
  padding: 0; border: 0;
}

Go ahead and customize the CSS to create your own look.

Retrieving the Files on the Server

First, wrap your input in a form and add a submit button:

<form action="test.php" method="post" enctype="multipart/form-data">
  <div class="customfile-container">
    <label>File: </label>
    <input type="file" id="file" name="myfiles[]" multiple />
  </div>
<button type="submit">Submit</button>
</form>

Then, we can get all the filenames and print them out in test.php:

<?php
$files = $_POST['myfiles']; // Array containing all files
echo implode( $files, '<br/>' );

Since we're using an array name myfiles[], the server will retrieve all files even when the fallback is being used. You can read more about this in the PHP manual on Uploading Multiple Files.

The plugin has been tested on IE9-8 and all modern browsers. Grab the full code or play with the demo below.

Conclusion

Without much effort file inputs are fairly easy to customize. The fallback is obviously not ideal but it works and it's simple to maintain without introducing hundreds of lines of code, or other technologies like Flash, Silverlight, etc...

If you have any suggestions please leave a comment below.