Index: src/js/media/models/attachments.js
===================================================================
--- src/js/media/models/attachments.js	(revision 50825)
+++ src/js/media/models/attachments.js	(working copy)
@@ -348,20 +348,48 @@
 		return this.mirroring ? this.mirroring.hasMore() : false;
 	},
 	/**
+	 * Holds the total number of attachments.
+	 *
+	 * @since 5.7.0
+	 */
+	totalAttachments: 0,
+
+	/**
+	 * Gets the total number of attachments.
+	 *
+	 * @since 5.7.0
+	 *
+	 * @return {number} The total number of attachments.
+	 */
+	getTotalAttachments: function() {
+		return this.mirroring ? this.mirroring.totalAttachments : 0;
+	},
+
+	/**
 	 * A custom Ajax-response parser.
 	 *
 	 * See trac ticket #24753
 	 *
-	 * @param {Object|Array} resp The raw response Object/Array.
+	 * Called automatically by Backbone whenever a collection's models are returned
+	 * by the server, in fetch. The default implementation is a no-op, simply
+	 * passing through the JSON response. We override this to add attributes to
+	 * the collection items.
+	 *
+	 * Since WordPress 5.5, the response returns the attachments under `response.attachments`
+	 * and `response.totalAttachments` holds the total number of attachments found.
+	 *
+	 * @param {Object|Array} response The raw response Object/Array.
 	 * @param {Object} xhr
 	 * @return {Array} The array of model attributes to be added to the collection
 	 */
