diff --git a/ui/app/components/list-accordion.js b/ui/app/components/list-accordion.js
new file mode 100644
index 0000000000000000000000000000000000000000..9e004645d2a3cd74f5051d5481b652c2cba9a6bd
--- /dev/null
+++ b/ui/app/components/list-accordion.js
@@ -0,0 +1,30 @@
+import Component from '@ember/component';
+import { computed, get } from '@ember/object';
+
+export default Component.extend({
+  classNames: ['accordion'],
+
+  key: 'id',
+  source: computed(() => []),
+
+  decoratedSource: computed('source.[]', function() {
+    const stateCache = this.get('stateCache');
+    const key = this.get('key');
+    const deepKey = `item.${key}`;
+
+    const decoratedSource = this.get('source').map(item => {
+      const cacheItem = stateCache.findBy(deepKey, get(item, key));
+      return {
+        item,
+        isOpen: cacheItem ? !!cacheItem.isOpen : false,
+      };
+    });
+
+    this.set('stateCache', decoratedSource);
+    return decoratedSource;
+  }),
+
+  // When source updates come in, the state cache is used to preserve
+  // open/close state.
+  stateCache: computed(() => []),
+});
diff --git a/ui/app/components/list-accordion/accordion-body.js b/ui/app/components/list-accordion/accordion-body.js
new file mode 100644
index 0000000000000000000000000000000000000000..32397fce6bd460f9f1c301ca1974afca5581c93e
--- /dev/null
+++ b/ui/app/components/list-accordion/accordion-body.js
@@ -0,0 +1,6 @@
+import Component from '@ember/component';
+
+export default Component.extend({
+  tagName: '',
+  isOpen: false,
+});
diff --git a/ui/app/components/list-accordion/accordion-head.js b/ui/app/components/list-accordion/accordion-head.js
new file mode 100644
index 0000000000000000000000000000000000000000..4de6d4edca790235ebee5fbf44f14b37ac47c57d
--- /dev/null
+++ b/ui/app/components/list-accordion/accordion-head.js
@@ -0,0 +1,14 @@
+import Component from '@ember/component';
+
+export default Component.extend({
+  classNames: ['accordion-head'],
+  classNameBindings: ['isOpen::is-light', 'isExpandable::is-inactive'],
+
+  buttonLabel: 'toggle',
+  isOpen: false,
+  isExpandable: true,
+  item: null,
+
+  onClose() {},
+  onOpen() {},
+});
diff --git a/ui/app/styles/components.scss b/ui/app/styles/components.scss
index 49b5138066673a612588e2b7eda5b70d6a7a13e3..83efbb6763b0df90c0e041155ca50f9f44da1ba5 100644
--- a/ui/app/styles/components.scss
+++ b/ui/app/styles/components.scss
@@ -1,3 +1,4 @@
+@import './components/accordion';
 @import './components/badge';
 @import './components/boxed-section';
 @import './components/cli-window';
diff --git a/ui/app/styles/components/accordion.scss b/ui/app/styles/components/accordion.scss
new file mode 100644
index 0000000000000000000000000000000000000000..17ca8bcc51937da1f6861f1459e2b346daace332
--- /dev/null
+++ b/ui/app/styles/components/accordion.scss
@@ -0,0 +1,42 @@
+.accordion {
+  .accordion-head,
+  .accordion-body {
+    border: 1px solid $grey-blue;
+    border-bottom: none;
+    padding: 0.75em 1.5em;
+
+    &:first-child {
+      border-top-left-radius: $radius;
+      border-top-right-radius: $radius;
+    }
+
+    &:last-child {
+      border-bottom: 1px solid $grey-blue;
+      border-bottom-left-radius: $radius;
+      border-bottom-right-radius: $radius;
+    }
+  }
+
+  .accordion-head {
+    display: flex;
+    background: $white-ter;
+    flex: 1;
+
+    &.is-light {
+      background: $white;
+    }
+
+    &.is-inactive {
+      color: $grey-light;
+    }
+
+    .accordion-head-content {
+      width: 100%;
+    }
+
+    .accordion-toggle {
+      flex-basis: 0;
+      white-space: nowrap;
+    }
+  }
+}
diff --git a/ui/app/templates/components/list-accordion.hbs b/ui/app/templates/components/list-accordion.hbs
new file mode 100644
index 0000000000000000000000000000000000000000..67678cef290cd323cd85ab0018bf7d68f789f82b
--- /dev/null
+++ b/ui/app/templates/components/list-accordion.hbs
@@ -0,0 +1,10 @@
+{{#each decoratedSource as |item|}}
+  {{yield (hash
+    head=(component "list-accordion/accordion-head"
+      isOpen=item.isOpen
+      onOpen=(action (mut item.isOpen) true)
+      onClose=(action (mut item.isOpen) false))
+    body=(component "list-accordion/accordion-body" isOpen=item.isOpen)
+    item=item.item
+  )}}
+{{/each}}
diff --git a/ui/app/templates/components/list-accordion/accordion-body.hbs b/ui/app/templates/components/list-accordion/accordion-body.hbs
new file mode 100644
index 0000000000000000000000000000000000000000..894e6681894def0535e8e10f38510360e7550634
--- /dev/null
+++ b/ui/app/templates/components/list-accordion/accordion-body.hbs
@@ -0,0 +1,5 @@
+{{#if isOpen}}
+  <div class="accordion-body">
+    {{yield}}
+  </div>
+{{/if}}
diff --git a/ui/app/templates/components/list-accordion/accordion-head.hbs b/ui/app/templates/components/list-accordion/accordion-head.hbs
new file mode 100644
index 0000000000000000000000000000000000000000..a362b1165a7ce8f47bd5c198d3fb068264fed904
--- /dev/null
+++ b/ui/app/templates/components/list-accordion/accordion-head.hbs
@@ -0,0 +1,8 @@
+<div class="accordion-head-content">
+  {{yield}}
+</div>
+<button
+  class="button is-light is-compact pull-right accordion-toggle {{unless isExpandable "is-invisible"}}"
+  onclick={{action (if isOpen onClose onOpen) item}}>
+  {{buttonLabel}}
+</button>