diff --git a/docs-ui/components/collapsible.stories.js b/docs-ui/components/collapsible.stories.js
new file mode 100644
index 00000000000000..7152098d73e795
--- /dev/null
+++ b/docs-ui/components/collapsible.stories.js
@@ -0,0 +1,41 @@
+import React from 'react';
+import {withInfo} from '@storybook/addon-info';
+import {number} from '@storybook/addon-knobs';
+
+import Button from 'app/components/button';
+import Collapsible from 'app/components/collapsible';
+import {tn} from 'app/locale';
+
+export default {
+ title: 'Utilities/Collapsible',
+};
+
+export const Default = withInfo(
+ 'This component is used to show first X items and collapse the rest'
+)(() => {
+ return (
+
+ {[1, 2, 3, 4, 5, 6, 7].map(item => (
+ Item {item}
+ ))}
+
+ );
+});
+
+export const CustomButtons = () => {
+ return (
+ }
+ expandButton={({onExpand, numberOfCollapsedItems}) => (
+
+ )}
+ >
+ {[1, 2, 3, 4, 5, 6, 7].map(item => (
+ Item {item}
+ ))}
+
+ );
+};
diff --git a/src/sentry/static/sentry/app/components/collapsible.tsx b/src/sentry/static/sentry/app/components/collapsible.tsx
new file mode 100644
index 00000000000000..71e041db76dcf5
--- /dev/null
+++ b/src/sentry/static/sentry/app/components/collapsible.tsx
@@ -0,0 +1,96 @@
+import React from 'react';
+
+import Button from 'app/components/button';
+import {t, tn} from 'app/locale';
+
+type CollapseButtonRenderProps = {
+ onCollapse: () => void;
+};
+
+type ExpandButtonRenderProps = {
+ onExpand: () => void;
+ numberOfCollapsedItems: number;
+};
+
+type DefaultProps = {
+ maxVisibleItems: number;
+};
+
+type Props = {
+ collapseButton?: (props: CollapseButtonRenderProps) => React.ReactNode;
+ expandButton?: (props: ExpandButtonRenderProps) => React.ReactNode;
+} & DefaultProps;
+
+type State = {
+ collapsed: boolean;
+};
+
+class Collapsible extends React.Component {
+ static defaultProps: DefaultProps = {
+ maxVisibleItems: 5,
+ };
+
+ state: State = {
+ collapsed: true,
+ };
+
+ handleCollapseToggle = () => {
+ this.setState(prevState => ({
+ collapsed: !prevState.collapsed,
+ }));
+ };
+
+ renderCollapseButton() {
+ const {collapseButton} = this.props;
+
+ if (typeof collapseButton === 'function') {
+ return collapseButton({onCollapse: this.handleCollapseToggle});
+ }
+
+ return (
+
+ );
+ }
+
+ renderExpandButton(numberOfCollapsedItems: number) {
+ const {expandButton} = this.props;
+
+ if (typeof expandButton === 'function') {
+ return expandButton({
+ onExpand: this.handleCollapseToggle,
+ numberOfCollapsedItems,
+ });
+ }
+
+ return (
+
+ );
+ }
+
+ render() {
+ const {maxVisibleItems, children} = this.props;
+ const {collapsed} = this.state;
+
+ const items = React.Children.toArray(children);
+ const canExpand = items.length > maxVisibleItems;
+ const itemsToRender =
+ collapsed && canExpand ? items.slice(0, maxVisibleItems) : items;
+ const numberOfCollapsedItems = items.length - itemsToRender.length;
+
+ return (
+
+ {itemsToRender}
+
+ {numberOfCollapsedItems > 0 && this.renderExpandButton(numberOfCollapsedItems)}
+
+ {numberOfCollapsedItems === 0 && canExpand && this.renderCollapseButton()}
+
+ );
+ }
+}
+
+export default Collapsible;
diff --git a/tests/js/spec/components/collapsible.spec.jsx b/tests/js/spec/components/collapsible.spec.jsx
new file mode 100644
index 00000000000000..c294e143f229c6
--- /dev/null
+++ b/tests/js/spec/components/collapsible.spec.jsx
@@ -0,0 +1,83 @@
+import React from 'react';
+
+import {mountWithTheme} from 'sentry-test/enzyme';
+
+import Button from 'app/components/button';
+import Collapsible from 'app/components/collapsible';
+
+const items = [1, 2, 3, 4, 5, 6, 7].map(i => Item {i}
);
+
+describe('Collapsible', function () {
+ it('collapses items', function () {
+ const wrapper = mountWithTheme({items});
+
+ expect(wrapper.find('div').length).toBe(5);
+ expect(wrapper.find('div').at(2).text()).toBe('Item 3');
+
+ expect(wrapper.find('button[aria-label="Show 2 collapsed items"]').text()).toBe(
+ 'Show 2 collapsed items'
+ );
+ expect(wrapper.find('button[aria-label="Collapse"]').exists()).toBeFalsy();
+ });
+
+ it('expands items', function () {
+ const wrapper = mountWithTheme({items});
+
+ // expand
+ wrapper.find('button[aria-label="Show 2 collapsed items"]').simulate('click');
+
+ expect(wrapper.find('div').length).toBe(7);
+
+ // collapse back
+ wrapper.find('button[aria-label="Collapse"]').simulate('click');
+
+ expect(wrapper.find('div').length).toBe(5);
+ });
+
+ it('respects maxVisibleItems prop', function () {
+ const wrapper = mountWithTheme(
+ {items}
+ );
+
+ expect(wrapper.find('div').length).toBe(2);
+ });
+
+ it('does not collapse items below threshold', function () {
+ const wrapper = mountWithTheme(
+ {items}
+ );
+
+ expect(wrapper.find('div').length).toBe(7);
+
+ expect(wrapper.find('button').exists()).toBeFalsy();
+ });
+
+ it('takes custom buttons', function () {
+ const wrapper = mountWithTheme(
+ (
+
+ )}
+ expandButton={({onExpand, numberOfCollapsedItems}) => (
+
+ )}
+ >
+ {items}
+
+ );
+
+ expect(wrapper.find('button').length).toBe(1);
+
+ // custom expand
+ wrapper.find('button[aria-label="Expand"]').simulate('click');
+
+ expect(wrapper.find('div').length).toBe(7);
+
+ // custom collapse back
+ wrapper.find('button[aria-label="Custom Collapse"]').simulate('click');
+
+ expect(wrapper.find('div').length).toBe(5);
+ });
+});