diff --git a/packages/block-editor/src/components/inserter/media-tab/hooks.js b/packages/block-editor/src/components/inserter/media-tab/hooks.js
new file mode 100644
index 00000000000000..88a00923c74db3
--- /dev/null
+++ b/packages/block-editor/src/components/inserter/media-tab/hooks.js
@@ -0,0 +1,135 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { useEffect, useState } from '@wordpress/element';
+import { useSelect } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import { store as blockEditorStore } from '../../../store';
+
+// TODO: Even though the `mature` param is `false` by default, we might need to determine where
+// and if there is other 'weird' content like in the title. It happened for me to test with `skate`
+// and the first result contains a word in the title that might not be suitable for all users.
+async function fetchFromOpenverse( { search, pageSize = 10 } ) {
+ const controller = new AbortController();
+ const url = new URL( 'https://api.openverse.engineering/v1/images/' );
+ // TODO: add licence filters etc..
+ url.searchParams.set( 'q', search );
+ url.searchParams.set( 'page_size', pageSize );
+ const response = await window.fetch( url, {
+ headers: [ [ 'Content-Type', 'application/json' ] ],
+ signal: controller.signal,
+ } );
+ return response.json();
+}
+
+export function useMediaResults( category, options = {} ) {
+ const [ results, setResults ] = useState( [] );
+ const settings = useSelect(
+ ( select ) => select( blockEditorStore ).getSettings(),
+ []
+ );
+ const isOpenverse = category.name === 'openverse';
+ useEffect( () => {
+ ( async () => {
+ // TODO: add loader probably and not set results..
+ setResults( [] );
+ try {
+ if ( isOpenverse ) {
+ const response = await fetchFromOpenverse( options );
+ setResults( response.results );
+ } else {
+ const _media = await settings?.__unstableFetchMedia( {
+ per_page: 7,
+ media_type: category.mediaType,
+ } );
+ if ( _media ) setResults( _media );
+ }
+ } catch ( error ) {
+ // TODO: handle this
+ throw error;
+ }
+ } )();
+ }, [ category?.name, ...Object.values( options ) ] );
+
+ return results;
+}
+
+// TODO: Need to think of the props.. :)
+const MEDIA_CATEGORIES = [
+ { label: __( 'Images' ), name: 'images', mediaType: 'image' },
+ { label: __( 'Videos' ), name: 'videos', mediaType: 'video' },
+ { label: __( 'Audio' ), name: 'audio', mediaType: 'audio' },
+ { label: 'Openverse', name: 'openverse', mediaType: 'image' },
+];
+// TODO: definitely revisit the implementation and probably add a loader(return loading state)..
+export function useMediaCategories( rootClientId ) {
+ const [ categories, setCategories ] = useState( [] );
+ const { canInsertImage, canInsertVideo, canInsertAudio, fetchMedia } =
+ useSelect(
+ ( select ) => {
+ const { canInsertBlockType, getSettings } =
+ select( blockEditorStore );
+ return {
+ fetchMedia: getSettings().__unstableFetchMedia,
+ canInsertImage: canInsertBlockType(
+ 'core/image',
+ rootClientId
+ ),
+ canInsertVideo: canInsertBlockType(
+ 'core/video',
+ rootClientId
+ ),
+ canInsertAudio: canInsertBlockType(
+ 'core/audio',
+ rootClientId
+ ),
+ };
+ },
+ [ rootClientId ]
+ );
+ useEffect( () => {
+ ( async () => {
+ const query = {
+ context: 'view',
+ per_page: 1,
+ _fields: [ 'id' ],
+ };
+ const [ showImage, showVideo, showAudio ] = await Promise.all( [
+ canInsertImage &&
+ !! (
+ await fetchMedia( {
+ ...query,
+ media_type: 'image',
+ } )
+ ).length,
+ canInsertVideo &&
+ !! (
+ await fetchMedia( {
+ ...query,
+ media_type: 'video',
+ } )
+ ).length,
+ canInsertAudio &&
+ !! (
+ await fetchMedia( {
+ ...query,
+ media_type: 'audio',
+ } )
+ ).length,
+ ] );
+ setCategories(
+ MEDIA_CATEGORIES.filter(
+ ( { mediaType } ) =>
+ ( mediaType === 'image' && showImage ) ||
+ ( mediaType === 'video' && showVideo ) ||
+ ( mediaType === 'audio' && showAudio )
+ )
+ );
+ } )();
+ }, [ canInsertImage, canInsertVideo, canInsertAudio, fetchMedia ] );
+ return categories;
+}
diff --git a/packages/block-editor/src/components/inserter/media-tab/index.js b/packages/block-editor/src/components/inserter/media-tab/index.js
new file mode 100644
index 00000000000000..3c944c6c709eff
--- /dev/null
+++ b/packages/block-editor/src/components/inserter/media-tab/index.js
@@ -0,0 +1,2 @@
+export { default as MediaTab } from './media-tab';
+export { MediaCategoryDialog } from './media-panel';
diff --git a/packages/block-editor/src/components/inserter/media-tab/media-list.js b/packages/block-editor/src/components/inserter/media-tab/media-list.js
new file mode 100644
index 00000000000000..78e28a51af432d
--- /dev/null
+++ b/packages/block-editor/src/components/inserter/media-tab/media-list.js
@@ -0,0 +1,130 @@
+/**
+ * WordPress dependencies
+ */
+// import { useMemo, useCallback } from '@wordpress/element';
+// import { useInstanceId } from '@wordpress/compose';
+import {
+ __unstableComposite as Composite,
+ __unstableUseCompositeState as useCompositeState,
+ __unstableCompositeItem as CompositeItem,
+} from '@wordpress/components';
+import { createBlock } from '@wordpress/blocks';
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+/**
+ * Internal dependencies
+ */
+import InserterDraggableBlocks from '../../inserter-draggable-blocks';
+import BlockPreview from '../../block-preview';
+
+function getBlocksPreview( media, mediaType, isOpenverse ) {
+ let attributes;
+ // TODO: check all the needed attributes(alt, caption, etc..)
+ if ( mediaType === 'image' ) {
+ attributes = isOpenverse
+ ? { url: media.thumbnail || media.url }
+ : {
+ id: media.id,
+ url: media.source_url,
+ };
+ } else if ( mediaType === 'video' || mediaType === 'audio' ) {
+ attributes = {
+ id: media.id,
+ src: media.source_url,
+ };
+ }
+
+ const blocks = createBlock( `core/${ mediaType }`, attributes );
+ return blocks;
+}
+
+function MediaPreview( { media, onClick, composite, mediaType, isOpenverse } ) {
+ // TODO: Check caption or attribution, etc..
+ // TODO: create different blocks per media type..
+ const blocks = getBlocksPreview( media, mediaType, isOpenverse );
+ // TODO: we have to set a max height for previews as the image can be very tall.
+ // Probably a fixed-max height for all(?).
+ const title = media.title?.rendered || media.title;
+ const baseCssClass = 'block-editor-inserter__media-list';
+ // const descriptionId = useInstanceId(
+ // MediaPreview,
+ // `${ baseCssClass }__item-description`
+ // );
+ return (
+
+ { ( { draggable, onDragStart, onDragEnd } ) => (
+
+ {
+ // TODO: We need to handle the case with focus to image's caption
+ // during insertion. This makes the inserter to close.
+ onClick( blocks );
+ } }
+ aria-label={ title }
+ // aria-describedby={}
+ >
+
+