diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss
index 9a15505dd25fc3c26b8202e171a605b231808afe..4cbb2f5ba71ba71f82ab3aa1758fd57b5e180115 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -515,6 +515,12 @@ img.emoji {
   cursor: pointer;
 }
 
+// this needs to use "!important" due to some very specific styles
+// around buttons
+.cursor-default {
+  cursor: default !important;
+}
+
 // Make buttons/dropdowns full-width on mobile
 .full-width-mobile {
   @include media-breakpoint-down(xs) {
diff --git a/ee/app/assets/javascripts/vue_shared/components/accordion/accordion.vue b/ee/app/assets/javascripts/vue_shared/components/accordion/accordion.vue
new file mode 100644
index 0000000000000000000000000000000000000000..975f7a578b310eb4c533ccfbe6bd346f65485985
--- /dev/null
+++ b/ee/app/assets/javascripts/vue_shared/components/accordion/accordion.vue
@@ -0,0 +1,16 @@
+<script>
+import { uniqueId } from 'underscore';
+
+export default {
+  created() {
+    this.uniqueId = uniqueId('accordion-');
+  },
+};
+</script>
+<template>
+  <div>
+    <ul class="list-group list-group-flush py-2">
+      <slot :accordion-id="uniqueId"></slot>
+    </ul>
+  </div>
+</template>
diff --git a/ee/app/assets/javascripts/vue_shared/components/accordion/accordion_event_bus.js b/ee/app/assets/javascripts/vue_shared/components/accordion/accordion_event_bus.js
new file mode 100644
index 0000000000000000000000000000000000000000..0674bc043ae7692a55eea09d083855cb85d82735
--- /dev/null
+++ b/ee/app/assets/javascripts/vue_shared/components/accordion/accordion_event_bus.js
@@ -0,0 +1,5 @@
+import Vue from 'vue';
+
+const accordionEventBus = new Vue();
+
+export default accordionEventBus;
diff --git a/ee/app/assets/javascripts/vue_shared/components/accordion/accordion_item.vue b/ee/app/assets/javascripts/vue_shared/components/accordion/accordion_item.vue
new file mode 100644
index 0000000000000000000000000000000000000000..1e4c298e59277f867bb67b9131afca899d9a3766
--- /dev/null
+++ b/ee/app/assets/javascripts/vue_shared/components/accordion/accordion_item.vue
@@ -0,0 +1,142 @@
+<script>
+import { uniqueId } from 'underscore';
+import { GlSkeletonLoader } from '@gitlab/ui';
+
+import Icon from '~/vue_shared/components/icon.vue';
+
+import accordionEventBus from './accordion_event_bus';
+
+const accordionItemUniqueId = name => uniqueId(`gl-accordion-item-${name}-`);
+
+export default {
+  components: {
+    GlSkeletonLoader,
+    Icon,
+  },
+  props: {
+    accordionId: {
+      type: String,
+      required: true,
+    },
+    disabled: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
+    maxHeight: {
+      type: String,
+      required: false,
+      default: '',
+    },
+    isLoading: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
+  },
+  data() {
+    return {
+      isExpanded: false,
+    };
+  },
+  computed: {
+    contentStyles() {
+      return {
+        maxHeight: this.maxHeight,
+        overflow: 'auto',
+      };
+    },
+    isDisabled() {
+      return this.disabled || !this.hasContent;
+    },
+    hasContent() {
+      return this.$scopedSlots.default !== undefined;
+    },
+  },
+  created() {
+    this.buttonId = accordionItemUniqueId('trigger');
+    this.contentContainerId = accordionItemUniqueId('content-container');
+    // create a unique event name so multiple accordion instances don't close each other items
+    this.closeOtherItemsEvent = `${this.accordionId}.closeOtherAccordionItems`;
+
+    accordionEventBus.$on(this.closeOtherItemsEvent, this.onCloseOtherAccordionItems);
+  },
+  destroyed() {
+    accordionEventBus.$off(this.closeOtherItemsEvent);
+  },
+  methods: {
+    onCloseOtherAccordionItems(trigger) {
+      if (trigger !== this) {
+        this.collapse();
+      }
+    },
+    handleClick() {
+      if (this.isExpanded) {
+        this.collapse();
+      } else {
+        this.expand();
+      }
+      accordionEventBus.$emit(this.closeOtherItemsEvent, this);
+    },
+    expand() {
+      this.isExpanded = true;
+    },
+    collapse() {
+      this.isExpanded = false;
+    },
+  },
+};
+</script>
+
+<template>
+  <li class="list-group-item p-0">
+    <template v-if="!isLoading">
+      <div class="d-flex align-items-stretch">
+        <button
+          :id="buttonId"
+          ref="expansionTrigger"
+          type="button"
+          :disabled="isDisabled"
+          :aria-expanded="isExpanded"
+          :aria-controls="contentContainerId"
+          class="btn-transparent border-0 rounded-0 w-100 p-0 text-left"
+          :class="{ 'cursor-default': isDisabled }"
+          @click="handleClick"
+        >
+          <div
+            class="d-flex align-items-center p-2"
+            :class="{ 'list-group-item-action': !isDisabled }"
+          >
+            <icon
+              :size="16"
+              class="mr-2 gl-text-gray-900"
+              :name="isExpanded ? 'angle-down' : 'angle-right'"
+            />
+            <span
+              ><slot name="title" :is-expanded="isExpanded" :is-disabled="isDisabled"></slot
+            ></span>
+          </div>
+        </button>
+      </div>
+      <div
+        v-show="isExpanded"
+        :id="contentContainerId"
+        ref="contentContainer"
+        :aria-labelledby="buttonId"
+        role="region"
+      >
+        <slot name="subTitle"></slot>
+        <div ref="content" :style="contentStyles"><slot name="default"></slot></div>
+      </div>
+    </template>
+    <div v-else ref="loadingIndicator" class="d-flex p-2">
+      <div class="h-32-px">
+        <gl-skeleton-loader :height="32">
+          <rect width="12" height="16" rx="4" x="0" y="8" />
+          <circle cx="37" cy="15" r="15" />
+          <rect width="20" height="16" rx="4" x="63" y="8" />
+        </gl-skeleton-loader>
+      </div>
+    </div>
+  </li>
+</template>
diff --git a/ee/app/assets/javascripts/vue_shared/components/accordion/index.js b/ee/app/assets/javascripts/vue_shared/components/accordion/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..596bd3ac5a418cbc26ce2cb4d38304822d019109
--- /dev/null
+++ b/ee/app/assets/javascripts/vue_shared/components/accordion/index.js
@@ -0,0 +1,2 @@
+export { default as Accordion } from './accordion.vue';
+export { default as AccordionItem } from './accordion_item.vue';
diff --git a/ee/spec/frontend/vue_shared/components/accordion/accordion_event_bus_spec.js b/ee/spec/frontend/vue_shared/components/accordion/accordion_event_bus_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..20a398db21500a226ce762b7b01d8862d87a74ca
--- /dev/null
+++ b/ee/spec/frontend/vue_shared/components/accordion/accordion_event_bus_spec.js
@@ -0,0 +1,8 @@
+import Vue from 'vue';
+import accordionEventBus from 'ee/vue_shared/components/accordion/accordion_event_bus';
+
+describe('Accordion event bus', () => {
+  it('default exports a vue instance', () => {
+    expect(accordionEventBus instanceof Vue).toBe(true);
+  });
+});
diff --git a/ee/spec/frontend/vue_shared/components/accordion/accordion_item_spec.js b/ee/spec/frontend/vue_shared/components/accordion/accordion_item_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..30ec29a5c224684b100f8eba9fcb90a68d5e4830
--- /dev/null
+++ b/ee/spec/frontend/vue_shared/components/accordion/accordion_item_spec.js
@@ -0,0 +1,213 @@
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import { uniqueId } from 'underscore';
+
+import { AccordionItem } from 'ee/vue_shared/components/accordion';
+import accordionEventBus from 'ee/vue_shared/components/accordion/accordion_event_bus';
+
+jest.mock('ee/vue_shared/components/accordion/accordion_event_bus', () => ({
+  $on: jest.fn(),
+  $emit: jest.fn(),
+  $off: jest.fn(),
+}));
+
+jest.mock('underscore');
+
+const localVue = createLocalVue();
+
+describe('AccordionItem component', () => {
+  const mockUniqueId = 'mockUniqueId';
+  const accordionId = 'accordionID';
+
+  let wrapper;
+
+  const factory = ({ propsData = {}, defaultSlot = `<p></p>`, titleSlot = `<p></p>` } = {}) => {
+    const defaultPropsData = {
+      accordionId,
+      isLoading: false,
+      maxHeight: '',
+    };
+
+    wrapper = shallowMount(AccordionItem, {
+      localVue,
+      sync: false,
+      propsData: {
+        ...defaultPropsData,
+        ...propsData,
+      },
+      scopedSlots: {
+        default: defaultSlot,
+        title: titleSlot,
+      },
+    });
+  };
+
+  const loadingIndicator = () => wrapper.find({ ref: 'loadingIndicator' });
+  const expansionTrigger = () => wrapper.find({ ref: 'expansionTrigger' });
+  const contentContainer = () => wrapper.find({ ref: 'contentContainer' });
+  const content = () => wrapper.find({ ref: 'content' });
+  const namespacedCloseOtherAccordionItemsEvent = `${accordionId}.closeOtherAccordionItems`;
+
+  beforeEach(() => {
+    uniqueId.mockReturnValue(mockUniqueId);
+  });
+
+  afterEach(() => {
+    wrapper.destroy();
+    wrapper = null;
+
+    jest.clearAllMocks();
+  });
+
+  describe('rendering options', () => {
+    it('does not show a loading indicator if the "isLoading" prop is set to "false"', () => {
+      factory({ propsData: { isLoading: false } });
+
+      expect(loadingIndicator().exists()).toBe(false);
+    });
+
+    it('shows a loading indicator if the "isLoading" prop is set to "true"', () => {
+      factory({ propsData: { isLoading: true } });
+
+      expect(loadingIndicator().exists()).toBe(true);
+    });
+
+    it('does not limit the content height per default', () => {
+      factory();
+
+      expect(contentContainer().element.style.maxHeight).toBeFalsy();
+    });
+
+    it('has "maxHeight" prop that limits the height of the content container to the given value', () => {
+      factory({ propsData: { maxHeight: '200px' } });
+
+      expect(content().element.style.maxHeight).toBe('200px');
+    });
+  });
+
+  describe('scoped slots', () => {
+    it.each(['default', 'title'])("contains a '%s' slot", slotName => {
+      const className = `${slotName}-slot-content`;
+
+      factory({ [`${slotName}Slot`]: `<div class='${className}' />` });
+
+      expect(wrapper.find(`.${className}`).exists()).toBe(true);
+    });
+
+    it('contains a default slot', () => {
+      factory({ defaultSlot: `<div class='foo' />` });
+      expect(wrapper.find(`.foo`).exists()).toBe(true);
+    });
+
+    it.each([true, false])(
+      'passes the "isExpanded" and "isDisabled" state to the title slot',
+      state => {
+        const titleSlot = jest.fn();
+
+        factory({ propsData: { disabled: state }, titleSlot });
+        wrapper.vm.isExpanded = state;
+
+        return wrapper.vm.$nextTick().then(() => {
+          expect(titleSlot).toHaveBeenCalledWith({
+            isExpanded: state,
+            isDisabled: state,
+          });
+        });
+      },
+    );
+  });
+
+  describe('collapsing and expanding', () => {
+    beforeEach(factory);
+
+    it('is collapsed per default', () => {
+      expect(contentContainer().isVisible()).toBe(false);
+    });
+
+    it('expands when the trigger-element gets clicked', () => {
+      expect(contentContainer().isVisible()).toBe(false);
+
+      expansionTrigger().trigger('click');
+
+      return wrapper.vm.$nextTick().then(() => {
+        expect(contentContainer().isVisible()).toBe(true);
+      });
+    });
+
+    it('emits a namespaced "closeOtherAccordionItems" event, containing the trigger item as a payload', () => {
+      expansionTrigger().trigger('click');
+
+      expect(accordionEventBus.$emit).toHaveBeenCalledTimes(1);
+      expect(accordionEventBus.$emit).toHaveBeenCalledWith(
+        namespacedCloseOtherAccordionItemsEvent,
+        wrapper.vm,
+      );
+    });
+
+    it('subscribes "onCloseOtherAccordionItems" as handler to the namespaced "closeOtherAccordionItems" event', () => {
+      expect(accordionEventBus.$on).toHaveBeenCalledTimes(1);
+      expect(accordionEventBus.$on).toHaveBeenCalledWith(
+        namespacedCloseOtherAccordionItemsEvent,
+        wrapper.vm.onCloseOtherAccordionItems,
+      );
+    });
+
+    it('collapses if "closeOtherAccordionItems" is called with the trigger not being the current item', () => {
+      wrapper.setData({ isExpanded: true });
+      wrapper.vm.onCloseOtherAccordionItems({});
+
+      expect(wrapper.vm.isExpanded).toBe(false);
+    });
+
+    it('does not collapses if "closeOtherAccordionItems" is called with the trigger being the current item', () => {
+      wrapper.setData({ isExpanded: true });
+      wrapper.vm.onCloseOtherAccordionItems(wrapper.vm);
+
+      expect(wrapper.vm.isExpanded).toBe(true);
+    });
+
+    it('unsubscribes from namespaced "closeOtherAccordionItems" when the component is destroyed', () => {
+      wrapper.destroy();
+      expect(accordionEventBus.$off).toHaveBeenCalledTimes(1);
+      expect(accordionEventBus.$off).toHaveBeenCalledWith(namespacedCloseOtherAccordionItemsEvent);
+    });
+  });
+
+  describe('accessibility', () => {
+    beforeEach(factory);
+
+    it('contains a expansion trigger element with a unique, namespaced id', () => {
+      expect(uniqueId).toHaveBeenCalledWith('gl-accordion-item-trigger-');
+
+      expect(expansionTrigger().attributes('id')).toBe('mockUniqueId');
+    });
+
+    it('contains a content-container element with a unique, namespaced id', () => {
+      expect(uniqueId).toHaveBeenCalledWith('gl-accordion-item-content-container-');
+      expect(contentContainer().attributes('id')).toBe(mockUniqueId);
+    });
+
+    it('has a trigger element that has an "aria-expanded" attribute set, to show if it is expanded or collapsed', () => {
+      expect(expansionTrigger().attributes('aria-expanded')).toBeFalsy();
+
+      wrapper.setData({ isExpanded: true });
+
+      return wrapper.vm.$nextTick().then(() => {
+        expect(expansionTrigger().attributes('aria-expanded')).toBe('true');
+      });
+    });
+
+    it('has a trigger element that has a "aria-controls" attribute, which points to the content element', () => {
+      expect(expansionTrigger().attributes('aria-controls')).toBeTruthy();
+      expect(expansionTrigger().attributes('aria-controls')).toBe(
+        contentContainer().attributes('id'),
+      );
+    });
+
+    it('has a content-container element that has a "aria-labelledby" attribute, which points to the trigger element', () => {
+      expect(contentContainer().attributes('aria-labelledby')).toBeTruthy();
+      expect(contentContainer().attributes('aria-labelledby')).toBe(
+        expansionTrigger().attributes('id'),
+      );
+    });
+  });
+});
diff --git a/ee/spec/frontend/vue_shared/components/accordion/accordion_spec.js b/ee/spec/frontend/vue_shared/components/accordion/accordion_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..be6b892c6886022fa60d4579239324d56382f767
--- /dev/null
+++ b/ee/spec/frontend/vue_shared/components/accordion/accordion_spec.js
@@ -0,0 +1,47 @@
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import { Accordion } from 'ee/vue_shared/components/accordion';
+
+import { uniqueId } from 'underscore';
+
+jest.mock('underscore');
+
+const localVue = createLocalVue();
+
+describe('Accordion component', () => {
+  let wrapper;
+  const factory = ({ defaultSlot = '' } = {}) => {
+    wrapper = shallowMount(Accordion, {
+      localVue,
+      sync: false,
+      scopedSlots: {
+        default: defaultSlot,
+      },
+    });
+  };
+
+  afterEach(() => {
+    wrapper.destroy();
+    wrapper = null;
+
+    jest.clearAllMocks();
+  });
+
+  it('contains a default slot', () => {
+    const defaultSlot = `<span class="content"></span>`;
+
+    factory({ defaultSlot });
+
+    expect(wrapper.find('.content').exists()).toBe(true);
+  });
+
+  it('passes a unique "accordionId" to the default slot', () => {
+    const mockUniqueIdValue = 'foo';
+    uniqueId.mockReturnValueOnce(mockUniqueIdValue);
+
+    const defaultSlot = '<span>{{ props.accordionId }}</span>';
+
+    factory({ defaultSlot });
+
+    expect(wrapper.text()).toContain(mockUniqueIdValue);
+  });
+});