diff --git a/core/modules/media/js/media_file_widget.js b/core/modules/media/js/media_file_widget.js
new file mode 100644
index 0000000000..556a46be3a
--- /dev/null
+++ b/core/modules/media/js/media_file_widget.js
@@ -0,0 +1,19 @@
+/**
+ * @file
+ * Javascript for the media file widget.
+ */
+
+(function (Drupal) {
+
+  "use strict";
+
+  Drupal.AjaxCommands.prototype.open_url = function (ajax, response, status) {
+    Drupal.ajax({
+      dialog: { width: '70%', height: 500 },
+      dialogType: 'modal',
+      selector: 'body',
+      url: response.url
+    }).execute();
+  };
+
+})(Drupal);
diff --git a/core/modules/media/media.field.inc b/core/modules/media/media.field.inc
new file mode 100644
index 0000000000..f23b1ac00a
--- /dev/null
+++ b/core/modules/media/media.field.inc
@@ -0,0 +1,108 @@
+<?php
+
+/**
+ * @file
+ * Field module functionality for the Media module.
+ */
+
+use Drupal\Core\Render\Element;
+
+/**
+ * Prepares variables for media multi file form widget templates.
+ *
+ * Default template: media-file-widget-multiple.html.twig.
+ *
+ * @param array $variables
+ *   An associative array containing:
+ *   - element: A render element representing the widgets.
+ */
+function template_preprocess_media_file_widget_multiple(array &$variables) {
+  $element = $variables['element'];
+
+  // Special ID and classes for draggable tables.
+  $weight_class = $element['#id'] . '-weight';
+  $table_id = $element['#id'] . '-table';
+
+  // Build up a table of applicable fields.
+  $headers = [];
+  $headers[] = t('File information');
+  if ($element['#display_field']) {
+    $headers[] = [
+      'data' => t('Display'),
+      'class' => ['checkbox'],
+    ];
+  }
+  $headers[] = t('Weight');
+  $headers[] = t('Operations');
+
+  // Get our list of widgets in order (needed when the form comes back after
+  // preview or failed validation).
+  $widgets = [];
+  foreach (Element::children($element['selection']) as $key) {
+    $widgets[] = &$element['selection'][$key];
+  }
+  usort($widgets, '_field_multiple_value_form_sort_helper');
+
+  $rows = [];
+  foreach ($widgets as $key => &$widget) {
+    $file_field = &$widget['file_field'];
+
+    // Delay rendering of the buttons, so that they can be rendered later in the
+    // "operations" column.
+    $operations_elements = [];
+    foreach (Element::children($file_field) as $sub_key) {
+      if (isset($file_field[$sub_key]['#type']) && $file_field[$sub_key]['#type'] == 'submit') {
+        hide($file_field[$sub_key]);
+        $operations_elements[] = &$file_field[$sub_key];
+      }
+    }
+
+    // Delay rendering the weight selector, so that
+    // it can be rendered later in its own column.
+    hide($widget['_weight']);
+
+    // Render everything else together in a column, without the normal wrappers.
+    $file_field['#theme_wrappers'] = [];
+    $information = \Drupal::service('renderer')->render($file_field);
+
+    $widget['_weight']['#attributes']['class'] = [$weight_class];
+    $weight = render($widget['_weight']);
+
+    // Arrange the row with all of the rendered columns.
+    $row = [];
+    $row[] = $information;
+    $row[] = $weight;
+
+    // Show the buttons that had previously been marked as hidden in this
+    // preprocess function. We use show() to undo the earlier hide().
+    foreach (Element::children($operations_elements) as $key) {
+      show($operations_elements[$key]);
+    }
+    $row[] = [
+      'data' => $operations_elements,
+    ];
+    $rows[] = [
+      'data' => $row,
+      'class' => isset($file_field['#attributes']['class']) ? array_merge($file_field['#attributes']['class'], ['draggable']) : ['draggable'],
+    ];
+  }
+
+  $variables['table'] = [
+    '#type' => 'table',
+    '#header' => $headers,
+    '#rows' => $rows,
+    '#attributes' => [
+      'id' => $table_id,
+    ],
+    '#tabledrag' => [
+      [
+        'action' => 'order',
+        'relationship' => 'sibling',
+        'group' => $weight_class,
+      ],
+    ],
+    '#access' => !empty($rows),
+  ];
+
+  $variables['element'] = $element;
+}
diff --git a/core/modules/media/media.libraries.yml b/core/modules/media/media.libraries.yml
index 72496a3233..3e04f5dd25 100644
--- a/core/modules/media/media.libraries.yml
+++ b/core/modules/media/media.libraries.yml
@@ -11,3 +11,10 @@ type_form:
     js/type_form.js: {}
   dependencies:
     - core/drupal.form
+
+file_widget:
+  version: VERSION
+  js:
+    'js/media_file_widget.js': {}
+  dependencies:
+    - core/drupal.dialog.ajax
diff --git a/core/modules/media/media.module b/core/modules/media/media.module
index eeb2ac168a..71cb982eab 100644
--- a/core/modules/media/media.module
+++ b/core/modules/media/media.module
@@ -44,6 +44,10 @@ function media_theme() {
     'media' => [
       'render element' => 'elements',
     ],
+    'media_file_widget_multiple' => [
+      'render element' => 'element',
+      'file' => 'media.field.inc',
+    ],
   ];
 }
 
