diff --git a/core/modules/media/config/schema/media.schema.yml b/core/modules/media/config/schema/media.schema.yml
index b9156b23f8..d0da71496b 100644
--- a/core/modules/media/config/schema/media.schema.yml
+++ b/core/modules/media/config/schema/media.schema.yml
@@ -66,3 +66,11 @@ media.source.field_aware:
     source_field:
       type: string
       label: 'Source field'
+
+field.widget.settings.media_upload:
+  type: mapping
+  label: 'Media upload field display format settings'
+  mapping:
+    thumbnail_image_style:
+      type: string
+      label: 'Thumbnail image style'
diff --git a/core/modules/media/css/widget.css b/core/modules/media/css/widget.css
new file mode 100644
index 0000000000..4cdd0c074c
--- /dev/null
+++ b/core/modules/media/css/widget.css
@@ -0,0 +1,13 @@
+/**
+ * @file
+ * Tweaks for Media field widgets.
+ */
+
+.media-upload-widget table td:nth-child(2) {
+  display: flex;
+  align-items: center;
+}
+
+.media-upload-widget table a {
+  margin: 0 1rem 0 1rem;
+}
diff --git a/core/modules/media/js/commands.es6.js b/core/modules/media/js/commands.es6.js
new file mode 100644
index 0000000000..d302a047b6
--- /dev/null
+++ b/core/modules/media/js/commands.es6.js
@@ -0,0 +1,52 @@
+/**
+ *
+ * @file
+ * Defines AJAX commands used by the Media module.
+ */
+
+(function ($, Drupal) {
+
+  /**
+   * Ajax command to open the Media Bulk Upload form in a modal.
+   *
+   * @param {Drupal.Ajax} ajax
+   *   {@link Drupal.Ajax} object created by {@link Drupal.ajax}.
+   * @param {object} response
+   *   JSON response from the Ajax request.
+   * @param {number} [status]
+   *   XMLHttpRequest status.
+   */
+  Drupal.AjaxCommands.prototype.openMediaUploadModal = function (ajax, response, status) {
+    new Drupal.Ajax(null, null, {
+      url: response.url,
+      dialogType: 'modal',
+      dialog: response.dialogOptions,
+      progress: {
+        type: 'throbber'
+      }
+    }).execute().done((data, textStatus, jqXHR) => {
+      // Warn the user when closing the dialog.
+      $('#drupal-modal').on('dialogbeforeclose', (event, ui) => {
+        if (event.originalEvent) {
+          return confirm(Drupal.t("Closing this modal will cancel the upload process.\n\nAre you sure you want to close?"));
+        }
+      });
+    });
+  };
+
+  /**
+   * Ajax command to add Media items to a field widget.
+   *
+   * @param {Drupal.Ajax} ajax
+   *   {@link Drupal.Ajax} object created by {@link Drupal.ajax}.
+   * @param {object} response
+   *   JSON response from the Ajax request.
+   * @param {number} [status]
+   *   XMLHttpRequest status.
+   */
+  Drupal.AjaxCommands.prototype.addMediaToWidget = function (ajax, response, status) {
+    $(`[data-media-widget-value="${response.identifier}"]`).val(response.mids.join(','));
+    $(`[data-media-widget-update="${response.identifier}"]`).trigger('mousedown');
+  };
+
+}(jQuery, Drupal));
diff --git a/core/modules/media/js/commands.js b/core/modules/media/js/commands.js
new file mode 100644
index 0000000000..672d646bd8
--- /dev/null
+++ b/core/modules/media/js/commands.js
@@ -0,0 +1,30 @@
+/**
+* DO NOT EDIT THIS FILE.
+* See the following change record for more information,
+* https://www.drupal.org/node/2815083
+* @preserve
+**/
+
+(function ($, Drupal) {
+  Drupal.AjaxCommands.prototype.openMediaUploadModal = function (ajax, response, status) {
+    new Drupal.Ajax(null, null, {
+      url: response.url,
+      dialogType: 'modal',
+      dialog: response.dialogOptions,
+      progress: {
+        type: 'throbber'
+      }
+    }).execute().done(function (data, textStatus, jqXHR) {
+      $('#drupal-modal').on('dialogbeforeclose', function (event, ui) {
+        if (event.originalEvent) {
+          return confirm(Drupal.t("Closing this modal will cancel the upload process.\n\nAre you sure you want to close?"));
+        }
+      });
+    });
+  };
+
+  Drupal.AjaxCommands.prototype.addMediaToWidget = function (ajax, response, status) {
+    $('[data-media-widget-value="' + response.identifier + '"]').val(response.mids.join(','));
+    $('[data-media-widget-update="' + response.identifier + '"]').trigger('mousedown');
+  };
+})(jQuery, Drupal);
\ No newline at end of file
diff --git a/core/modules/media/media.libraries.yml b/core/modules/media/media.libraries.yml
index 72496a3233..73bc2acfeb 100644
--- a/core/modules/media/media.libraries.yml
+++ b/core/modules/media/media.libraries.yml
@@ -11,3 +11,18 @@ type_form:
     js/type_form.js: {}
   dependencies:
     - core/drupal.form
+
+commands:
+  version: VERSION
+  js:
+    js/commands.js: {}
+  dependencies:
+    - core/drupal.dialog.ajax
+
+widget:
+  version: VERSION
+  css:
+    theme:
+      css/widget.css: {}
+  dependencies:
+    - media/commands
diff --git a/core/modules/media/media.links.action.yml b/core/modules/media/media.links.action.yml
index e76ab06b73..01882bfb19 100644
--- a/core/modules/media/media.links.action.yml
+++ b/core/modules/media/media.links.action.yml
@@ -10,3 +10,10 @@ media.add:
   weight: 10
   appears_on:
     - entity.media.collection
+
+media.bulk_upload:
+  route_name: media.bulk_upload
+  title: 'Bulk upload media'
+  weight: 20
+  appears_on:
+    - entity.media.collection
diff --git a/core/modules/media/media.routing.yml b/core/modules/media/media.routing.yml
index 9fbadeff29..367b7b6b9e 100644
--- a/core/modules/media/media.routing.yml
+++ b/core/modules/media/media.routing.yml
@@ -5,6 +5,14 @@ entity.media.multiple_delete_confirm:
   requirements:
     _permission: 'administer media+delete any media'
 
