diff --git a/app/assets/javascripts/vue_shared/components/empty_result.stories.js b/app/assets/javascripts/vue_shared/components/empty_result.stories.js
new file mode 100644
index 0000000000000000000000000000000000000000..0571ed47b561522b11aebd26b430c495b760eed5
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/empty_result.stories.js
@@ -0,0 +1,23 @@
+import EmptyResult, { TYPES } from './empty_result.vue';
+
+export default {
+ component: EmptyResult,
+ title: 'vue_shared/empty_result',
+ argTypes: {
+ type: {
+ control: 'select',
+ options: Object.values(TYPES),
+ },
+ },
+};
+
+const Template = (args, { argTypes }) => ({
+ components: { EmptyResult },
+ props: Object.keys(argTypes),
+ template: ``,
+});
+
+export const Default = Template.bind({});
+Default.args = {
+ type: TYPES.search,
+};
diff --git a/app/assets/javascripts/vue_shared/components/empty_result.vue b/app/assets/javascripts/vue_shared/components/empty_result.vue
new file mode 100644
index 0000000000000000000000000000000000000000..ab13b32caeecf37d543c9a67179c9a842b473f1f
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/empty_result.vue
@@ -0,0 +1,51 @@
+
+
+
+
+
diff --git a/app/components/layouts/empty_result_component.haml b/app/components/layouts/empty_result_component.haml
new file mode 100644
index 0000000000000000000000000000000000000000..3b86a31761419904dd200f88690606fbb77222ef
--- /dev/null
+++ b/app/components/layouts/empty_result_component.haml
@@ -0,0 +1,10 @@
+- title = _('No results found')
+- description = filter? ? _('To widen your search, change or remove filters above.') : _('Edit your search and try again.')
+- svg_path = 'illustrations/empty-state/empty-search-md.svg'
+
+= render Pajamas::EmptyStateComponent.new(svg_path: svg_path,
+ title: title,
+ empty_state_options: { **@html_options }) do |c|
+
+ - c.with_description do
+ = description
diff --git a/app/components/layouts/empty_result_component.rb b/app/components/layouts/empty_result_component.rb
new file mode 100644
index 0000000000000000000000000000000000000000..08aa5379beea8aa6ba737f8ade452902aa34adf4
--- /dev/null
+++ b/app/components/layouts/empty_result_component.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Layouts
+ class EmptyResultComponent < Pajamas::Component
+ TYPE_OPTIONS = [:search, :filter].freeze
+
+ # @param [Symbol] type
+ # @param [Hash] html_options
+ def initialize(
+ type: :search,
+ **html_options
+ )
+ @type = filter_attribute(type.to_sym, TYPE_OPTIONS, default: :search)
+ @html_options = html_options
+ end
+
+ def filter?
+ @type == :filter
+ end
+
+ def html_options
+ format_options(options: @html_options)
+ end
+ end
+end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 11d5e8e02f6655c112d64d3265fcaeb6912b7e7e..d865e884c08ec4fd0f0a91929c3eeafac679435d 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -20466,6 +20466,9 @@ msgstr ""
msgid "Edit your search and try again"
msgstr ""
+msgid "Edit your search and try again."
+msgstr ""
+
msgid "Edit your search filter and try again."
msgstr ""
diff --git a/spec/components/layouts/empty_result_component_spec.rb b/spec/components/layouts/empty_result_component_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..4bd7f92de1984117138dc97bf01a35301318fd75
--- /dev/null
+++ b/spec/components/layouts/empty_result_component_spec.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Layouts::EmptyResultComponent, type: :component, feature_category: :shared do
+ let(:type) { :search }
+ let(:html_options) { { data: { testid: 'empty-result-test-id' } } }
+
+ before do
+ render_inline described_class.new(type: type, **html_options)
+ end
+
+ it 'renders search empty result' do
+ expect(page).to have_css('.gl-empty-state', text: 'No results found')
+ expect(page).to have_css('.gl-empty-state', text: 'Edit your search and try again.')
+ end
+
+ it 'renders custom attributes' do
+ expect(page).to have_css('[data-testid="empty-result-test-id"]')
+ end
+
+ context 'when type is filter' do
+ let(:type) { :filter }
+
+ it 'renders empty result' do
+ expect(page).to have_css('.gl-empty-state', text: 'No results found')
+ expect(page).to have_css('.gl-empty-state', text: 'To widen your search, change or remove filters above.')
+ end
+ end
+end
diff --git a/spec/components/previews/layouts/empty_result_component_preview.rb b/spec/components/previews/layouts/empty_result_component_preview.rb
new file mode 100644
index 0000000000000000000000000000000000000000..52b53cd682c86db6f1432db758b68f77b6fd873e
--- /dev/null
+++ b/spec/components/previews/layouts/empty_result_component_preview.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Layouts
+ class EmptyResultComponentPreview < ViewComponent::Preview
+ # @param type select {{ Layouts::EmptyResultComponent::TYPE_OPTIONS }}
+
+ def default(type: :search)
+ render(::Layouts::EmptyResultComponent.new(
+ type: type
+ ))
+ end
+ end
+end
diff --git a/spec/frontend/vue_shared/components/empty_result_spec.js b/spec/frontend/vue_shared/components/empty_result_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..a9e18d7ed4db8cc48d136fc942258c0108aaedc6
--- /dev/null
+++ b/spec/frontend/vue_shared/components/empty_result_spec.js
@@ -0,0 +1,36 @@
+import emptyStateSvgPath from '@gitlab/svgs/dist/illustrations/empty-state/empty-search-md.svg';
+import { GlEmptyState } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import EmptyResult from '~/vue_shared/components/empty_result.vue';
+
+describe('Empty result', () => {
+ let wrapper;
+
+ const createComponent = (props) => {
+ wrapper = shallowMount(EmptyResult, {
+ propsData: props,
+ });
+ };
+
+ const findEmptyState = () => wrapper.findComponent(GlEmptyState);
+
+ it('renders empty search state', () => {
+ createComponent({ type: 'search' });
+
+ expect(findEmptyState().props()).toMatchObject({
+ svgPath: emptyStateSvgPath,
+ title: 'No results found',
+ description: 'Edit your search and try again.',
+ });
+ });
+
+ it('renders empty filter state', () => {
+ createComponent({ type: 'filter' });
+
+ expect(findEmptyState().props()).toMatchObject({
+ svgPath: emptyStateSvgPath,
+ title: 'No results found',
+ description: 'To widen your search, change or remove filters above.',
+ });
+ });
+});