diff --git a/core/modules/media/src/Ajax/MediaOpenUrlCommand.php b/core/modules/media/src/Ajax/MediaOpenUrlCommand.php
new file mode 100644
index 0000000000..348cd076c7
--- /dev/null
+++ b/core/modules/media/src/Ajax/MediaOpenUrlCommand.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace Drupal\media\Ajax;
+
+use Drupal\Core\Ajax\CommandInterface;
+use Drupal\Core\Url;
+
+/**
+ * AJAX command to open a URL in a modal dialog.
+ */
+class MediaOpenUrlCommand implements CommandInterface {
+
+  /**
+   * The URL to open.
+   *
+   * @var \Drupal\Core\GeneratedUrl|string
+   */
+  protected $url;
+
+  /**
+   * Constructs a MediaOpenUrlCommand object.
+   *
+   * @param \Drupal\Core\Url|string $url
+   *   The URL to open.
+   */
+  public function __construct($url) {
+    $this->url = $url instanceof Url ? $url->toString(TRUE) : $url;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function render() {
+    return [
+      'command' => 'open_url',
+      'url' => $this->url,
+    ];
+  }
+
+}
diff --git a/core/modules/media/src/Controller/MediaController.php b/core/modules/media/src/Controller/MediaController.php
new file mode 100644
index 0000000000..04e8f5f24a
--- /dev/null
+++ b/core/modules/media/src/Controller/MediaController.php
@@ -0,0 +1,181 @@
+<?php
+
+namespace Drupal\media\Controller;
+
+use Drupal\Core\Ajax\AjaxResponse;
+use Drupal\Core\Entity\Controller\EntityController;
+use Drupal\Core\Entity\EntityRepositoryInterface;
+use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Render\RendererInterface;
+use Drupal\Core\Routing\UrlGeneratorInterface;
+use Drupal\Core\StringTranslation\TranslationInterface;
+use Drupal\Core\Url;
+use Drupal\file\FileInterface;
+use Drupal\media\Ajax\MediaOpenUrlCommand;
+use Drupal\media\MediaTypeInterface;
+use Symfony\Component\HttpFoundation\Request;
+
+/**
+ * Provides the media add page and title callbacks for entities.
+ */
+class MediaController extends EntityController {
+
+  /**
+   * The current request.
+   *
+   * @var \Symfony\Component\HttpFoundation\Request
+   */
+  protected $request;
+
+  /**
+   * The first available file in the URL.
+   *
+   * @var \Drupal\file\FileInterface
+   */
+  protected $file;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function __construct(EntityTypeManagerInterface $entity_type_manager, EntityTypeBundleInfoInterface $entity_type_bundle_info, EntityRepositoryInterface $entity_repository, RendererInterface $renderer, TranslationInterface $string_translation, UrlGeneratorInterface $url_generator, Request $request = NULL) {
+    parent::__construct($entity_type_manager, $entity_type_bundle_info, $entity_repository, $renderer, $string_translation, $url_generator);
+    $fids = $request->get('fids');
+    if ($fids) {
+      $this->file = $this->entityTypeManager
+        ->getStorage('file')
+        ->load($fids[0]);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function addPage($entity_type_id, Request $request = NULL) {
+    $this->request = $request;
+    $build = parent::addPage($entity_type_id);
+
+    // Filter bundles for the file widget modal.
+    if ($this->request->get('modal') == 'media_file') {
+      $types = &$build['#bundles'];
+
+      $field_id = $this->request->get('field_id');
+      if ($field_id) {
+        $handler_settings = $this->entityTypeManager
+          ->getStorage('field_config')
+          ->load($field_id)
+          ->getSetting('handler_settings');
+
+        // Only allow types that can be referenced by the field.
+        $types = array_intersect_key($types, array_flip($handler_settings['target_bundles']));
+      }
+
+      if ($this->file) {
+        // Only allow types whose source field supports the file.
+        $types = array_intersect_key($types, $this->getSupportedTypesForFile($this->file, array_keys($types)));
+      }
+
+      // Redirect to media add form if we only found 1 type.
+      if (count($types) === 1) {
+        $route_parameters = [
+          'media_type' => key($types),
+        ];
+        return $this->redirect('entity.media.add_form', $route_parameters, [], 302);
+      }
+      else {
+        $types = $this->propagate($types);
+      }
+    }
+
+    return $build;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function addTitle($entity_type_id, Request $request = NULL) {
+    $this->request = $request;
+    $title = parent::addTitle($entity_type_id);
+
+    // Change title when a file has already been uploaded.
+    if ($this->request->get('modal') === 'media_file' && $this->file) {
+      $title = $this->t('Add @filename', ['@filename' => $this->file->getFilename()]);
+    }
+
+    return $title;
+  }
+
+  /**
+   * Add query parameters to types and make them open in a modal.
+   *
+   * @param array $types
+   *   The types to change.
+   *
+   * @return array
+   *   The changed types.
+   */
+  protected function propagate(array $types) {
+    foreach ($types as $id => $element) {
+      $url_options = [
+        'query' => $this->request->query->all(),
+        'attributes' => [
+          'class' => ['use-ajax'],
+          'data-dialog-type' => 'modal',
+        ],
+      ];
+      $route_parameters = ['media_type' => $id];
+      $url = Url::fromRoute('entity.media.add_form', $route_parameters, $url_options);
+      $types[$id]['add_link']->setUrl($url);
+    }
+    return $types;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function redirect($route_name, array $route_parameters = [], array $options = [], $status = 302) {
+    $options = array_merge_recursive($options, [
+      'query' => $this->request->query->all(),
+    ]);
+    $redirect = parent::redirect($route_name, $route_parameters, $options, $status);
+
+    // Change redirect response for the file widget modal.
+    if ($this->request->get('modal') == 'media_file') {
+      return (new AjaxResponse)
+        ->addCommand(new MediaOpenUrlCommand($redirect->getTargetUrl()));
+    }
+    else {
+      return $redirect;
+    }
+  }
+
+  /**
+   * Filter a list of media types by the extension of a file.
+   *
+   * @param \Drupal\file\FileInterface $file
+   *   The uploaded file.
+   * @param array $types
+   *   The media types to check for support.
+   *
+   * @return array
+   *   A list of media types that support the file extension.
+   */
+  protected function getSupportedTypesForFile(FileInterface $file, array $types = NULL) {
+    /** @var \Drupal\media\MediaTypeInterface[] $types */
+    $types = $this->entityTypeManager
+      ->getStorage('media_type')
+      ->loadMultiple($types);
+
+    $extension = pathinfo($file->getFilename(), PATHINFO_EXTENSION);
+
+    return array_filter($types, function (MediaTypeInterface $type) use ($extension) {
+      $handler = $type->getSource();
+      $field = $handler->getSourceFieldDefinition($type);
+      if (in_array($field->getType(), ['file'])) {
+        return in_array($extension, explode(' ', $field->getSetting('file_extensions')));
+      }
+      return FALSE;
+    });
+  }
+
+}
diff --git a/core/modules/media/src/Entity/Media.php b/core/modules/media/src/Entity/Media.php
index 858404112e..50717af56b 100644
--- a/core/modules/media/src/Entity/Media.php
+++ b/core/modules/media/src/Entity/Media.php
@@ -42,7 +42,7 @@
  *     "translation" = "Drupal\content_translation\ContentTranslationHandler",
  *     "views_data" = "Drupal\media\MediaViewsData",
  *     "route_provider" = {
- *       "html" = "Drupal\Core\Entity\Routing\AdminHtmlRouteProvider",
+ *       "html" = "Drupal\media\Routing\MediaAdminHtmlRouteProvider",
  *     }
  *   },
  *   base_table = "media",
diff --git a/core/modules/media/src/MediaForm.php b/core/modules/media/src/MediaForm.php
index f98889fc97..49712f463e 100644
--- a/core/modules/media/src/MediaForm.php
+++ b/core/modules/media/src/MediaForm.php
@@ -2,8 +2,13 @@
 
 namespace Drupal\media;
 
+use Drupal\Core\Ajax\AjaxResponse;
+use Drupal\Core\Ajax\CloseModalDialogCommand;
+use Drupal\Core\Ajax\InvokeCommand;
 use Drupal\Core\Entity\ContentEntityForm;
 use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Url;
+use Drupal\media\Ajax\MediaOpenUrlCommand;
 
 /**
  * Form controller for the media edit forms.
@@ -60,6 +65,15 @@ public function form(array $form, FormStateInterface $form_state) {
   protected function actions(array $form, FormStateInterface $form_state) {
     $element = parent::actions($form, $form_state);
     $media = $this->entity;
+    $request = $this->getRequest();
+
+    // Add extra submit handler for ajax requests.
+    if ($request->query->get('modal') == 'media_file') {
+      $element['submit']['#submit'][] = '::fileWidgetAfterSave';
+      $element['submit']['#ajax'] = [
+        'callback' => '::fileWidgetAjaxSubmit',
+      ];
+    }
 
     // Add a "Publish" button.
     $element['publish'] = $element['submit'];
@@ -105,6 +119,12 @@ protected function actions(array $form, FormStateInterface $form_state) {
     $element['delete']['#access'] = $media->access('delete');
     $element['delete']['#weight'] = 100;
 
+    // Ajax doesn't work for dropbuttons in modal.
+    if ($request->query->get('modal') == 'media_file') {
+      unset($element['publish']['#dropbutton']);
+      unset($element['unpublish']['#dropbutton']);
+    }
+
     return $element;
   }
 
@@ -154,4 +174,102 @@ public function save(array $form, FormStateInterface $form_state) {
     return $saved;
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  protected function prepareEntity() {
+    parent::prepareEntity();
+
+    $fids = $this->getRequest()->query->get('fids');
+    if ($fids) {
+      // @todo: Fix file upload location.
+      $file = $this->entityTypeManager
+        ->getStorage('file')
+        ->load($fids[0]);
+
+      $handler = $this->entity->getSource();
+      if ($handler instanceof MediaSourceInterface) {
+        $this->entity->set($handler->getSourceFieldDefinition($this->entity->bundle->entity)->getName(), $file);
+      }
+    }
+  }
+
+  /**
+   * Form submission handler for file widgets after 'save' action.
+   *
+   * Normally the user is redirected to the created entity after saving.
+   * In the modal this is not needed. We also want to store the entity id in the
+   * form state so we can pass it back to the parent form in the ajax submit.
+   *
+   * @param array $form
+   *   An associative array containing the structure of the form.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The current state of the form.
+   */
+  public function fileWidgetAfterSave(array &$form, FormStateInterface $form_state) {
+    $form_state->disableRedirect();
+    $form_state->setValue('entity_id', $this->entity->id());
+  }
+
+  /**
+   * Ajax form submission handler for file widgets.
+   *
+   * After saving an entity we need to check if there are more files to add
+   * entities for. In that case we go back to the media add page to select
+   * a type for the next file.
+   * After saving entities for all files, the new entity ID needs to be passed
+   * back to the parent form.
+   *
+   * @param array $form
+   *   An associative array containing the structure of the form.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The current state of the form.
+   *
+   * @return \Drupal\Core\Ajax\AjaxResponse
+   *   The ajax response for the form.
+   */
+  public function fileWidgetAjaxSubmit(array &$form, FormStateInterface $form_state) {
+    $response = new AjaxResponse();
+
+    $field_id = $this->getRequest()->query->get('field_id');
+    $fids = array_slice($this->getRequest()->query->get('fids'), 1);
+    $entity_id = $form_state->getValue('entity_id');
+
+    // Check if we have a saved entity.
+    if ($entity_id) {
+      if ($field_id) {
+        $field_name = $this->entityTypeManager
+          ->getStorage('field_config')
+          ->load($field_id)
+          ->getName();
+
+        // Pass id back to field widget and trigger form update.
+        $input_selector = '[data-media-file-input-id="' . $field_name . '"]';
+        $submit_selector = '[data-media-file-submit-id="' . $field_name . '"]';
+        $response->addCommand(new InvokeCommand($input_selector, 'val', [$entity_id]));
+
+        // todo: Only rebuild the form after all media entities are saved.
+        $response->addCommand(new InvokeCommand($submit_selector, 'trigger', ['mousedown']));
+      }
+    }
+
+    // If we still have file IDs, return to media add list for next file,
+    // or close the modal if all files are added.
+    if ($fids) {
+      $url = Url::fromRoute('entity.media.add_page', [], [
+        'query' => [
+          'modal' => 'media_file',
+          'field_id' => $field_id,
+          'fids' => $fids,
+        ],
+      ]);
+      $response->addCommand(new MediaOpenUrlCommand($url));
+    }
+    else {
+      $response->addCommand(new CloseModalDialogCommand());
+    }
+
+    return $response;
+  }
+
 }
diff --git a/core/modules/media/src/Plugin/Field/FieldWidget/MediaFileWidget.php b/core/modules/media/src/Plugin/Field/FieldWidget/MediaFileWidget.php
new file mode 100644
index 0000000000..311144a002
--- /dev/null
+++ b/core/modules/media/src/Plugin/Field/FieldWidget/MediaFileWidget.php
@@ -0,0 +1,766 @@
+<?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\TypedData\FieldItemDataDefinition;
+use Drupal\Core\Field\WidgetBase;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\Core\Render\Element;
+use Drupal\Core\Render\ElementInfoManagerInterface;
+use Drupal\Core\Render\RendererInterface;
+use Drupal\Core\Url;
+use Drupal\file\Element\ManagedFile;
+use Drupal\file\Entity\File;
+use Drupal\file\Plugin\Field\FieldType\FileItem;
+use Drupal\media\Ajax\MediaOpenUrlCommand;
+use Drupal\media\Entity\MediaType;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\Validator\ConstraintViolationInterface;
+
+/**
+ * Plugin implementation of the 'media_file' widget.
+ *
+ * @FieldWidget(
+ *   id = "media_file",
+ *   label = @Translation("File"),
+ *   media_sources = {
+ *     "file"
+ *   },
+ *   field_types = {
+ *     "entity_reference"
+ *   }
+ * )
+ */
+class MediaFileWidget extends WidgetBase implements ContainerFactoryPluginInterface {
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $elementInfo;
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * The renderer.
+   *
+   * @var \Drupal\Core\Render\RendererInterface
+   */
+  protected $renderer;
+
+  /**
+   * Constructs a MediaFileWidget object.
+   *
+   * @param string $plugin_id
+   *   The plugin_id for the widget.
+   * @param mixed $plugin_definition
+   *   The plugin implementation definition.
+   * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
+   *   The definition of the field to which the widget is associated.
+   * @param array $settings
+   *   The widget settings.
+   * @param array $third_party_settings
+   *   Any third party settings.
+   * @param \Drupal\Core\Render\ElementInfoManagerInterface $element_info
+   *   Any third party settings.
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager.
+   * @param \Drupal\Core\Render\RendererInterface $renderer
+   *   The renderer.
+   */
+  public function __construct($plugin_id, $plugin_definition, FieldDefinitionInterface $field_definition, array $settings, array $third_party_settings, ElementInfoManagerInterface $element_info, EntityTypeManagerInterface $entity_type_manager, RendererInterface $renderer) {
+    parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $third_party_settings);
+    $this->elementInfo = $element_info;
+    $this->entityTypeManager = $entity_type_manager;
+    $this->renderer = $renderer;
+  }
+
+  /**
+   * {@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('element_info'),
+      $container->get('entity_type.manager'),
+      $container->get('renderer')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function defaultSettings() {
+    return [
+      'progress_indicator' => 'throbber',
+    ] + parent::defaultSettings();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function settingsForm(array $form, FormStateInterface $form_state) {
+    $element['progress_indicator'] = [
+      '#type' => 'radios',
+      '#title' => t('Progress indicator'),
+      '#options' => [
+        'throbber' => t('Throbber'),
+        'bar' => t('Bar with progress meter'),
+      ],
+      '#default_value' => $this->getSetting('progress_indicator'),
+      '#description' => t('The throbber display does not show the status of uploads but takes up less space. The progress bar is helpful for monitoring progress on large uploads.'),
+      '#weight' => 16,
+      '#access' => file_progress_implementation(),
+    ];
+    return $element;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function settingsSummary() {
+    $summary = [];
+    $summary[] = t('Progress indicator: @progress_indicator', ['@progress_indicator' => $this->getSetting('progress_indicator')]);
+    return $summary;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function isApplicable(FieldDefinitionInterface $field_definition) {
+    $settings = $field_definition->getSettings();
+
+    if (parent::isApplicable($field_definition) && $settings['target_type'] == 'media') {
+      /** @var \Drupal\media\MediaTypeInterface[] $allowed_types */
+      $allowed_types = MediaType::loadMultiple($settings['handler_settings']['target_bundles']);
+
+      $plugin_definition = \Drupal::service('plugin.manager.field.widget')
+        ->getDefinition('media_file');
+
+      // The widget applies if any of the media types that can be referenced by
+      // the field are using the media sources listed in the plugin's
+      // media_sources array.
+      foreach ($allowed_types as $type) {
+        if (in_array($type->get('source'), $plugin_definition['media_sources'])) {
+          return TRUE;
+        }
+      }
+    }
+    return FALSE;
+  }
+
+  /**
+   * {@inheritdoc}
+   *
+   * Special handling for updating items from the field state and for providing
+   * an Ajax wrapper.
+   */
+  public function form(FieldItemListInterface $items, array &$form, FormStateInterface $form_state, $get_delta = NULL) {
+    $field_name = $this->fieldDefinition->getName();
+    $parents = $form['#parents'];
+    $wrapper_id = $field_name . '-ajax-wrapper';
+
+    // Load the items for form rebuilds from the field state as they might
+    // not be in $form_state['values'] because of validation limitations.
+    $field_state = static::getWidgetState($parents, $field_name, $form_state);
+    if ($field_state && isset($field_state['items'])) {
+      $items->setValue($field_state['items']);
+    }
+    $build = parent::form($items, $form, $form_state);
+
+    // Add a hidden textfield to the form, which is used to pass values from the
+    // modal to the widget. This is similar to how the
+    // EntityReferenceAutocompleteWidget stores values.
+    $build[$field_name . '_media_file_selection'] = [
+      '#type' => 'textfield',
+      '#name' => $field_name . '-media-file-selection',
+      '#attributes' => [
+        'data-media-file-input-id' => $field_name,
+        'class' => ['visually-hidden'],
+      ],
+    ];
+    // @TODO: Document this element.
+    $build[$field_name . '_media_file_update_widget'] = [
+      '#type' => 'submit',
+      '#name' => $field_name . '-media-file-update-button',
+      '#value' => $this->t('Add selection'),
+      '#submit' => [
+        [static::class, 'addItems'],
+      ],
+      '#ajax' => [
+        'callback' => [static::class, 'updateWidget'],
+        'wrapper' => $wrapper_id,
+        'effect' => 'fade',
+      ],
+      '#attributes' => [
+        'data-media-file-submit-id' => $field_name,
+        'class' => ['visually-hidden'],
+      ],
+      '#limit_validation_errors' => [array_merge($parents, [$field_name])],
+    ];
+
+    // Wrap the widget in a DIV so that it can be re-loaded via AJAX.
+    $build['#prefix'] = '<div id="' . $wrapper_id . '">';
+    $build['#suffix'] = '</div>';
+    return $build;
+  }
+
+  /**
+   * {@inheritdoc}
+   *
+   * Special handling for draggable multiple widgets and upload field.
+   */
+  protected function formMultipleElements(FieldItemListInterface $items, array &$form, FormStateInterface $form_state) {
+    $field_name = $this->fieldDefinition->getName();
+
+    // Determine the number of widgets to display.
+    $cardinality = $this->fieldDefinition->getFieldStorageDefinition()->getCardinality();
+    if ($cardinality === FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED) {
+      $max = count($items);
+      $is_multiple = TRUE;
+    }
+    else {
+      $max = $cardinality - 1;
+      $is_multiple = ($cardinality > 1);
+    }
+
+    $title = $this->fieldDefinition->getLabel();
+    $description = $this->getFilteredDescription();
+
+    $elements = [
+      'selection' => [
+        '#type' => 'container',
+      ],
+    ];
+
+    $delta = 0;
+    // Add an element for every existing item.
+    foreach ($items as $item) {
+      $element = [
+        '#title' => $title,
+        '#description' => $description,
+      ];
+      $element = $this->formSingleElement($items, $delta, $element, $form, $form_state);
+
+      if ($element) {
+        // Input field for the delta (drag-n-drop reordering).
+        if ($is_multiple) {
+          // We name the element '_weight' to avoid clashing with elements
+          // defined by widget.
+          $element['_weight'] = [
+            '#type' => 'weight',
+            '#title' => t('Weight for row @number', ['@number' => $delta + 1]),
+            '#title_display' => 'invisible',
+            // Note: this 'delta' is the FAPI #type 'weight' element's property.
+            '#delta' => $max,
+            '#default_value' => $item->_weight ?: $delta,
+            '#weight' => 100,
+          ];
+        }
+
+        $elements['selection'][$delta] = $element;
+        $delta++;
+      }
+    }
+
+    $empty_single_allowed = ($cardinality == 1 && $delta == 0);
+    // @TODO: Explain why programmed forms cannot be $empty_multiple_allowed.
+    $empty_multiple_allowed = ($cardinality == FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED || $delta < $cardinality) && !$form_state->isProgrammed();
+
+    // Add a managed file field for new uploads except when this is a programmed
+    // multiple form.
+    if ($empty_single_allowed || $empty_multiple_allowed) {
+      // Fetch the upload validators from the source field.
+      $upload_validators = [];
+
+      $allowed_types = $this->fieldDefinition->getSetting('handler_settings')['target_bundles'];
+      /** @var \Drupal\media\MediaTypeInterface[] $allowed_types */
+      $allowed_types = $this->entityTypeManager
+        ->getStorage('media_type')
+        ->loadMultiple($allowed_types);
+
+      foreach ($allowed_types as $type) {
+        $source_field = $type->getSource()->getSourceFieldDefinition($type);
+        $source_data_definition = FieldItemDataDefinition::create($source_field);
+        $file_item = new FileItem($source_data_definition);
+        // @TODO: The upload validation should be smarter and as permissive as
+        // possible, since we don't know which media type will apply to the
+        // uploaded file.
+        $upload_validators = array_merge($upload_validators, $file_item->getUploadValidators());
+      }
+
+      // Essentially we use the managed_file type, extended with some
+      // enhancements.
+      $element_info = $this->elementInfo->getInfo('managed_file');
+      $file_upload_help = [
+        '#theme' => 'file_upload_help',
+        '#upload_validators' => $upload_validators,
+        '#cardinality' => $cardinality,
+      ];
+      $elements['upload'][$delta][$field_name . '_media_file_upload'] = [
+        '#title' => $title,
+        '#type' => 'managed_file',
+        '#required' => $delta == 0 && $this->fieldDefinition->isRequired(),
+        '#upload_validators' => $upload_validators,
+        '#description' => $this->renderer->renderPlain($file_upload_help),
+        '#value_callback' => [static::class, 'uploadFieldValueCallback'],
+        '#process' => array_merge($element_info['#process'], [[static::class, 'uploadFieldProcess']]),
+        '#progress_indicator' => $this->getSetting('progress_indicator'),
+        '#extended' => TRUE,
+        '#element_validate' => $element_info['#element_validate'],
+        // Add properties needed by value() and process() methods.
+        '#field_name' => $this->fieldDefinition->getName(),
+        '#bundle' => $this->fieldDefinition->getTargetBundle(),
+        '#entity_type' => $this->fieldDefinition->getTargetEntityTypeId(),
+        '#cardinality' => $cardinality,
+        '#attached' => array_merge_recursive(
+          $element_info['#attached'],
+          ['library' => ['media/file_widget']]
+        ),
+        '#multiple' => $cardinality != 1,
+      ];
+      if ($cardinality > 1) {
+        $elements['upload'][$delta][$field_name . '_media_file_upload']['#element_validate'] = [
+          [static::class, 'validateMultipleCount'],
+        ];
+      }
+
+    }
+
+    if ($is_multiple) {
+      // The group of elements all-together need some extra functionality after
+      // building up the full list (like draggable table rows).
+      $elements['#file_upload_delta'] = $delta;
+      $elements['#type'] = 'details';
+      $elements['#open'] = TRUE;
+      $elements['#theme'] = 'media_file_widget_multiple';
+      $elements['#theme_wrappers'] = ['details'];
+      $elements['#process'] = [
+        [static::class, 'processMultiple'],
+      ];
+      $elements['#title'] = $title;
+      $elements['#description'] = $description;
+      $elements['#field_name'] = $field_name;
+      unset($elements['upload'][$delta][$field_name . '_media_file_upload']['#title']);
+      unset($elements['upload'][$delta][$field_name . '_media_file_upload']['#description']);
+    }
+
+    return $elements;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) {
+    $field_name = $this->fieldDefinition->getName();
+    $wrapper_id = $field_name . '-ajax-wrapper';
+
+    /** @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $items */
+    $referenced_entities = $items->referencedEntities();
+    if (isset($referenced_entities[$delta])) {
+      /** @var \Drupal\media\MediaInterface $media */
+      $media = $referenced_entities[$delta];
+      /** @var \Drupal\field\FieldConfigInterface $source_field */
+      $source_field = $media->getSource()->getSourceFieldDefinition($media->bundle->entity);
+      /** @var \Drupal\file\FileInterface $file */
+      $file = $media->get($source_field->getName())->entity;
+
+      // We use a hidden field with the media ID and show a managed file for the
+      // media item's source field value.
+      $element += [
+        '#field_name' => $field_name,
+        // Add media entity values for storage.
+        'target_id' => [
+          '#type' => 'hidden',
+          '#value' => $media->id(),
+        ],
+        '#weight' => $delta,
+        // Add file field for display.
+        'file_field' => [
+          '#type' => 'managed_file',
+          '#value' => [
+            'fids' => [$file->id()],
+          ],
+          '#process' => [],
+          'remove_button' => [
+            '#name' => implode('_', [
+              $field_name,
+              $element['#delta'],
+              'remove_button',
+            ]),
+            '#type' => 'submit',
+            '#value' => t('Remove'),
+            '#remove_delta' => $element['#delta'],
+            '#attributes' => ['class' => ['remove-button']],
+            '#validate' => [],
+            '#submit' => [
+              [static::class, 'removeItem'],
+            ],
+            '#limit_validation_errors' => [array_merge($form['#parents'], [$field_name])],
+            '#ajax' => [
+              'callback' => [static::class, 'updateWidget'],
+              'wrapper' => $wrapper_id,
+              'effect' => 'fade',
+            ],
+            '#weight' => 1,
+            '#access' => TRUE,
+          ],
+          'file_' . $delta => [
+            'filename' => [
+              '#theme' => 'file_link',
+              '#file' => $file,
+              '#weight' => -10,
+            ],
+          ],
+        ],
+      ];
+    }
+    return $element;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function errorElement(array $element, ConstraintViolationInterface $error, array $form, FormStateInterface $form_state) {
+    return isset($element['target_id']) ? $element['target_id'] : 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]);
+    $key_exists = NULL;
+    $values = NestedArray::getValue($form_state->getValues(), $path, $key_exists);
+
+    // Get the actual values from the selection container.
+    if (isset($values['selection'])) {
+      $values = $values['selection'];
+    }
+
+    if ($key_exists) {
+      // Account for drag-and-drop reordering if needed.
+      if (!$this->handlesMultipleValues()) {
+        // Remove the 'value' of the 'add more' button.
+        unset($values['add_more']);
+
+        // The original delta, before drag-and-drop reordering, is needed to
+        // route errors to the correct form element.
+        foreach ($values as $delta => &$value) {
+          $value['_original_delta'] = $delta;
+        }
+
+        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();
+
+      // Put delta mapping in $form_state, so that flagErrors() can use it.
+      $field_state = static::getWidgetState($form['#parents'], $field_name, $form_state);
+      foreach ($items as $delta => $item) {
+        $field_state['original_deltas'][$delta] = isset($item->_original_delta) ? $item->_original_delta : $delta;
+        unset($item->_original_delta, $item->_weight);
+      }
+      static::setWidgetState($form['#parents'], $field_name, $form_state, $field_state);
+    }
+  }
+
+  /**
+   * Processes a group of media elements.
+   *
+   * This method on is assigned as a #process callback in formMultipleElements()
+   * method.
+   *
+   * @param array $element
+   *   The widget element array.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The form state.
+   * @param array $form
+   *   The form array.
+   *
+   * @return array
+   *   The changed widget element.
+   */
+  public static function processMultiple(array $element, FormStateInterface $form_state, array $form) {
+    $element_children = Element::children($element, TRUE);
+    foreach ($element_children as $delta => $key) {
+      // The title needs to be assigned to the upload field so that validation
+      // errors include the correct widget label.
+      $element[$key]['#title'] = $element['#title'];
+    }
+    return $element;
+  }
+
+  /**
+   * Form element validation callback for upload element on file widget.
+   *
+   * Checks if the user has uploaded more files than allowed.
+   * This validator is used only when cardinality not set to 1 or unlimited.
+   *
+   * @param array $element
+   *   The widget element array.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The form state.
+   * @param array $form
+   *   The form array.
+   */
+  public static function validateMultipleCount(array $element, FormStateInterface $form_state, array $form) {
+    $values = NestedArray::getValue($form_state->getValues(), $element['#parents']);
+
+    $array_parents = $element['#array_parents'];
+    array_pop($array_parents);
+    $previously_uploaded_count = count(Element::children(NestedArray::getValue($form, $array_parents))) - 1;
+
+    /** @var FieldStorageDefinitionInterface $field_storage */
+    $field_storage = \Drupal::service('entity_field.manager')->getFieldStorageDefinitions($element['#entity_type'])[$element['#field_name']];
+    $newly_uploaded_count = count($values['fids']);
+    $total_uploaded_count = $newly_uploaded_count + $previously_uploaded_count;
+    if ($total_uploaded_count > $field_storage->getCardinality()) {
+      $keep = $newly_uploaded_count - $total_uploaded_count + $field_storage->getCardinality();
+      $removed_files = array_slice($values['fids'], $keep);
+      $removed_names = [];
+      foreach ($removed_files as $fid) {
+        $file = File::load($fid);
+        $removed_names[] = $file->getFilename();
+      }
+      $args = [
+        '%field' => $field_storage->getName(),
+        '@max' => $field_storage->getCardinality(),
+        '@count' => $total_uploaded_count,
+        '%list' => implode(', ', $removed_names),
+      ];
+      $message = t('Field %field can only hold @max values but there were @count uploaded. The following files have been omitted as a result: %list.', $args);
+      drupal_set_message($message, 'warning');
+      $values['fids'] = array_slice($values['fids'], 0, $keep);
+      NestedArray::setValue($form_state->getValues(), $element['#parents'], $values);
+    }
+  }
+
+  /**
+   * Process callback for the upload field.
+   *
+   * Change the Ajax functionality for the upload button.
+   *
+   * @param array $element
+   *   The file field element array.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The form state.
+   * @param array $form
+   *   The form array.
+   *
+   * @return array
+   *   The changed file field element.
+   */
+  public static function uploadFieldProcess(array $element, FormStateInterface $form_state, array $form) {
+    // Override the ajax callback on the button.
+    $element['upload_button']['#ajax']['callback'] = [static::class, 'uploadFieldAjaxCallback'];
+    return $element;
+  }
+
+  /**
+   * Value callback for the upload field.
+   *
+   * Provide defaults for the field when uploading media.
+   *
+   * @param array $element
+   *   An associative array containing the properties of the element.
+   * @param mixed $input
+   *   The incoming input to populate the form element. If this is FALSE,
+   *   the element's default value should be returned.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The current state of the form.
+   *
+   * @return array
+   *   The value to assign to the element.
+   */
+  public static function uploadFieldValueCallback(array $element, $input, FormStateInterface $form_state) {
+    // We depend on the managed file element to handle uploads.
+    $return = ManagedFile::valueCallback($element, $input, $form_state);
+    // Ensure that all the required properties are returned even if empty.
+    $return += [
+      'fids' => [],
+    ];
+    return $return;
+  }
+
+  /**
+   * Ajax callback for the upload field.
+   *
+   * This callback takes care of the following things:
+   *   - Add commands from ManagedFile::uploadAjaxCallback() to handle
+   *     file uploads.
+   *   - Pass the file IDs to a modal.
+   *
+   * @param array $form
+   *   The build form.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The form state.
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The current request.
+   *
+   * @return \Drupal\Core\Ajax\AjaxResponse
+   *   The ajax response for the widget.
+   */
+  public static function uploadFieldAjaxCallback(array &$form, FormStateInterface &$form_state, Request $request) {
+    $response = new AjaxResponse();
+
+    $form_parents = explode('/', $request->query->get('element_parents'));
+    $element = NestedArray::getValue($form, $form_parents);
+
+    if (!empty($element['#value']['fids'])) {
+      // Open modal to add media.
+      $url = Url::fromRoute('entity.media.add_page', [], [
+        'query' => [
+          'modal' => 'media_file',
+          'field_id' => sprintf(
+            '%s.%s.%s',
+            $element['#entity_type'],
+            $element['#bundle'],
+            $element['#field_name']
+          ),
+          'fids' => $element['#value']['fids'],
+        ],
+      ]);
+      $response->addCommand(new MediaOpenUrlCommand($url));
+
+    }
+    else {
+      // @todo: Rebuild widget to show errors.
+    }
+
+    return $response;
+  }
+
+  /**
+   * Submission handler for the hidden "Add selection" button.
+   *
+   * Adds media to the field state of the widget.
+   *
+   * @param array $form
+   *   The form array.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The form state.
+   */
+  public static function addItems(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))['widget'];
+    $field_name = $element['#field_name'];
+    $parents = $element['#field_parents'];
+
+    // Get the new media ids passed to our hidden button.
+    $user_input = $form_state->getUserInput();
+    $input_key = $field_name . '-media-file-selection';
+    if (isset($user_input[$input_key])) {
+      $values = $form_state->getValue($field_name);
+      $field_state = static::getWidgetState($parents, $field_name, $form_state);
+
+      // Add new media entity to field.
+      $ids = explode(',', $user_input[$input_key]);
+      /** @var \Drupal\media\MediaInterface[] $media */
+      $media = \Drupal::entityTypeManager()->getStorage('media')->loadMultiple($ids);
+      foreach ($media as $media_item) {
+        if ($media && $media_item->access('view')) {
+          $values['selection'][] = [
+            'target_id' => $media_item->id(),
+          ];
+        }
+      }
+
+      $field_state['items'] = $values['selection'];
+      $field_state['items_count'] = count($field_state['items']);
+
+      static::setWidgetState($parents, $field_name, $form_state, $field_state);
+
+      $form_state->setRebuild();
+    }
+  }
+
+  /**
+   * Submission handler for remove buttons on added media.
+   *
+   * Remove media from the field state of the widget.
+   *
+   * @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) {
+    $triggering_element = $form_state->getTriggeringElement();
+
+    $parents = $triggering_element['#array_parents'];
+    $parents = array_slice($parents, 0, -4);
+    $element = NestedArray::getValue($form, $parents);
+    $field_name = $element['#field_name'];
+    $parents = $element['#field_parents'];
+    $delta = $triggering_element['#remove_delta'];
+
+    // Find and remove correct entity.
+    $values = $form_state->getValue($field_name);
+    $field_state = static::getWidgetState($parents, $field_name, $form_state);
+    if (isset($values['selection'])) {
+      if (isset($values['selection'][$delta])) {
+        array_splice($values['selection'], $delta, 1);
+        $field_state['items'] = $values['selection'];
+        $field_state['items_count'] = count($field_state['items']);
+      }
+    }
+    $form_state->setValue($field_name, $values);
+    static::setWidgetState($parents, $field_name, $form_state, $field_state);
+
+    // Rebuild form.
+    $form_state->setRebuild();
+  }
+
+  /**
+   * AJAX callback to update the widget when the selection changes.
+   *
+   * @param array $form
+   *   The form array.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The form state.
+   *
+   * @return array
+   *   An array representing the updated widget.
+   */
+  public static function updateWidget(array $form, FormStateInterface $form_state) {
+    $triggering_element = $form_state->getTriggeringElement();
+    $length = isset($triggering_element['#remove_delta']) ? -5 : -1;
+    $parents = $triggering_element['#array_parents'];
+    $parents = array_slice($parents, 0, $length);
+    $element = NestedArray::getValue($form, $parents);
+    return $element;
+  }
+
+}
diff --git a/core/modules/media/src/Routing/MediaAdminHtmlRouteProvider.php b/core/modules/media/src/Routing/MediaAdminHtmlRouteProvider.php
new file mode 100644
index 0000000000..4742ca0a53
--- /dev/null
+++ b/core/modules/media/src/Routing/MediaAdminHtmlRouteProvider.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace Drupal\media\Routing;
+
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Entity\Routing\AdminHtmlRouteProvider;
+use Drupal\media\Controller\MediaController;
+
+/**
+ * Provides HTML routes for media entities.
+ */
+class MediaAdminHtmlRouteProvider extends AdminHtmlRouteProvider {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getAddPageRoute(EntityTypeInterface $entity_type) {
+    $route = parent::getAddPageRoute($entity_type);
+    if ($route) {
+      $route->setDefault('_controller', MediaController::class . '::addPage');
+      $route->setDefault('_title_callback', MediaController::class . '::addTitle');
+    }
+    return $route;
+  }
+
+}
diff --git a/core/modules/media/templates/media-file-widget-multiple.html.twig b/core/modules/media/templates/media-file-widget-multiple.html.twig
new file mode 100644
index 0000000000..358b1cca2e
--- /dev/null
+++ b/core/modules/media/templates/media-file-widget-multiple.html.twig
@@ -0,0 +1,16 @@
+{#
+/**
+ * @file
+ * Default theme implementation to display a multiple media file widget.
+ *
+ * Available variables:
+ * - table: Table of previously uploaded files.
+ * - element: The form element for uploading another file.
+ *
+ * @see template_preprocess_file_widget_multiple()
+ *
+ * @ingroup themeable
+ */
+#}
+{{ table }}
+{{ element }}