+media.bulk_upload:
+  path: '/admin/content/media/upload'
+  defaults:
+    _form: '\Drupal\media\Form\MediaBulkUploadForm'
+    _title: 'Bulk Upload Media'
+  requirements:
+    _permission: 'create media'
+
 entity.media.revision:
   path: '/media/{media}/revisions/{media_revision}/view'
   defaults:
diff --git a/core/modules/media/src/Ajax/AddMediaToWidget.php b/core/modules/media/src/Ajax/AddMediaToWidget.php
new file mode 100644
index 0000000000..79b76eef1f
--- /dev/null
+++ b/core/modules/media/src/Ajax/AddMediaToWidget.php
@@ -0,0 +1,61 @@
+<?php
+
+namespace Drupal\media\Ajax;
+
+use Drupal\Core\Ajax\CommandInterface;
+
+/**
+ * Ajax command to add Media items to a field widget.
+ *
+ * This widget enables communication between Media forms, usually between a
+ * form that creates or selects media, and a field widget.
+ *
+ * The identifier property is used to select two elements on the field widget:
+ *   - [data-media-widget-value="<identifier>"]
+ *       An input element that will store new MIDs.
+ *   - [data-media-widget-update="<identifier>"]
+ *       An AJAX button that will be clicked to inform the widget of an update.
+ *
+ * @ingroup ajax
+ */
+class AddMediaToWidget implements CommandInterface {
+
+  /**
+   * Media IDs to pass to a field widget.
+   *
+   * @var array
+   */
+  protected $mids;
+
+  /**
+   * An identifier for the field widget.
+   *
+   * @var string
+   */
+  protected $identifier;
+
+  /**
+   * AddMediaToWidget constructor.
+   *
+   * @param array $mids
+   *   Media IDs to pass to a field widget.
+   * @param string $identifier
+   *   An identifier for the field widget.
+   */
+  public function __construct(array $mids, $identifier) {
+    $this->mids = $mids;
+    $this->identifier = $identifier;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function render() {
+    return [
+      'command' => 'addMediaToWidget',
+      'mids' => $this->mids,
+      'identifier' => $this->identifier,
+    ];
+  }
+
+}
diff --git a/core/modules/media/src/Ajax/OpenMediaUploadModal.php b/core/modules/media/src/Ajax/OpenMediaUploadModal.php
new file mode 100644
index 0000000000..cc25ec30e5
--- /dev/null
+++ b/core/modules/media/src/Ajax/OpenMediaUploadModal.php
@@ -0,0 +1,75 @@
+<?php
+
+namespace Drupal\media\Ajax;
+
+use Drupal\Core\Ajax\CommandInterface;
+use Drupal\Core\Url;
+
+/**
+ * Ajax command to open the Media Bulk Upload form in a modal.
+ *
+ * @ingroup ajax
+ */
+
+class OpenMediaUploadModal implements CommandInterface {
+
+  /**
+   * The URL to open in the modal dialog.
+   *
+   * @var string
+   */
+  protected $url;
+
+  /**
+   * Additional client-side options for the modal dialog.
+   *
+   * @var array
+   */
+  protected $dialogOptions = [
+    'width' => '70%',
+  ];
+
+  /**
+   * OpenMediaUploadModal constructor.
+   *
+   * @param array $fids
+   *   (optional) An array of File IDs, which skips the normal upload step.
+   * @param array $target_bundles
+   *   (optional) An array of Media Type IDs, to restrict creation.
+   * @param string $modal_return_id
+   *   (optional) A string used to identify where Media IDs should be returned.
+   * @param array $dialog_options
+   *   (optional) Additional client-side options for the modal dialog.
+   */
+  public function __construct(array $fids = NULL, array $target_bundles = NULL, $modal_return_id = NULL, $dialog_options = []) {
+    $query = [];
+    if ($fids) {
+      $query['fids'] = $fids;
+    }
+    if ($target_bundles) {
+      $query['target_bundles'] = $target_bundles;
+    }
+    if ($modal_return_id) {
+      $query['modal_return_id'] = $modal_return_id;
+    }
+    $url = Url::fromRoute('media.bulk_upload', [], [
+      'query' => $query,
+    ]);
+    $this->url = $url->toString();
+    if ($dialog_options) {
+      $this->dialogOptions = array_merge($this->dialogOptions, $dialog_options);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function render() {
+    return [
+      'command' => 'openMediaUploadModal',
+      'url' => $this->url,
+      'dialogOptions' => $this->dialogOptions,
+    ];
+  }
+
+}
diff --git a/core/modules/media/src/Form/MediaBulkUploadForm.php b/core/modules/media/src/Form/MediaBulkUploadForm.php
new file mode 100644
index 0000000000..1a77b866a7
--- /dev/null
+++ b/core/modules/media/src/Form/MediaBulkUploadForm.php
@@ -0,0 +1,525 @@
+<?php
+
+namespace Drupal\media\Form;
+
+use Drupal\Core\Ajax\AjaxResponse;
+use Drupal\Core\Ajax\CloseModalDialogCommand;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Form\FormBase;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Render\ElementInfoManagerInterface;
+use Drupal\Core\Url;
+use Drupal\file\Entity\File;
+use Drupal\file\FileInterface;
+use Drupal\media\Ajax\AddMediaToWidget;
+use Drupal\media\Entity\MediaType;
+use Drupal\media\MediaInterface;
+use Drupal\media\MediaTypeInterface;
+use Drupal\media\MediaUploadTrait;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * An AJAX multi-step form to bulk create media entities.
+ *
+ * The following query parameters can change the behavior of this form:
+ *   - fids[]
+ *       An array of File IDs, which skips the normal upload step.
+ *   - target_bundles[]
+ *       An array of Media Type IDs, to restrict creation.
+ *   - modal_return_id
+ *       If opened in a modal, this string can be used to identify fields where
+ *       media IDs should be stored after the bulk upload is complete.
+ */
+class MediaBulkUploadForm extends FormBase {
+
+  use MediaUploadTrait;
+
+  /**
+   * The element info manager.
+   *
+   * @var \Drupal\Core\Render\ElementInfoManagerInterface
+   */
+  protected $elementInfo;
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function __construct(EntityTypeManagerInterface $entityTypeManager, ElementInfoManagerInterface $element_info) {
+    $this->entityTypeManager = $entityTypeManager;
+    $this->elementInfo = $element_info;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('entity_type.manager'),
+      $container->get('element_info')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getFormId() {
+    return 'media_bulk_upload_form';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildForm(array $form, FormStateInterface $form_state) {
+    // Every AJAX callback updates the entire form, so we need to wrap it.
+    $form['#prefix'] = '<div id="media-build-upload-form-wrapper">';
+    $form['#suffix'] = '</div>';
+
+    // If initial File IDs have been passed, we can skip the upload step.
+    $query = $this->getRequest()->query;
+    if (($fids = $query->get('fids')) && empty($form_state->get('total_files'))) {
+      $form_state->setValue('upload', $fids);
+      self::uploadButtonSubmit($form, $form_state);
+    }
+
+    // Get all media types the current user can create.
+    $media_type_storage = $this->entityTypeManager->getStorage('media_type');
+    /** @var \Drupal\media\MediaTypeInterface[] $types */
+    $types = $media_type_storage->loadMultiple($query->get('target_bundles'));
+    $types = $this->filterTypesWithFileSource($types);
+    $types = $this->filterTypesWithCreateAccess($types);
+
+    // This case is fairly rare and specific, so we show an informative message
+    // instead of returning a generic "Access Denied".
+    if (count($types) === 0) {
+      $form = [
+        '#markup' => $this->t('<p>You do not have access to create media that uses files.</p>'),
+      ];
+      return $form;
+    }
+
+    // If only one type accepts the current file, skip the type selection.
+    if ($form_state->get('step') === 'select_media') {
+      $file = $form_state->get('current_file');
+      $valid_types = $this->filterTypesThatAcceptFile($file, $types);
+      if (count($valid_types) === 1) {
+        self::mediaTypeSelectSubmit($form, $form_state, reset($valid_types));
+      }
+    }
+
+    $form['progress'] = $this->getFormProgress($form_state);
+    // Determine what step of the form we're on.
+    switch ($form_state->get('step')) {
+      case NULL:
+        $form = $this->buildUploadForm($form, $form_state, $types);
+        break;
+
+      case 'select_media':
+        $form = $this->buildMediaTypeSelectionForm($form, $form_state, $types);
+        break;
+
+      case 'show_media_form':
+        $form = $this->buildMediaForm($form, $form_state);
+        break;
+
+      case 'finished':
+        $form = $this->buildFinishedForm($form, $form_state);
+        break;
+    }
+    return $form;
+  }
+
+  /**
+   * Returns a progress bar representing the form process.
+   *
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The form state.
+   *
+   * @return array
+   *   A render array representing the form progress.
+   */
+  protected function getFormProgress(FormStateInterface $form_state) {
+    $element = [];
+    $total = $form_state->get('total_files');
+    if ($total > 1 && $form_state->get('step') !== 'finished') {
+      $files = $form_state->get('files');
+      $remaining = ($total - count($files));
+      $element = [
+        '#theme' => 'progress_bar',
+        '#label' => $this->t('Processing @remaining of @total files', [
+          '@remaining' => $remaining,
+          '@total' => $total,
+        ]),
+        '#percent' => floor((($remaining - 1) / $total) * 100),
+      ];
+    }
+    return $element;
+  }
+
+  /**
+   * Builds a form for selecting a media type for the next uploaded file.
+   *
+   * @param array $form
+   *   The form render array.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The form state.
+   * @param \Drupal\media\MediaTypeInterface[] $types
+   *   Available Media Types.
+   *
+   * @return array
+   *   The form render array.
+   */
+  protected function buildUploadForm(array $form, FormStateInterface $form_state, array $types) {
+    $element_info = $this->elementInfo->getInfo('managed_file');
+    $upload_validators = $this->mergeUploadValidators($types);
+    $form['upload'] = [
+      '#type' => 'managed_file',
+      '#process' => array_merge($element_info['#process'], [[static::class, 'processUpload']]),
+      '#upload_validators' => $upload_validators,
+      '#multiple' => TRUE,
+    ];
+    $form['upload_help'] = [
+      '#theme' => 'file_upload_help',
+      '#description' => $this->t('Upload files here to start the bulk media creation process.'),
+      '#upload_validators' => $upload_validators,
+    ];
+    return $form;
+  }
+
+  /**
+   * Processes an upload (managed_file) element.
+   *
+   * @param array $element
+   *   The upload element.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The form state.
+   *
+   * @return array
+   *   The processed upload element.
+   */
+  public static function processUpload(array $element, FormStateInterface $form_state) {
+    $element['upload_button']['#submit'] = [[static::class, 'uploadButtonSubmit']];
+    $element['upload_button']['#ajax'] = [
+      'callback' => [static::class, 'updateFormCallback'],
+      'wrapper' => 'media-build-upload-form-wrapper',
+    ];
+    // Hide the Managed File element's table display - we don't use it.
+    $element['remove_button']['#access'] = FALSE;
+    foreach ($element['#value']['fids'] as $fid) {
+      if (isset($element['file_' . $fid])) {
+        $element['file_' . $fid]['#access'] = FALSE;
+      }
+    }
+    return $element;
+  }
+
+  /**
+   * Builds a form for selecting a media type for the next uploaded file.
+   *
+   * @param array $form
+   *   The form render array.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The form state.
+   * @param \Drupal\media\MediaTypeInterface[] $types
+   *   Available media types.
+   *
+   * @return array
+   *   The form render array.
+   */
+  protected function buildMediaTypeSelectionForm(array $form, FormStateInterface $form_state, array $types) {
+    /** @var \Drupal\file\FileInterface $file */
+    $file = $form_state->get('current_file');
+    $valid_types = $this->filterTypesThatAcceptFile($file, $types);
+    $form['title'] = [
+      '#markup' => t('<p>Select media type to create with <strong>@file</strong></p>', [
+        '@file' => $file->label(),
+      ]),
+    ];
+    foreach ($valid_types as $type) {
+      $form[] = [
+        '#type' => 'submit',
+        '#name' => $type->id(),
+        '#value' => $type->label(),
+        '#submit' => [[static::class, 'mediaTypeSelectSubmit']],
+        '#ajax' => [
+          'callback' => [static::class, 'updateFormCallback'],
+          'wrapper' => 'media-build-upload-form-wrapper',
+        ],
+      ];
+    }
+    return $form;
+  }
+
+  /**
+   * Builds a form for selecting a media type for the next uploaded file.
+   *
+   * @param array $form
+   *   The form render array.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The form state.
+   *
+   * @return array
+   *   The form render array.
+   */
+  protected function buildMediaForm(array $form, FormStateInterface $form_state) {
+    /** @var \Drupal\Core\Entity\EntityFormInterface $media_form */
+    $media_form = $form_state->get('media_form');
+    if (!empty($form_state->get('files'))) {
+      $save_value = $this->t('Save and continue');
+    }
+    else {
+      $save_value = $this->t('Save and finish');
+    }
+    $form['subform'] = $media_form->buildForm([], $form_state);
+    $form['subform']['actions']['submit'] = [
+      '#type' => 'submit',
+      '#value' => $save_value,
+      '#submit' => [[static::class, 'mediaFormSubmit']],
+      '#ajax' => [
+        'callback' => [static::class, 'mediaFormSubmitCallback'],
+        'wrapper' => 'media-build-upload-form-wrapper',
+      ],
+    ];
+    // \Drupal\Core\Entity\EntityForm::processForm is unneeded as the entity in
+    // this form is never uncached.
+    unset($form['subform']['#process'][0]);
+    // The entity in this form is always new, this entity builder is unneeded.
+    unset($form['subform']['#entity_builders']['update_form_langcode']);
+    return $form;
+  }
+
+  /**
+   * Submit handler for the media form.
+   *
+   * @param array $form
+   *   The form render array.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The form state.
+   */
+  public static function mediaFormSubmit(array &$form, FormStateInterface $form_state) {
+    $files = $form_state->get('files');
+    $media_items = $form_state->get('media_items') ?: [];
+    /** @var \Drupal\Core\Entity\EntityFormInterface $media_form */
+    $media_form = $form_state->get('media_form');
+    // Submit the media form instance and save the new media.
+    $media_form->submitForm($form, $form_state);
+    $media = $media_form->getEntity();
+    $media->save();
+    $media_items[] = $media;
+    $form_state->set('media_items', $media_items);
+    // Determine if there are more files to process.
+    if (empty($files)) {
+      $form_state->set('step', 'finished');
+    }
+    else {
+      $form_state->set('current_file', array_shift($files));
+      $form_state->set('files', $files);
+      $form_state->set('step', 'select_media');
+    }
+    $form_state->setRebuild();
+  }
+
+  /**
+   * Submit handler for the media type select button.
+   *
+   * @param array $form
+   *   The form render array.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The form state.
+   * @param \Drupal\media\MediaTypeInterface $type
+   *   (optional) A pre-selected media type, if available.
+   */
+  public static function mediaTypeSelectSubmit(array &$form, FormStateInterface $form_state, $type = NULL) {
+    if (!$type) {
+      $media_type = $form_state->getTriggeringElement()['#name'];
+      $type = MediaType::load($media_type);
+    }
+    /** @var \Drupal\file\FileInterface $file */
+    $file = $form_state->get('current_file');
+    $form_state->set('step', 'show_media_form');
+    $form_state->set('media_type', $type);
+    // Prepare the media form.
+    /** @var self $form_object */
+    $form_object = $form_state->getFormObject();
+    $media_form = $form_object->createMediaForm($file, $type);
+    $storage = $form_state->getStorage();
+    // Remove all unknown values from form state, to ensure a clean media form.
+    $storage = array_intersect_key($storage, array_flip([
+      'total_files',
+      'current_file',
+      'files',
+      'step',
+      'media_type',
+      'media_items',
+    ]));
+    $form_state->setStorage($storage);
+    $input = $form_state->getUserInput();
+    $input = array_intersect_key($input, array_flip([
+      'form_build_id',
+      'form_token',
+      'form_id',
+      'ajax_page_state',
+      '_drupal_ajax',
+      '_triggering_element_name',
+      '_triggering_element_value',
+    ]));
+    $form_state->setUserInput($input);
+    $form_state->set('media_form', $media_form);
+
+    $form_state->setRebuild();
+  }
+
+  /**
+   * Creates a media form object for a newly uploaded file.
+   *
+   * @param \Drupal\file\FileInterface $file
+   *   A newly uploaded file.
+   * @param \Drupal\media\MediaTypeInterface $type
+   *   A media Type.
+   *
+   * @return \Drupal\Core\Entity\EntityFormInterface
+   *   A media form object.
+   */
+  public function createMediaForm(FileInterface $file, MediaTypeInterface $type) {
+    // Move the temporary file to the correct destination.
+    $location = $this->getUploadLocationForType($type);
+    file_prepare_directory($location, FILE_CREATE_DIRECTORY);
+    file_move($file, $location);
+    $media_form = $this->entityTypeManager->getFormObject('media', 'add');
+    /** @var \Drupal\media\MediaInterface $media */
+    $media = $this->entityTypeManager->getStorage('media')->create([
+      'bundle' => $type->id(),
+    ]);
+    $source_field = $type->getSource()->getSourceFieldDefinition($type);
+    $media->set($source_field->getName(), $file->id());
+    $media_form->setEntity($media);
+    return $media_form;
+  }
+
+  /**
+   * Submit handler for the upload button, inside the managed_file element.
+   *
+   * @param array $form
+   *   The form render array.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The form state.
+   */
+  public static function uploadButtonSubmit(array $form, FormStateInterface $form_state) {
+    $fids = $form_state->getValue('upload', []);
+    // Prepare form state storage to be used in future steps.
+    $files = File::loadMultiple($fids);
+    $form_state->set('total_files', count($fids));
+    $form_state->set('current_file', array_shift($files));
+    $form_state->set('files', $files);
+    $form_state->set('step', 'select_media');
+
+    $form_state->setRebuild();
+  }
+
+  /**
+   * Builds the confirmation page of the form.
+   *
+   * @param array $form
+   *   The form render array.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The form state.
+   *
+   * @return array
+   *   The form render array.
+   */
+  protected function buildFinishedForm(array $form, FormStateInterface $form_state) {
+    /** @var \Drupal\media\MediaInterface[] $media_items */
+    $media_items = $form_state->get('media_items');
+    $form['header'] = [
+      '#markup' => $this->formatPlural(count($media_items),
+        '<h3>One media item created:</h3>',
+        '<h3>@count media items created:</h3>'),
+    ];
+    $form['list'] = [
+      '#theme' => 'item_list',
+      '#items' => [],
+    ];
+    foreach ($media_items as $item) {
+      $form['list']['#items'][] = $item->toLink()->toRenderable();
+    }
+    $form['actions']['#type'] = 'actions';
+    $form['actions']['return'] = [
+      '#type' => 'link',
+      '#attributes' => ['class' => ['button']],
+      '#url' => Url::fromRoute('entity.media.collection'),
+      '#title' => $this->t('Return to media listing'),
+    ];
+    $form['actions']['upload'] = [
+      '#type' => 'link',
+      '#attributes' => ['class' => ['button', 'button--primary']],
+      '#url' => Url::fromRoute('media.bulk_upload'),
+      '#title' => $this->t('Upload more'),
+    ];
+    return $form;
+  }
+
+  /**
+   * AJAX callback for refreshing the entire form.
+   *
+   * @param array $form
+   *   The form render array.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The form state.
+   *
+   * @return array
+   *   The form render array.
+   */
+  public static function updateFormCallback(array &$form, FormStateInterface $form_state) {
+    return $form;
+  }
+
+  /**
+   * AJAX command that either refreshes the form, or closes the parent modal.
+   *
+   * @param array $form
+   *   The form render array.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The form state.
+   *
+   * @return array|\Drupal\Core\Ajax\AjaxResponse
+   *   The form render array, or an AJAX response to return media IDs to a
+   *   form element.
+   */
+  public static function mediaFormSubmitCallback(array &$form, FormStateInterface $form_state) {
+    if ($form_state->get('step') === 'finished' && $identifier = \Drupal::request()->query->get('modal_return_id')) {
+      $ids = array_map(function (MediaInterface $media) {
+        return $media->id();
+      }, $form_state->get('media_items'));
+      $response = new AjaxResponse();
+      $response->addCommand(new AddMediaToWidget($ids, $identifier));
+      $response->addCommand(new CloseModalDialogCommand());
+      return $response;
+    }
+    else {
+      return self::updateFormCallback($form, $form_state);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validateForm(array &$form, FormStateInterface $form_state) {
+    if ($form_state->get('step') === 'show_media_form') {
+      /** @var \Drupal\Core\Entity\EntityFormInterface $media_form */
+      $media_form = $form_state->get('media_form');
+      $media_form->validateForm($form['subform'], $form_state);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state) {}
+
+}
diff --git a/core/modules/media/src/MediaUploadTrait.php b/core/modules/media/src/MediaUploadTrait.php
new file mode 100644
index 0000000000..61d1c45c66
--- /dev/null
+++ b/core/modules/media/src/MediaUploadTrait.php
@@ -0,0 +1,154 @@
+<?php
+
+namespace Drupal\media;
+
+use Drupal\file\FileInterface;
+use Drupal\Core\Field\TypedData\FieldItemDataDefinition;
+use Drupal\file\Plugin\Field\FieldType\FileItem;
+
+/**
+ * Provides generic methods to support media file uploads.
+ */
+trait MediaUploadTrait {
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * Filters media types that accept a given file.
+   *
+   * @param \Drupal\file\FileInterface $file
+   *   A File Entity.
+   * @param \Drupal\media\MediaTypeInterface[] $types
+   *   An array of available media types.
+   *
+   * @return \Drupal\media\MediaTypeInterface[]
+   *   An array of media types that accept the file.
+   */
+  protected function filterTypesThatAcceptFile(FileInterface $file, array $types) {
+    $valid_types = [];
+    $types = $this->filterTypesWithFileSource($types);
+    foreach ($types as $type) {
+      $validators = $this->getUploadValidatorsForType($type);
+      $errors = file_validate($file, $validators);
+      if (empty($errors)) {
+        $valid_types[] = $type;
+      }
+    }
+    return $valid_types;
+  }
+
+  /**
+   * Filters an array of media types that accept file sources.
+   *
+   * @param \Drupal\media\MediaTypeInterface[] $types
+   *   An array of media types.
+   *
+   * @return \Drupal\media\MediaTypeInterface[]
+   *   An array of media types that accept file sources.
+   */
+  protected function filterTypesWithFileSource(array $types) {
+    $valid_types = [];
+    foreach ($types as $type) {
+      $source = $type->getSource();
+      if (is_a($source, 'Drupal\media\Plugin\media\Source\File')) {
+        $valid_types[] = $type;
+      }
+    }
+    return $valid_types;
+  }
+
+  /**
+   * Merges file upload validators for an array of media types.
+   *
+   * @param \Drupal\media\MediaTypeInterface[] $types
+   *   An array of media types.
+   *
+   * @return array
+   *   An array suitable for passing to file_save_upload() or the file field
+   *   element's '#upload_validators' property.
+   */
+  protected function mergeUploadValidators(array $types) {
+    $max_size = 0;
+    $extensions = [];
+    $types = $this->filterTypesWithFileSource($types);
+    foreach ($types as $type) {
+      $validators = $this->getUploadValidatorsForType($type);
+      $max_size = max($max_size, $validators['file_validate_size'][0]);
+      $extensions = array_unique(array_merge($extensions, explode(' ', $validators['file_validate_extensions'][0])));
+    }
+    return [
+      'file_validate_extensions' => [implode(' ', $extensions)],
+      'file_validate_size' => [$max_size],
+    ];
+  }
+
+  /**
+   * Gets upload validators for a given Media Type.
+   *
+   * @param \Drupal\media\MediaTypeInterface $type
+   *   A Media Type.
+   *
+   * @return array
+   *   An array suitable for passing to file_save_upload() or the file field
+   *   element's '#upload_validators' property.
+   */
+  protected function getUploadValidatorsForType(MediaTypeInterface $type) {
+    $file_item = $this->getFileItemForType($type);
+    return $file_item->getUploadValidators();
+  }
+
+  /**
+   * Gets upload destination for a given Media Type.
+   *
+   * @param \Drupal\media\MediaTypeInterface $type
+   *   A Media Type.
+   *
+   * @return string
+   *   An unsanitized file directory URI with tokens replaced.
+   */
+  protected function getUploadLocationForType(MediaTypeInterface $type) {
+    $file_item = $this->getFileItemForType($type);
+    return $file_item->getUploadLocation();
+  }
+
+  /**
+   * Creates a file item for a given Media Type.
+   *
+   * @param \Drupal\media\MediaTypeInterface $type
+   *   A Media Type.
+   *
+   * @return \Drupal\file\Plugin\Field\FieldType\FileItem
+   *   The file item.
+   */
+  protected function getFileItemForType(MediaTypeInterface $type) {
+    $source = $type->getSource();
+    $source_data_definition = FieldItemDataDefinition::create($source->getSourceFieldDefinition($type));
+    return new FileItem($source_data_definition);
+  }
+
+  /**
+   * Filters an array of media types that can be created by the current user.
+   *
+   * @param \Drupal\media\MediaTypeInterface[] $types
+   *   An array of media types.
+   *
+   * @return \Drupal\media\MediaTypeInterface[]
+   *   An array of media types that accept file sources.
+   */
+  protected function filterTypesWithCreateAccess(array $types) {
+    $valid_types = [];
+    $access_handler = $this->entityTypeManager->getAccessControlHandler('media');
+    foreach ($types as $type) {
+      if ($access_handler->createAccess($type->id())) {
+        $valid_types[] = $type;
+      }
+    }
+    return $valid_types;
+  }
+
+}
diff --git a/core/modules/media/src/Plugin/Field/FieldWidget/MediaUploadWidget.php b/core/modules/media/src/Plugin/Field/FieldWidget/MediaUploadWidget.php
new file mode 100644
index 0000000000..d7a2e071ff
--- /dev/null
+++ b/core/modules/media/src/Plugin/Field/FieldWidget/MediaUploadWidget.php
@@ -0,0 +1,564 @@
+<?php
+
+namespace Drupal\media\Plugin\Field\FieldWidget;
+
+use Drupal\Component\Utility\NestedArray;
+use Drupal\Component\Utility\SortArray;
+use Drupal\Core\Ajax\AjaxResponse;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Field\FieldDefinitionInterface;
+use Drupal\Core\Field\FieldItemListInterface;
+use Drupal\Core\Field\FieldStorageDefinitionInterface;
+use Drupal\Core\Field\WidgetBase;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\Core\Render\ElementInfoManagerInterface;
+use Drupal\media\Ajax\AddMediaToWidget;
+use Drupal\media\Ajax\OpenMediaUploadModal;
+use Drupal\media\Entity\MediaType;
+use Drupal\media\MediaUploadTrait;
+use Drupal\media\Entity\Media;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Plugin implementation of the 'media_upload' widget.
+ *
+ * @FieldWidget(
+ *   id = "media_upload",
+ *   label = @Translation("Media upload"),
+ *   field_types = {
+ *     "entity_reference"
+ *   }
+ * )
+ */
+class MediaUploadWidget extends WidgetBase implements ContainerFactoryPluginInterface {
+
+  use MediaUploadTrait;
+
+  /**
+   * The element info manager.
+   *
+   * @var \Drupal\Core\Render\ElementInfoManagerInterface
+   */
+  protected $elementInfo;
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function __construct($plugin_id, $plugin_definition, FieldDefinitionInterface $field_definition, array $settings, array $third_party_settings, EntityTypeManagerInterface $entityTypeManager, ElementInfoManagerInterface $element_info) {
+    parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $third_party_settings);
+    $this->entityTypeManager = $entityTypeManager;
+    $this->elementInfo = $element_info;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+    return new static(
+      $plugin_id,
+      $plugin_definition,
+      $configuration['field_definition'],
+      $configuration['settings'],
+      $configuration['third_party_settings'],
+      $container->get('entity_type.manager'),
+      $container->get('element_info')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function defaultSettings() {
+    return [
+      'thumbnail_image_style' => 'thumbnail',
+    ] + parent::defaultSettings();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function settingsForm(array $form, FormStateInterface $form_state) {
+    $element = parent::settingsForm($form, $form_state);
+
+    $element['thumbnail_image_style'] = [
+      '#title' => $this->t('Thumbnail image style'),
+      '#type' => 'select',
+      '#options' => image_style_options(FALSE),
+      '#empty_option' => '<' . $this->t('no thumbnail') . '>',
+      '#default_value' => $this->getSetting('thumbnail_image_style'),
+      '#description' => $this->t('The media thumbnail image style used in the widget.'),
+      '#weight' => 15,
+    ];
+
+    return $element;
+  }
+
+  /**
+   * {@inheritdoc}
+   *
+   * The image style logic is a copied from the image_image widget.
+   */
+  public function settingsSummary() {
+    $summary = parent::settingsSummary();
+
+    $image_styles = image_style_options(FALSE);
+    // Unset possible 'No defined styles' option.
+    unset($image_styles['']);
+    // Styles could be lost because of enabled/disabled modules that defines
+    // their styles in code.
+    $image_style_setting = $this->getSetting('thumbnail_image_style');
+    if (isset($image_styles[$image_style_setting])) {
+      $preview_image_style = $this->t('Thumbnail image style: @style', ['@style' => $image_styles[$image_style_setting]]);
+    }
+    else {
+      $preview_image_style = $this->t('No thumbnail');
+    }
+
+    array_unshift($summary, $preview_image_style);
+
+    return $summary;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function formMultipleElements(FieldItemListInterface $items, array &$form, FormStateInterface $form_state) {
+    $field_name = $this->fieldDefinition->getName();
+    $handler_settings = $this->fieldDefinition->getSetting('handler_settings');
+    /** @var \Drupal\media\MediaTypeInterface[] $types */
+    $types = MediaType::loadMultiple($handler_settings['target_bundles']);
+    $create_types = $this->filterTypesWithFileSource($types);
+    $create_types = $this->filterTypesWithCreateAccess($create_types);
+
+    // Load the items for form rebuilds from the field state as they might not
+    // be in $form_state->getValues() because of validation limitations. Also,
+    // they are only passed in as $items when editing existing entities.
+    $field_state = static::getWidgetState($form['#parents'], $field_name, $form_state);
+    if (isset($field_state['items'])) {
+      $items->setValue($field_state['items']);
+    }
+    else {
+      $field_state['items'] = $items->getValue();
+      $field_state['items_count'] = count($field_state['items']);
+      static::setWidgetState($form['#parents'], $field_name, $form_state, $field_state);
+    }
+
+    $cardinality = $this->fieldDefinition->getFieldStorageDefinition()->getCardinality();
+    $cardinality_remaining = $cardinality;
+    $cardinality_unlimited = $cardinality === FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED;
+    if (!$cardinality_unlimited) {
+      $cardinality_remaining = $cardinality - $field_state['items_count'];
+    }
+
+    $elements = [
+      '#type' => 'fieldset',
+      '#title' => $this->fieldDefinition->getLabel(),
+      '#attributes' => [
+        'id' => $this->getFieldWrapperId($field_name),
+        'class' => ['media-upload-widget'],
+      ],
+      '#attached' => [
+        'library' => [
+          'media/widget',
+        ],
+      ],
+      '#target_bundles' => $handler_settings['target_bundles'],
+    ];
+    if (($cardinality_unlimited || $cardinality_remaining > 0) && count($create_types) > 0) {
+      $upload_validators = $this->mergeUploadValidators($create_types);
+
+      $element_info = $this->elementInfo->getInfo('managed_file');
+      $elements['upload'] = [
+        '#type' => 'managed_file',
+        '#title' => $this->t('Upload new media'),
+        '#process' => array_merge($element_info['#process'], [
+          [static::class, 'processUpload'],
+        ]),
+        '#element_validate' => [[static::class, 'validateUploadCardinality']],
+        '#upload_validators' => $upload_validators,
+        '#multiple' => $cardinality_unlimited || $cardinality_remaining > 1,
+        '#wrapper_id' => $this->getFieldWrapperId($field_name),
+        '#cardinality' => $cardinality_remaining,
+      ];
+      $elements['upload_help'] = [
+        '#theme' => 'file_upload_help',
+        '#description' => $this->t('Upload files here to create new media.'),
+        '#upload_validators' => $upload_validators,
+        '#cardinality' => $cardinality_remaining,
+      ];
+      // @todo Replace this with a button to open the media library.
+      $elements['use_existing'] = [
+        '#type' => 'entity_autocomplete',
+        '#title' => $this->t('Use existing media'),
+        '#target_type' => $this->getFieldSetting('target_type'),
+        '#selection_handler' => $this->getFieldSetting('handler'),
+        '#selection_settings' => $handler_settings,
+        // Entity reference field items are handling validation themselves via
+        // the 'ValidReference' constraint.
+        '#validate_reference' => FALSE,
+        '#size' => 60,
+        '#placeholder' => $this->t('Search...'),
+        '#ajax' => [
+          'callback' => [static::class, 'useExistingCallback'],
+          'wrapper' => $this->getFieldWrapperId($field_name),
+          'event' => 'autocompleteclose',
+        ],
+      ];
+    }
+    else {
+      $elements['upload_help'] = [
+        '#markup' => $this->t('The limit of @count media has been reached.', [
+          '@count' => $cardinality,
+        ]),
+      ];
+    }
+    $elements['selection'] = [
+      '#type' => 'textfield',
+      '#attributes' => [
+        'class' => ['visually-hidden'],
+        'data-media-widget-value' => $field_name,
+      ],
+    ];
+    $elements['update'] = [
+      '#type' => 'submit',
+      '#value' => $this->t('Update widget'),
+      '#ajax' => [
+        'callback' => [static::class, 'updateWidget'],
+        'wrapper' => $this->getFieldWrapperId($field_name),
+      ],
+      '#attributes' => [
+        'data-media-widget-update' => $field_name,
+        'class' => ['visually-hidden'],
+      ],
+      '#submit' => [[static::class, 'updateItems']],
+      '#limit_validation_errors' => [array_merge($form['#parents'], [$field_name])],
+    ];
+    $elements['table'] = parent::formMultipleElements($items, $form, $form_state);
+    if (isset($elements['table']['add_more'])) {
+      unset($elements['table']['add_more']);
+    }
+    return $elements;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) {
+    $field_name = $this->fieldDefinition->getName();
+
+    /** @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $items */
+    $entities = $items->referencedEntities();
+    if (isset($entities[$delta])) {
+      /** @var \Drupal\media\MediaInterface $media */
+      $media = $entities[$delta];
+      $element += [
+        'target_id' => [
+          '#type' => 'value',
+          '#value' => $media->id(),
+        ],
+        'thumbnail' => [
+          '#theme' => 'image_style',
+          '#style_name' => $this->getSetting('thumbnail_image_style'),
+          '#uri' => $media->getSource()->getMetadata($media, 'thumbnail_uri'),
+          '#access' => !empty($this->getSetting('thumbnail_image_style')),
+        ],
+        'label' => $media->toLink(NULL, 'canonical', [
+          'attributes' => ['target' => '_blank'],
+        ])->toRenderable(),
+        'remove' => [
+          '#type' => 'submit',
+          '#name' => 'media-upload-widget-' . $field_name . '-remove-' . $delta,
+          '#value' => $this->t('Remove'),
+          '#delta' => $delta,
+          '#ajax' => [
+            'callback' => [static::class, 'updateWidget'],
+            'wrapper' => $this->getFieldWrapperId($field_name),
+          ],
+          '#submit' => [[static::class, 'removeItem']],
+          '#limit_validation_errors' => [array_merge($form['#parents'], [$field_name])],
+        ],
+      ];
+      return $element;
+    }
+    return [];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function isApplicable(FieldDefinitionInterface $field_definition) {
+    if ($field_definition->getSetting('target_type') !== 'media') {
+      return FALSE;
+    }
+
+    // If at least one target bundle accepts files, this widget can be used.
+    $handler_settings = $field_definition->getSetting('handler_settings');
+    foreach ($handler_settings['target_bundles'] as $bundle) {
+      $media_type = MediaType::load($bundle);
+      if ($media_type) {
+        $source = $media_type->getSource();
+        if (is_a($source, '\Drupal\media\Plugin\media\Source\File')) {
+          return TRUE;
+        }
+      }
+    }
+
+    return FALSE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function extractFormValues(FieldItemListInterface $items, array $form, FormStateInterface $form_state) {
+    $field_name = $this->fieldDefinition->getName();
+
+    // Extract the values from $form_state->getValues().
+    $path = array_merge($form['#parents'], [$field_name, 'table']);
+    $values = NestedArray::getValue($form_state->getValues(), $path, $key_exists);
+    $values = $values ?: [];
+
+    usort($values, function ($a, $b) {
+      return SortArray::sortByKeyInt($a, $b, '_weight');
+    });
+
+    // Let the widget massage the submitted values.
+    $values = $this->massageFormValues($values, $form, $form_state);
+
+    // Assign the values and remove the empty ones.
+    $items->setValue($values);
+    $items->filterEmptyItems();
+  }
+
+  /**
+   * Removes an item from the table.
+   *
+   * @param array $form
+   *   The form array.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The form state.
+   */
+  public static function removeItem(array $form, FormStateInterface $form_state) {
+    $button = $form_state->getTriggeringElement();
+
+    $element = NestedArray::getValue($form, array_slice($button['#array_parents'], 0, -3));
+    $field_name = $element['#field_name'];
+    $parents = $element['#field_parents'];
+    $field_state = static::getWidgetState($parents, $field_name, $form_state);
+
+    $path = array_merge($form['#parents'], [$field_name, 'table']);
+    $values = NestedArray::getValue($form_state->getValues(), $path);
+
+    if (isset($values[$button['#delta']])) {
+      unset($values[$button['#delta']]);
+    }
+
+    $values = array_values($values);
+
+    $field_state['items'] = $values;
+    $field_state['items_count'] = count($field_state['items']);
+    static::setWidgetState($parents, $field_name, $form_state, $field_state);
+
+    $form_state->setRebuild();
+  }
+
+  /**
+   * AJAX callback to update the entire form widget.
+   *
+   * @param array $form
+   *   The form render array.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The form state.
+   *
+   * @return array
+   *   The element render array.
+   */
+  public static function updateWidget(array &$form, FormStateInterface $form_state) {
+    $button = $form_state->getTriggeringElement();
+    $form_state->setRebuild();
+    $depth = -1;
+    if (strpos($button['#name'], 'remove') !== FALSE) {
+      $depth = -3;
+    }
+    $element = NestedArray::getValue($form, array_slice($button['#array_parents'], 0, $depth));
+    return $element;
+  }
+
+  /**
+   * Callback for when an Entity Reference autocomplete widget changes.
+   *
+   * @todo Remove this when the media library is complete.
+   *
+   * @param array $form
+   *   The form render array.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The form state.
+   *
+   * @return \Drupal\Core\Ajax\AjaxResponse
+   *   The form render array.
+   */
+  public static function useExistingCallback(array $form, FormStateInterface $form_state) {
+    $button = $form_state->getTriggeringElement();
+    $form_state->setRebuild();
+    $element = NestedArray::getValue($form, array_slice($button['#array_parents'], 0, -1));
+    $field_name = $element['#field_name'];
+    $values = NestedArray::getValue($form_state->getValues(), array_merge($form['#parents'], [$field_name]));
+
+    $response = new AjaxResponse();
+    if (!empty($values['use_existing'])) {
+      // This is the same method the upload modal uses, and the same method the
+      // media library will eventually use.
+      $response->addCommand(new AddMediaToWidget([$values['use_existing']], $field_name));
+    }
+    return $response;
+  }
+
+  /**
+   * Updates the field state based on the form state and sets the form rebuild.
+   *
+   * @param array $form
+   *   The form array.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The form state.
+   */
+  public static function updateItems(array $form, FormStateInterface $form_state) {
+    $button = $form_state->getTriggeringElement();
+
+    // Go one level up in the form, to the widgets container.
+    $element = NestedArray::getValue($form, array_slice($button['#array_parents'], 0, -1));
+    $field_name = $element['#field_name'];
+    $parents = $element['#field_parents'];
+    $field_state = static::getWidgetState($parents, $field_name, $form_state);
+
+    // Get the new media ids passed to our hidden button.
+    $values = $form_state->getValues();
+    $path = array_merge($form['#parents'], [$field_name]);
+    $value = NestedArray::getValue($values, $path);
+
+    if (!empty($value['selection'])) {
+      $ids = explode(',', $value['selection']);
+      /** @var \Drupal\media\MediaInterface[] $media */
+      $media = Media::loadMultiple($ids);
+
+      $field_state['items'] = isset($field_state['items']) ? $field_state['items'] : [];
+
+      // Append the provided media to the existing selection.
+      foreach ($media as $media_item) {
+        if ($media && $media_item->access('view')) {
+          $field_state['items'][] = [
+            'target_id' => $media_item->id(),
+          ];
+        }
+      }
+    }
+
+    $field_state['items_count'] = count($field_state['items']);
+    static::setWidgetState($parents, $field_name, $form_state, $field_state);
+
+    // Discard previously set values in the managed file element.
+    $input = $form_state->getUserInput();
+    NestedArray::unsetValue($input, array_merge($path, ['upload']));
+    $form_state->setUserInput($input);
+
+    $form_state->setRebuild();
+  }
+
+  /**
+   * Processes an upload (managed_file) element.
+   *
+   * @param array $element
+   *   The upload element.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The form state.
+   * @param array $form
+   *   The form render array.
+   *
+   * @return array
+   *   The processed upload element.
+   */
+  public static function processUpload(array $element, FormStateInterface $form_state, array $form) {
+    $element['upload_button']['#ajax'] = [
+      'callback' => [static::class, 'uploadAjaxCallback'],
+      'wrapper' => $element['#wrapper_id'],
+    ];
+    // We have our own table and remove button.
+    $element['remove_button']['#access'] = FALSE;
+    foreach ($element['#value']['fids'] as $fid) {
+      if (isset($element['file_' . $fid])) {
+        $element['file_' . $fid]['#access'] = FALSE;
+      }
+    }
+    return $element;
+  }
+
+  /**
+   * Validates the cardinality of the upload element.
+   *
+   * @param array $element
+   *   The form element.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The form state.
+   */
+  public static function validateUploadCardinality(array $element, FormStateInterface $form_state) {
+    $is_unlimited = $element['#cardinality'] === FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED;
+    $over_limit = !empty($element['#value']['fids']) && $element['#cardinality'] - count($element['#value']['fids']) < 0;
+    if (!$is_unlimited && $over_limit) {
+      $error = \Drupal::translation()->formatPlural($element['#cardinality'],
+        'Only one more file can be uploaded',
+        'A maximum of @count files can be uploaded');
+      $form_state->setError($element, $error);
+    }
+  }
+
+  /**
+   * Opens the bulk upload modal when files are uploaded.
+   *
+   * @param array $form
+   *   The form render array.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The form state.
+   *
+   * @return \Drupal\Core\Ajax\AjaxResponse|array
+   *   An AJAX response to open a modal, or an array if there were errors.
+   */
+  public static function uploadAjaxCallback(array &$form, FormStateInterface $form_state) {
+    $triggering_element = $form_state->getTriggeringElement();
+    $parents = array_slice($triggering_element['#array_parents'], 0, -2);
+
+    $element = NestedArray::getValue($form, $parents);
+    $fids = $element['upload']['#value']['fids'];
+    if (empty($form_state->getErrors()) && !empty($fids)) {
+      $response = new AjaxResponse();
+      $command = new OpenMediaUploadModal($fids, $element['#target_bundles'], $element['#field_name'], [
+        'title' => t('Upload new media'),
+      ]);
+      $response->addCommand($command);
+      return $response;
+    }
+    else {
+      $element['upload']['fids'] = [];
+      return $element;
+    }
+  }
+
+  /**
+   * Returns the widget wrapper ID for AJAX callbacks to target.
+   *
+   * @param string $field_name
+   *   The field name.
+   *
+   * @return string
+   *   The widget wrapper ID.
+   */
+  protected function getFieldWrapperId($field_name) {
+    return 'media-upload-widget-' . $field_name;
+  }
+
+}