-	parse: function( resp, xhr ) {
-		if ( ! _.isArray( resp ) ) {
-			resp = [resp];
+	parse: function( response, xhr ) {
+		if ( ! _.isArray( response.attachments ) ) {
+			response = [response.attachments];
 		}
 
-		return _.map( resp, function( attrs ) {
+		this.totalAttachments = parseInt( response.totalAttachments, 10 );
+
+		return _.map( response.attachments, function( attrs ) {
 			var id, attachment, newAttributes;
 
 			if ( attrs instanceof Backbone.Model ) {
Index: src/js/media/models/query.js
===================================================================
--- src/js/media/models/query.js	(revision 50825)
+++ src/js/media/models/query.js	(working copy)
@@ -112,8 +112,11 @@
 		options = options || {};
 		options.remove = false;
 
-		return this._more = this.fetch( options ).done( function( resp ) {
-			if ( _.isEmpty( resp ) || -1 === this.args.posts_per_page || resp.length < this.args.posts_per_page ) {
+		return this._more = this.fetch( options ).done( function( response ) {
+			// Since WordPress 5.5, the response returns the attachments under `response.attachments`.
+			var attachments = response.attachments;
+
+			if ( _.isEmpty( attachments ) || -1 === this.args.posts_per_page || attachments.length < this.args.posts_per_page ) {
 				query._hasMore = false;
 			}
 		});
Index: src/js/media/views/attachments/browser.js
===================================================================
--- src/js/media/views/attachments/browser.js	(revision 50825)
+++ src/js/media/views/attachments/browser.js	(working copy)
@@ -2,7 +2,10 @@
 	mediaTrash = wp.media.view.settings.mediaTrash,
 	l10n = wp.media.view.l10n,
 	$ = jQuery,
-	AttachmentsBrowser;
+	AttachmentsBrowser,
+	infiniteScrolling = wp.media.view.settings.infiniteScrolling,
+	__ = wp.i18n.__,
+	sprintf = wp.i18n.sprintf;
 
 /**
  * wp.media.view.AttachmentsBrowser
@@ -68,13 +71,17 @@
 			this.createUploader();
 		}
 
-
 		// Add a heading before the attachments list.
 		this.createAttachmentsHeading();
 
-		// Create the list of attachments.
-		this.createAttachments();
+		// Create the attachments wrapper view.
+		this.createAttachmentsWrapperView();
 
+		if ( ! infiniteScrolling ) {
+			this.$el.addClass( 'has-load-more' );
+			this.createLoadMoreView();
+		}
+
 		// For accessibility reasons, place the normal sidebar after the attachments, see ticket #36909.
 		if ( this.options.sidebar && 'errors' !== this.options.sidebar ) {
 			this.createSidebar();
@@ -92,6 +99,10 @@
 
 		this.collection.on( 'add remove reset', this.updateContent, this );
 
+		if ( ! infiniteScrolling ) {
+			this.collection.on( 'add remove reset', this.updateLoadMoreView, this );
+		}
+
 		// The non-cached or cached attachments query has completed.
 		this.collection.on( 'attachments:received', this.announceSearchResults, this );
 	},
@@ -106,8 +117,15 @@
 	 * @return {void}
 	 */
 	announceSearchResults: _.debounce( function() {
-		var count;
+		var count,
+			/* translators: Accessibility text. %d: Number of attachments found in a search. */
+			mediaFoundHasMoreResultsMessage = __( 'Number of media items displayed: %d. Click load more for more results.' );
 
+		if ( infiniteScrolling ) {
+			/* translators: Accessibility text. %d: Number of attachments found in a search. */
+			mediaFoundHasMoreResultsMessage = __( 'Number of media items displayed: %d. Scroll the page for more results.' );
+		}
+
 		if ( this.collection.mirroring.args.s ) {
 			count = this.collection.length;
 
@@ -117,7 +135,7 @@
 			}
 
 			if ( this.collection.hasMore() ) {
-				wp.a11y.speak( l10n.mediaFoundHasMoreResults.replace( '%d', count ) );
+				wp.a11y.speak( mediaFoundHasMoreResultsMessage.replace( '%d', count ) );
 				return;
 			}
 
@@ -392,8 +410,10 @@
 			noItemsView;
 
 		if ( this.controller.isModeActive( 'grid' ) ) {
+			// Usually the media library.
 			noItemsView = view.attachmentsNoResults;
 		} else {
+			// Usually the media modal.
 			noItemsView = view.uploader;
 		}
 
@@ -433,6 +453,23 @@
 		}
 	},
 
+	/**
+	 * Creates the Attachments wrapper view.
+	 *
+	 * @since 5.7.0
+	 *
+	 * @return {void}
+	 */
+	createAttachmentsWrapperView: function() {
+		this.attachmentsWrapper = new wp.media.View( {
+			className: 'attachments-wrapper'
+		} );
+
+		// Create the list of attachments.
+		this.views.add( this.attachmentsWrapper );
+		this.createAttachments();
+	},
+
 	createAttachments: function() {
 		this.attachments = new wp.media.view.Attachments({
 			controller:           this.controller,
@@ -451,9 +488,8 @@
 		this.controller.on( 'attachment:keydown:arrow',     _.bind( this.attachments.arrowEvent, this.attachments ) );
 		this.controller.on( 'attachment:details:shift-tab', _.bind( this.attachments.restoreFocus, this.attachments ) );
 
-		this.views.add( this.attachments );
+		this.views.add( '.attachments-wrapper', this.attachments );
 
-
 		if ( this.controller.isModeActive( 'grid' ) ) {
 			this.attachmentsNoResults = new View({
 				controller: this.controller,
@@ -467,6 +503,157 @@
 		}
 	},
 
+	/**
+	 * Creates the load more button and attachments counter view.
+	 *
+	 * @since 5.7.0
+	 *
+	 * @return {void}
+	 */
+	createLoadMoreView: function() {
+		var view = this;
+
+		this.loadMoreWrapper = new View( {
+			controller: this.controller,
+			className: 'load-more-wrapper'
+		} );
+
+		this.loadMoreCount = new View( {
+			controller: this.controller,
+			tagName: 'p',
+			className: 'load-more-count hidden'
+		} );
+
+		this.loadMoreButton = new wp.media.view.Button( {
+			text: __( 'Load more' ),
+			className: 'load-more hidden',
+			style: 'primary',
+			size: '',
+			click: function() {
+				view.loadMoreAttachments();
+			}
+		} );
+
+		this.loadMoreSpinner = new wp.media.view.Spinner();
+
+		this.loadMoreJumpToFirst = new wp.media.view.Button( {
+			text: __( 'Jump to first loaded item' ),
+			className: 'load-more-jump hidden',
+			size: '',
+			click: function() {
+				view.jumpToFirstAddedItem();
+			}
+		} );
+
+		this.views.add( '.attachments-wrapper', this.loadMoreWrapper );
+		this.views.add( '.load-more-wrapper', this.loadMoreSpinner );
+		this.views.add( '.load-more-wrapper', this.loadMoreCount );
+		this.views.add( '.load-more-wrapper', this.loadMoreButton );
+		this.views.add( '.load-more-wrapper', this.loadMoreJumpToFirst );
+	},
+
+	/**
+	 * Updates the Load More view. This function is debounced because the
+	 * collection updates multiple times at the add, remove, and reset events.
+	 * We need it to run only once, after all attachments are added or removed.
+	 *
+	 * @since 5.7.0
+	 *
+	 * @return {void}
+	 */
+	updateLoadMoreView: _.debounce( function() {
+		// Ensure the load more view elements are initially hidden at each update.
+		this.loadMoreButton.$el.addClass( 'hidden' );
+		this.loadMoreCount.$el.addClass( 'hidden' );
+		this.loadMoreJumpToFirst.$el.addClass( 'hidden' ).prop( 'disabled', true );
+
+		if ( ! this.collection.getTotalAttachments() ) {
+			return;
+		}
+
+		if ( this.collection.length ) {
+			this.loadMoreCount.$el.text(
+				/* translators: 1: Number of displayed attachments, 2: Number of total attachments. */
+				sprintf(
+					__( 'Showing %1$s of %2$s media items' ),
+					this.collection.length,
+					this.collection.getTotalAttachments()
+				)
+			);
+
+			this.loadMoreCount.$el.removeClass( 'hidden' );
+		}
+
+		/*
+		 * Notice that while the collection updates multiple times hasMore() may
+		 * return true when it's actually not true.
+		 */
+		if ( this.collection.hasMore() ) {
+			this.loadMoreButton.$el.removeClass( 'hidden' );
+		}
+
+		// Find the media item to move focus to. The jQuery `eq()` index is zero-based.
+		this.firstAddedMediaItem = this.$el.find( '.attachment' ).eq( this.firstAddedMediaItemIndex );
+
+		// If there's a media item to move focus to, make the "Jump to" button available.
+		if ( this.firstAddedMediaItem.length ) {
+			this.firstAddedMediaItem.addClass( 'new-media' );
+			this.loadMoreJumpToFirst.$el.removeClass( 'hidden' ).prop( 'disabled', false );
+		}
+
+		// If there are new items added, but no more to be added, move focus to Jump button.
+		if ( this.firstAddedMediaItem.length && ! this.collection.hasMore() ) {
+			this.loadMoreJumpToFirst.$el.trigger( 'focus' );
+		}
+	}, 10 ),
+
+	/**
+	 * Loads more attachments.
+	 *
+	 * @since 5.7.0
+	 *
+	 * @return {void}
+	 */
+	loadMoreAttachments: function() {
+		var view = this;
+
+		if ( ! this.collection.hasMore() ) {
+			return;
+		}
+
+		/*
+		 * The collection index is zero-based while the length counts the actual
+		 * amount of items. Thus the length is equivalent to the position of the
+		 * first added item.
+		 */
+		this.firstAddedMediaItemIndex = this.collection.length;
+
+		this.$el.addClass( 'more-loaded' );
+		this.collection.each( function( attachment ) {
+			var attach_id = attachment.attributes.id;
+			$( '[data-id="' + attach_id + '"]' ).addClass( 'found-media' );
+		});
+
+		view.loadMoreSpinner.show();
+
+		this.collection.more().done( function() {
+			// Within done(), `this` is the returned collection.
+			view.loadMoreSpinner.hide();
+		} );
+	},
+
+	/**
+	 * Moves focus to the first new added item.	.
+	 *
+	 * @since 5.7.0
+	 *
+	 * @return {void}
+	 */
+	jumpToFirstAddedItem: function() {
+		// Set focus on first added item.
+		this.firstAddedMediaItem.focus();
+	},
+
 	createAttachmentsHeading: function() {
 		this.attachmentsHeading = new wp.media.view.Heading( {
 			text: l10n.attachmentsList,
Index: src/js/media/views/attachments.js
===================================================================
--- src/js/media/views/attachments.js	(revision 50825)
+++ src/js/media/views/attachments.js	(working copy)
@@ -1,6 +1,7 @@
 var View = wp.media.View,
 	$ = jQuery,
-	Attachments;
+	Attachments,
+	infiniteScrolling = wp.media.view.settings.infiniteScrolling;
 
 Attachments = View.extend(/** @lends wp.media.view.Attachments.prototype */{
 	tagName:   'ul',
@@ -35,6 +36,8 @@
 		this.el.id = _.uniqueId('__attachments-view-');
 
 		/**
+		 * @param infiniteScrolling  Whether to enable infinite scrolling or use
+		 *                           the default "load more" button.
 		 * @param refreshSensitivity The time in milliseconds to throttle the scroll
 		 *                           handler.
 		 * @param refreshThreshold   The amount of pixels that should be scrolled before
@@ -49,6 +52,7 @@
 		 *                           calculating the total number of columns.
 		 */
 		_.defaults( this.options, {
+			infiniteScrolling:  infiniteScrolling || false,
 			refreshSensitivity: wp.media.isTouchDevice ? 300 : 200,
 			refreshThreshold:   3,
 			AttachmentView:     wp.media.view.Attachment,
@@ -84,11 +88,13 @@
 
 		this.controller.on( 'library:selection:add', this.attachmentFocus, this );
 
-		// Throttle the scroll handler and bind this.
-		this.scroll = _.chain( this.scroll ).bind( this ).throttle( this.options.refreshSensitivity ).value();
+		if ( this.options.infiniteScrolling ) {
+			// Throttle the scroll handler and bind this.
+			this.scroll = _.chain( this.scroll ).bind( this ).throttle( this.options.refreshSensitivity ).value();
 
-		this.options.scrollElement = this.options.scrollElement || this.el;
-		$( this.options.scrollElement ).on( 'scroll', this.scroll );
+			this.options.scrollElement = this.options.scrollElement || this.el;
+			$( this.options.scrollElement ).on( 'scroll', this.scroll );
+		}
 
 		this.initSortable();
 
@@ -387,7 +393,9 @@
 			this.views.set( this.collection.map( this.createAttachmentView, this ) );
 		} else {
 			this.views.unset();
-			this.collection.more().done( this.scroll );
+			if ( this.options.infiniteScrolling ) {
+				this.collection.more().done( this.scroll );
+			}
 		}
 	},
 
@@ -400,7 +408,9 @@
 	 * @return {void}
 	 */
 	ready: function() {
-		this.scroll();
+		if ( this.options.infiniteScrolling ) {
+			this.scroll();
+		}
 	},
 
 	/**
Index: src/wp-admin/css/media.css
===================================================================
--- src/wp-admin/css/media.css	(revision 50825)
+++ src/wp-admin/css/media.css	(working copy)
@@ -420,7 +420,8 @@
 
 .media-frame.mode-grid,
 .media-frame.mode-grid .media-frame-content,
-.media-frame.mode-grid .attachments-browser .attachments,
+.media-frame.mode-grid .attachments-browser:not(.has-load-more) .attachments,
+.media-frame.mode-grid .attachments-browser.has-load-more .attachments-wrapper,
 .media-frame.mode-grid .uploader-inline-content {
 	position: static;
 }
@@ -498,7 +499,8 @@
 	border: 4px dashed #c3c4c7;
 }
 
-.media-frame.mode-select .attachments-browser.fixed .attachments {
+.media-frame.mode-select .attachments-browser.fixed:not(.has-load-more) .attachments,
+.media-frame.mode-select .attachments-browser.has-load-more.fixed .attachments-wrapper {
 	position: relative;
 	top: 94px; /* prevent jumping up when the toolbar becomes fixed */
 	padding-bottom: 94px; /* offset for above so the bottom doesn't get cut off */
Index: src/wp-admin/includes/ajax-actions.php
===================================================================
--- src/wp-admin/includes/ajax-actions.php	(revision 50825)
+++ src/wp-admin/includes/ajax-actions.php	(working copy)
@@ -2993,7 +2993,12 @@
 	$posts = array_map( 'wp_prepare_attachment_for_js', $query->posts );
 	$posts = array_filter( $posts );
 
-	wp_send_json_success( $posts );
+	$result = array(
+		'attachments'      => $posts,
+		'totalAttachments' => $query->found_posts,
+	);
+
+	wp_send_json_success( $result );
 }
 
 /**
Index: src/wp-includes/css/media-views.css
===================================================================
--- src/wp-includes/css/media-views.css	(revision 50825)
+++ src/wp-includes/css/media-views.css	(working copy)
@@ -1188,7 +1188,8 @@
 	padding: 2px 8px 8px;
 }
 
-.attachments-browser .attachments,
+.attachments-browser:not(.has-load-more) .attachments,
+.attachments-browser.has-load-more .attachments-wrapper,
 .attachments-browser .uploader-inline {
 	position: absolute;
 	top: 72px;
@@ -1267,6 +1268,96 @@
 	padding: 2em 0 0 2em;
 }
 
+.more-loaded .attachment:not(.found-media) {
+	background: #dcdcde;
+}
+
+.load-more-wrapper {
+	clear: both;
+	display: flex;
+	flex-wrap: wrap;
+	align-items: center;
+	justify-content: center;
+	padding: 1em 0;
+}
+
+.load-more-wrapper .load-more-count {
+	min-width: 100%;
+	margin: 0 0 1em;
+	text-align: center;
+}
+
+.load-more-wrapper .load-more {
+	margin: 0;
+}
+
+/* Needs high specificity. */
+.media-frame .load-more-wrapper .load-more + .spinner {
+	float: none;
+	margin: 0 -30px 0 10px;
+}
+
+/* Reset spinner margin when the button is hidden to avoid horizontal scrollbar. */
+.media-frame .load-more-wrapper .load-more.hidden + .spinner {
+	margin: 0;
+}
+
+/* Force a new row within the flex container. */
+.load-more-wrapper::after {
+	content: "";
+	min-width: 100%;
+	order: 1;
+}
+
+.load-more-wrapper .load-more-jump {
+	margin: 0 0 0 12px;
+}
+
+.attachment.new-media {
+	outline: 2px dotted #c3c4c7;
+}
+
+.load-more-wrapper {
+	clear: both;
+	display: flex;
+	flex-wrap: wrap;
+	align-items: center;
+	justify-content: center;
+	padding: 1em 0;
+}
+
+.load-more-wrapper .load-more-count {
+	min-width: 100%;
+	margin: 0 0 1em;
+	text-align: center;
+}
+
+.load-more-wrapper .load-more {
+	margin: 0;
+}
+
+/* Needs high specificity. */
+.media-frame .load-more-wrapper .load-more + .spinner {
+	float: none;
+	margin: 0 -30px 0 10px;
+}
+
+/* Reset spinner margin when the button is hidden to avoid horizontal scrollbar. */
+.media-frame .load-more-wrapper .load-more.hidden + .spinner {
+	margin: 0;
+}
+
+/* Force a new row within the flex container. */
+.load-more-wrapper::after {
+	content: "";
+	min-width: 100%;
+	order: 1;
+}
+
+.load-more-wrapper .load-more-jump {
+	margin: 0 0 0 12px;
+}
+
 /**
  * Progress Bar
  */
@@ -2818,6 +2909,12 @@
 	.media-frame-content .media-toolbar .instructions {
 		display: none;
 	}
+
+	/* Change margin direction on load more button in responsive views. */
+	.load-more-wrapper .load-more-jump {
+		margin: 12px 0 0 0;
+	}
+
 }
 
 @media only screen and (min-width: 901px) and (max-height: 400px) {
@@ -2826,6 +2923,12 @@
 		top: 0;
 		padding-top: 44px;
 	}
+
+	/* Change margin direction on load more button in responsive views. */
+	.load-more-wrapper .load-more-jump {
+		margin: 12px 0 0 0;
+	}
+
 }
 
 @media only screen and (max-width: 480px) {
Index: src/wp-includes/media.php
===================================================================
--- src/wp-includes/media.php	(revision 50825)
+++ src/wp-includes/media.php	(working copy)
@@ -4306,29 +4306,39 @@
 		);
 	}
 
+	/**
+	 * Filters whether the Media Library grid has infinite scrolling. Default `false`.
+	 *
+	 * @since 5.7.0
+	 *
+	 * @param bool $value The filtered value, defaults to `false`.
+	 */
+	$infinite_scrolling = apply_filters( 'media_library_infinite_scrolling', false );
+
 	$settings = array(
-		'tabs'             => $tabs,
-		'tabUrl'           => add_query_arg( array( 'chromeless' => true ), admin_url( 'media-upload.php' ) ),
-		'mimeTypes'        => wp_list_pluck( get_post_mime_types(), 0 ),
+		'tabs'              => $tabs,
+		'tabUrl'            => add_query_arg( array( 'chromeless' => true ), admin_url( 'media-upload.php' ) ),
+		'mimeTypes'         => wp_list_pluck( get_post_mime_types(), 0 ),
 		/** This filter is documented in wp-admin/includes/media.php */
-		'captions'         => ! apply_filters( 'disable_captions', '' ),
-		'nonce'            => array(
+		'captions'          => ! apply_filters( 'disable_captions', '' ),
+		'nonce'             => array(
 			'sendToEditor' => wp_create_nonce( 'media-send-to-editor' ),
 		),
-		'post'             => array(
+		'post'              => array(
 			'id' => 0,
 		),
-		'defaultProps'     => $props,
-		'attachmentCounts' => array(
+		'defaultProps'      => $props,
+		'attachmentCounts'  => array(
 			'audio' => ( $show_audio_playlist ) ? 1 : 0,
 			'video' => ( $show_video_playlist ) ? 1 : 0,
 		),
-		'oEmbedProxyUrl'   => rest_url( 'oembed/1.0/proxy' ),
-		'embedExts'        => $exts,
-		'embedMimes'       => $ext_mimes,
-		'contentWidth'     => $content_width,
-		'months'           => $months,
-		'mediaTrash'       => MEDIA_TRASH ? 1 : 0,
+		'oEmbedProxyUrl'    => rest_url( 'oembed/1.0/proxy' ),
+		'embedExts'         => $exts,
+		'embedMimes'        => $ext_mimes,
+		'contentWidth'      => $content_width,
+		'months'            => $months,
+		'mediaTrash'        => MEDIA_TRASH ? 1 : 0,
+		'infiniteScrolling' => ( $infinite_scrolling ) ? 1 : 0,
 	);
 
 	$post = null;
@@ -4412,8 +4422,8 @@
 		'searchLabel'                 => __( 'Search' ),
 		'searchMediaLabel'            => __( 'Search media' ),          // Backward compatibility pre-5.3.
 		'searchMediaPlaceholder'      => __( 'Search media items...' ), // Placeholder (no ellipsis), backward compatibility pre-5.3.
+		/* translators: %d: Number of attachments found in a search. */
 		'mediaFound'                  => __( 'Number of media items found: %d' ),
-		'mediaFoundHasMoreResults'    => __( 'Number of media items displayed: %d. Scroll the page for more results.' ),
 		'noMedia'                     => __( 'No media items found.' ),
 		'noMediaTryNewSearch'         => __( 'No media items found. Try a different search.' ),
 
