diff --git a/app/assets/stylesheets/lazy_bundles/gridstack.scss b/app/assets/stylesheets/lazy_bundles/gridstack.scss
new file mode 100644
index 0000000000000000000000000000000000000000..235b225d7479e0ac80c84b3345d4e1fb38fe5886
--- /dev/null
+++ b/app/assets/stylesheets/lazy_bundles/gridstack.scss
@@ -0,0 +1 @@
+@import 'gridstack/dist/gridstack';
diff --git a/config/application.rb b/config/application.rb
index d45e0bab54d955abb81d1de6e2c02d34e293bd6a..368036ce0649f16ff30499a1d19714487dc13b6b 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -324,6 +324,7 @@ class Application < Rails::Application
     config.assets.precompile << "page_bundles/xterm.css"
     config.assets.precompile << "lazy_bundles/cropper.css"
     config.assets.precompile << "lazy_bundles/select2.css"
+    config.assets.precompile << "lazy_bundles/gridstack.css"
     config.assets.precompile << "performance_bar.css"
     config.assets.precompile << "disable_animations.css"
     config.assets.precompile << "test_environment.css"
diff --git a/doc/development/fe_guide/customizable_dashboards.md b/doc/development/fe_guide/customizable_dashboards.md
new file mode 100644
index 0000000000000000000000000000000000000000..38ee750d4210d6ad148f5b476d06971ae3839a3a
--- /dev/null
+++ b/doc/development/fe_guide/customizable_dashboards.md
@@ -0,0 +1,99 @@
+---
+stage: Analytics
+group: Product Analytics
+info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/product/ux/technical-writing/#assignments
+---
+
+# Customizable dashboards **(PREMIUM)**
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/98610) in GitLab 15.5 as an [Alpha feature](../../policy/alpha-beta-support.md#alpha-features).
+
+Customizable dashboards provide a dashboard structure that allows users to create
+their own dashboards and commit the structure to a repository.
+
+## Usage
+
+To use customizable dashboards:
+
+1. Create your dashboard component.
+1. Render an instance of `CustomizableDashboard`.
+1. Pass a list of widgets to render.
+
+For example, a customizable dashboard for users over time:
+
+```vue
+<script>
+import CustomizableDashboard from 'ee/vue_shared/components/customizable_dashboard/customizable_dashboard.vue';
+import { s__ } from '~/locale';
+
+export default {
+  name: 'AnalyticsDashboard',
+  components: {
+    CustomizableDashboard,
+  },
+  data() {
+    return {
+      widgets: [
+        {
+          component: 'CubeLineChart', // The name of the widget component.
+          title: s__('ProductAnalytics|Users / Time'), // The title shown on the widget component.
+          // Gridstack settings based upon https://github.com/gridstack/gridstack.js/tree/master/doc#item-options.
+          // All values are grid row/column numbers up to 12.
+          // We use the default 12 column grid https://github.com/gridstack/gridstack.js#change-grid-columns.
+          gridAttributes: {
+            size: {
+              height: 4,
+              width: 6,
+              minHeight: 4,
+              minWidth: 6,
+            },
+            position: {
+              xPos: 0,
+              yPos: 0,
+            },
+          },
+          // Options that are used to set bespoke values for each widget.
+          // Available customizations are determined by the widget itself.
+          customizations: {},
+          // Chart options defined by the charting library being used by the widget.
+          chartOptions: {
+            xAxis: { name: __('Time'), type: 'time' },
+            yAxis: { name: __('Counts') },
+          },
+          // The data for the widget.
+          // This could be imported or in this case, a query passed to be used by the widgets API.
+          // Each widget type determines how it handles this property.
+          data: {
+            query: {
+              users: {
+                measures: ['Jitsu.count'],
+                dimensions: ['Jitsu.eventType'],
+              },
+            },
+          },
+        },
+      ]
+    };
+  },
+};
+</script>
+
+<template>
+  <h1>{{ s__('ProductAnalytics|Analytics dashboard') }}</h1>
+  <customizable-dashboard :widgets="widgets" />
+</template>
+```
+
+The widgets data can be retrieved from a file or API request, or imported through HTML data attributes.
+
+For each widget, a `component` is defined. Each `component` is a component declaration and should be included in
+[`vue_shared/components/customizable_dashboard/widgets_base.vue`](https://gitlab.com/gitlab-org/gitlab/blob/master/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/widgets_base.vue)
+as a dynamic import, to keep the memory usage down until it is used.
+
+For example:
+
+```javascript
+components: {
+  CubeLineChart: () => import('ee/product_analytics/dashboards/components/widgets/cube_line_chart.vue')
+}
+```
diff --git a/ee/app/assets/javascripts/product_analytics/dashboards/components/analytics_dashboard.vue b/ee/app/assets/javascripts/product_analytics/dashboards/components/analytics_dashboard.vue
index 27d83815fe3361355f617c0b84fc67b350a64692..dd6e77994a1d77efe3d1ac4dd8b5e77007cbf8f4 100644
--- a/ee/app/assets/javascripts/product_analytics/dashboards/components/analytics_dashboard.vue
+++ b/ee/app/assets/javascripts/product_analytics/dashboards/components/analytics_dashboard.vue
@@ -1,15 +1,31 @@
 <script>
+import CustomizableDashboard from 'ee/vue_shared/components/customizable_dashboard/customizable_dashboard.vue';
+import { s__ } from '~/locale';
+
 export default {
   name: 'AnalyticsDashboard',
-
+  components: {
+    CustomizableDashboard,
+  },
   data() {
     return {
-      dashboard: {},
+      widgets: [
+        {
+          component: 'CubeLineChart',
+          title: s__('ProductAnalytics|Audience'),
+          gridAttributes: {
+            size: {
+              width: 3,
+              height: 3,
+            },
+          },
+        },
+      ],
     };
   },
 };
 </script>
 
 <template>
-  <div></div>
+  <customizable-dashboard :widgets="widgets" :editable="false" />
 </template>
diff --git a/ee/app/assets/javascripts/product_analytics/dashboards/components/widgets/cube_line_chart.vue b/ee/app/assets/javascripts/product_analytics/dashboards/components/widgets/cube_line_chart.vue
new file mode 100644
index 0000000000000000000000000000000000000000..51634244cdbc3b353df6146778f63dbcc39a6f8f
--- /dev/null
+++ b/ee/app/assets/javascripts/product_analytics/dashboards/components/widgets/cube_line_chart.vue
@@ -0,0 +1,31 @@
+<script>
+import { s__ } from '~/locale';
+
+export default {
+  name: 'CubeLineChart',
+  props: {
+    data: {
+      type: Object,
+      required: false,
+      default: () => ({}),
+    },
+    chartOptions: {
+      type: Object,
+      required: false,
+      default: () => ({}),
+    },
+    customizations: {
+      type: Object,
+      required: false,
+      default: () => ({}),
+    },
+  },
+  i18n: {
+    content: s__('ProductAnalytics|Widgets content'),
+  },
+};
+</script>
+
+<template>
+  <p>{{ $options.i18n.content }}</p>
+</template>
diff --git a/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/constants.js b/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/constants.js
new file mode 100644
index 0000000000000000000000000000000000000000..a2708525af1ff1dc6977748bc8f80c0d707c11a8
--- /dev/null
+++ b/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/constants.js
@@ -0,0 +1,2 @@
+export const GRIDSTACK_MARGIN = 10;
+export const GRIDSTACK_CSS_HANDLE = '.grid-stack-item-handle';
diff --git a/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/customizable_dashboard.stories.js b/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/customizable_dashboard.stories.js
new file mode 100644
index 0000000000000000000000000000000000000000..a4856a342efb44230dd8696422527fddce0cc11f
--- /dev/null
+++ b/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/customizable_dashboard.stories.js
@@ -0,0 +1,46 @@
+import { s__ } from '~/locale';
+import CustomizableDashboard from './customizable_dashboard.vue';
+
+export default {
+  component: CustomizableDashboard,
+  title: 'ee/vue_shared/components/customizable_dashboard',
+};
+
+const Template = (args, { argTypes }) => ({
+  components: { CustomizableDashboard },
+  props: Object.keys(argTypes),
+  template: '<customizable-dashboard v-bind="$props" />',
+});
+
+export const Default = Template.bind({});
+Default.args = {
+  editable: false,
+  widgets: [
+    {
+      component: 'CubeLineChart',
+      title: s__('ProductAnalytics|Audience'),
+      gridAttributes: {
+        size: {
+          width: 3,
+          height: 3,
+        },
+      },
+    },
+    {
+      component: 'CubeLineChart',
+      title: s__('ProductAnalytics|Audience'),
+      gridAttributes: {
+        size: {
+          width: 3,
+          height: 3,
+        },
+      },
+    },
+  ],
+};
+
+export const Editable = Template.bind({});
+Editable.args = {
+  ...Default.args,
+  editable: true,
+};
diff --git a/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/customizable_dashboard.vue b/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/customizable_dashboard.vue
new file mode 100644
index 0000000000000000000000000000000000000000..835d17426c28d827c79d025b5d60f3295a9d1c8b
--- /dev/null
+++ b/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/customizable_dashboard.vue
@@ -0,0 +1,113 @@
+<script>
+import { GridStack } from 'gridstack';
+import * as Sentry from '@sentry/browser';
+import { loadCSSFile } from '~/lib/utils/css_utils';
+import WidgetsBase from './widgets_base.vue';
+import { GRIDSTACK_MARGIN, GRIDSTACK_CSS_HANDLE } from './constants';
+
+export default {
+  name: 'CustomizableDashboard',
+  components: {
+    WidgetsBase,
+  },
+  props: {
+    editable: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
+    widgets: {
+      type: Array,
+      required: false,
+      default: () => [],
+    },
+  },
+  data() {
+    return {
+      cssLoaded: false,
+      mounted: true,
+    };
+  },
+  computed: {
+    loaded() {
+      return this.cssLoaded && this.mounted;
+    },
+  },
+  watch: {
+    cssLoaded() {
+      this.initGridStack();
+    },
+    mounted() {
+      this.initGridStack();
+    },
+  },
+  async created() {
+    try {
+      await loadCSSFile(gon.gridstack_css_path);
+      this.cssLoaded = true;
+    } catch (e) {
+      Sentry.captureException(e);
+    }
+  },
+  mounted() {
+    this.mounted = true;
+  },
+  unmounted() {
+    this.mounted = false;
+  },
+  methods: {
+    initGridStack() {
+      if (this.loaded) {
+        GridStack.init({
+          staticGrid: !this.editable,
+          margin: GRIDSTACK_MARGIN,
+          handle: GRIDSTACK_CSS_HANDLE,
+        });
+      }
+    },
+    getGridAttribute(widget, attribute) {
+      const { gridAttributes: { position = {}, size = {} } = {} } = widget;
+
+      if (position[attribute]) {
+        return position[attribute];
+      }
+
+      if (size[attribute]) {
+        return size[attribute];
+      }
+
+      return undefined;
+    },
+  },
+};
+</script>
+
+<template>
+  <div class="grid-stack-container gl-py-6">
+    <div class="grid-stack">
+      <div
+        v-for="(widget, index) in widgets"
+        :key="index"
+        :gs-id="index"
+        :gs-x="getGridAttribute(widget, 'xPos')"
+        :gs-y="getGridAttribute(widget, 'yPos')"
+        :gs-h="getGridAttribute(widget, 'height')"
+        :gs-w="getGridAttribute(widget, 'width')"
+        :gs-min-h="getGridAttribute(widget, 'minHeight')"
+        :gs-min-w="getGridAttribute(widget, 'minWidth')"
+        :gs-max-h="getGridAttribute(widget, 'maxHeight')"
+        :gs-max-w="getGridAttribute(widget, 'maxWidth')"
+        class="grid-stack-item"
+        data-testid="grid-stack-widget"
+      >
+        <widgets-base
+          :component="widget.component"
+          :title="widget.title"
+          :data="widget.data"
+          :chart-options="widget.chartOptions"
+          :customizations="widget.customizations"
+        />
+      </div>
+    </div>
+  </div>
+</template>
diff --git a/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/widgets_base.stories.js b/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/widgets_base.stories.js
new file mode 100644
index 0000000000000000000000000000000000000000..362ab14b0511c638962a3b79338343946515172c
--- /dev/null
+++ b/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/widgets_base.stories.js
@@ -0,0 +1,22 @@
+import { s__ } from '~/locale';
+import WidgetsBase from './widgets_base.vue';
+
+export default {
+  component: WidgetsBase,
+  title: 'ee/vue_shared/components/widgets_base',
+};
+
+const Template = (args, { argTypes }) => ({
+  components: { WidgetsBase },
+  props: Object.keys(argTypes),
+  template: '<widgets-base v-bind="$props" />',
+});
+
+export const Default = Template.bind({});
+Default.args = {
+  component: 'CubeLineChart',
+  title: s__('ProductAnalytics|Audience'),
+  data: {},
+  chartOptions: {},
+  customizations: {},
+};
diff --git a/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/widgets_base.vue b/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/widgets_base.vue
new file mode 100644
index 0000000000000000000000000000000000000000..4f3b8a54d655c32280f02771595ed64a13bbc313
--- /dev/null
+++ b/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/widgets_base.vue
@@ -0,0 +1,50 @@
+<script>
+export default {
+  name: 'AnalyticsDashboardWidget',
+  components: {
+    CubeLineChart: () =>
+      import('ee/product_analytics/dashboards/components/widgets/cube_line_chart.vue'),
+  },
+  props: {
+    component: {
+      type: String,
+      required: true,
+    },
+    title: {
+      type: String,
+      required: false,
+      default: '',
+    },
+    data: {
+      type: Object,
+      required: false,
+      default: () => ({}),
+    },
+    chartOptions: {
+      type: Object,
+      required: false,
+      default: () => ({}),
+    },
+    customizations: {
+      type: Object,
+      required: false,
+      default: () => ({}),
+    },
+  },
+};
+</script>
+
+<template>
+  <div
+    class="grid-stack-item-content gl-shadow gl-rounded-base gl-p-4 gl-display-flex gl-flex-direction-column"
+  >
+    <strong v-if="title" class="gl-mb-4" data-testid="widget-title">{{ title }}</strong>
+    <component
+      :is="component"
+      :data="data"
+      :customizations="customizations"
+      :chart-options="chartOptions"
+      class="gl-overflow-y-auto"
+    />
+  </div>
+</template>
diff --git a/ee/spec/frontend/product_analytics/dashboards/components/analytics_dashboard_spec.js b/ee/spec/frontend/product_analytics/dashboards/components/analytics_dashboard_spec.js
index 6a04104e9bd3a270bb7e5247148c96c5cc7bb4b6..40daac344c4a8e23eedfdc9e0bc0f7e08a921e4c 100644
--- a/ee/spec/frontend/product_analytics/dashboards/components/analytics_dashboard_spec.js
+++ b/ee/spec/frontend/product_analytics/dashboards/components/analytics_dashboard_spec.js
@@ -1,18 +1,16 @@
-import { mountExtended } from 'helpers/vue_test_utils_helper';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
 import AnalyticsDashboard from 'ee/product_analytics/dashboards/components/analytics_dashboard.vue';
+import CustomizableDashboard from 'ee/vue_shared/components/customizable_dashboard/customizable_dashboard.vue';
+import { widgets } from 'ee_jest/vue_shared/components/customizable_dashboard/mock_data';
 
-describe('ee/product_analytics/dashboards/components/analytics_dashboard.vue', () => {
+describe('AnalyticsDashboard', () => {
   let wrapper;
 
-  afterEach(() => {
-    wrapper.destroy();
-  });
-
   const createWrapper = (data = {}) => {
-    wrapper = mountExtended(AnalyticsDashboard, {
+    wrapper = shallowMountExtended(AnalyticsDashboard, {
       data() {
         return {
-          dashboard: {},
+          widgets: [],
           ...data,
         };
       },
@@ -21,11 +19,16 @@ describe('ee/product_analytics/dashboards/components/analytics_dashboard.vue', (
 
   describe('when mounted', () => {
     beforeEach(() => {
-      createWrapper();
+      createWrapper({
+        widgets,
+      });
     });
 
     it('should render', () => {
-      expect(wrapper.exists()).toBe(true);
+      expect(wrapper.findComponent(CustomizableDashboard).props()).toStrictEqual({
+        widgets,
+        editable: false,
+      });
     });
   });
 });
diff --git a/ee/spec/frontend/product_analytics/dashboards/components/widgets/cube_line_chart_spec.js b/ee/spec/frontend/product_analytics/dashboards/components/widgets/cube_line_chart_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..3ebd4cc2eac864a921f65667c25e934b3d6e8a56
--- /dev/null
+++ b/ee/spec/frontend/product_analytics/dashboards/components/widgets/cube_line_chart_spec.js
@@ -0,0 +1,27 @@
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import CubeLineChart from 'ee/product_analytics/dashboards/components/widgets/cube_line_chart.vue';
+
+describe('CubeLineChart', () => {
+  let wrapper;
+
+  const createWrapper = (props = {}) => {
+    wrapper = shallowMountExtended(CubeLineChart, {
+      propsData: {
+        data: {},
+        chartOptions: {},
+        customizations: {},
+        ...props,
+      },
+    });
+  };
+
+  describe('when mounted', () => {
+    beforeEach(() => {
+      createWrapper();
+    });
+
+    it('should render', () => {
+      expect(wrapper.exists()).toBe(true);
+    });
+  });
+});
diff --git a/ee/spec/frontend/vue_shared/components/customizable_dashboard/customizable_dashboard_spec.js b/ee/spec/frontend/vue_shared/components/customizable_dashboard/customizable_dashboard_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..5be2c6b3e693c8b99bdad281bb9515f769e86a62
--- /dev/null
+++ b/ee/spec/frontend/vue_shared/components/customizable_dashboard/customizable_dashboard_spec.js
@@ -0,0 +1,100 @@
+import { GridStack } from 'gridstack';
+import * as Sentry from '@sentry/browser';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import CustomizableDashboard from 'ee/vue_shared/components/customizable_dashboard/customizable_dashboard.vue';
+import WidgetsBase from 'ee/vue_shared/components/customizable_dashboard/widgets_base.vue';
+import {
+  GRIDSTACK_MARGIN,
+  GRIDSTACK_CSS_HANDLE,
+} from 'ee/vue_shared/components/customizable_dashboard/constants';
+import { loadCSSFile } from '~/lib/utils/css_utils';
+import waitForPromises from 'helpers/wait_for_promises';
+import { widgets } from './mock_data';
+
+jest.mock('gridstack', () => ({
+  GridStack: {
+    init: jest.fn(),
+  },
+}));
+
+jest.mock('~/lib/utils/css_utils', () => ({
+  loadCSSFile: jest.fn(),
+}));
+
+describe('CustomizableDashboard', () => {
+  let wrapper;
+
+  const sentryError = new Error('Network error');
+
+  const createWrapper = (props = {}) => {
+    wrapper = shallowMountExtended(CustomizableDashboard, {
+      propsData: {
+        editable: false,
+        widgets: [],
+        ...props,
+      },
+    });
+  };
+
+  const findGridStackWidgets = () => wrapper.findAllByTestId('grid-stack-widget');
+  const findWidgets = () => wrapper.findAllComponents(WidgetsBase);
+  describe('when being created an error occurs while loading the CSS', () => {
+    beforeEach(() => {
+      jest.spyOn(Sentry, 'captureException');
+      loadCSSFile.mockRejectedValue(sentryError);
+
+      createWrapper({
+        widgets,
+      });
+    });
+
+    it('reports the error to sentry', async () => {
+      await waitForPromises();
+      expect(Sentry.captureException.mock.calls[0][0]).toStrictEqual(sentryError);
+    });
+  });
+
+  describe('when mounted', () => {
+    beforeEach(() => {
+      createWrapper({
+        widgets,
+      });
+    });
+
+    it('sets up GridStack', () => {
+      expect(GridStack.init).toHaveBeenCalledWith({
+        staticGrid: true,
+        margin: GRIDSTACK_MARGIN,
+        handle: GRIDSTACK_CSS_HANDLE,
+      });
+    });
+
+    it.each(
+      widgets.map((widget, index) => [
+        widget.component,
+        widget.title,
+        widget.gridAttributes,
+        widget.customizations,
+        widget.chartOptions,
+        widget.data,
+        index,
+      ]),
+    )(
+      'should render the widget for %s',
+      (component, title, gridAttributes, customizations, chartOptions, data, index) => {
+        expect(findWidgets().at(index).props()).toMatchObject({
+          component,
+          title,
+          customizations,
+          chartOptions,
+          data,
+        });
+        expect(findGridStackWidgets().at(index).attributes()).toMatchObject({
+          'gs-id': `${index}`,
+          'gs-h': `${gridAttributes.size.height}`,
+          'gs-w': `${gridAttributes.size.width}`,
+        });
+      },
+    );
+  });
+});
diff --git a/ee/spec/frontend/vue_shared/components/customizable_dashboard/mock_data.js b/ee/spec/frontend/vue_shared/components/customizable_dashboard/mock_data.js
new file mode 100644
index 0000000000000000000000000000000000000000..978b02153ec4ee2da9d2f6b6d63a792bc074a8f7
--- /dev/null
+++ b/ee/spec/frontend/vue_shared/components/customizable_dashboard/mock_data.js
@@ -0,0 +1,20 @@
+import { __ } from '~/locale';
+
+export const widgets = [
+  {
+    component: 'CubeLineChart',
+    title: __('Test A'),
+    gridAttributes: { size: { width: 3, height: 3 } },
+    customizations: {},
+    chartOptions: {},
+    data: {},
+  },
+  {
+    component: 'CubeLineChart',
+    title: __('Test B'),
+    gridAttributes: { size: { width: 2, height: 4 } },
+    customizations: {},
+    chartOptions: {},
+    data: {},
+  },
+];
diff --git a/ee/spec/frontend/vue_shared/components/customizable_dashboard/widgets_base_spec.js b/ee/spec/frontend/vue_shared/components/customizable_dashboard/widgets_base_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..49b7101e3205d408d813a4dc0f94e9ad332740d0
--- /dev/null
+++ b/ee/spec/frontend/vue_shared/components/customizable_dashboard/widgets_base_spec.js
@@ -0,0 +1,42 @@
+import CubeLineChart from 'ee/product_analytics/dashboards/components/widgets/cube_line_chart.vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import WidgetsBase from 'ee/vue_shared/components/customizable_dashboard/widgets_base.vue';
+import { widgets } from './mock_data';
+
+describe('WidgetsBase', () => {
+  const widgetConfig = widgets[0];
+
+  let wrapper;
+
+  const createWrapper = (props = {}) => {
+    wrapper = shallowMountExtended(WidgetsBase, {
+      propsData: {
+        ...props,
+      },
+    });
+  };
+
+  const findWidget = () => wrapper.findComponent(CubeLineChart);
+  const findWidgetTitle = () => wrapper.findByTestId('widget-title');
+
+  describe('when mounted', () => {
+    beforeEach(() => {
+      createWrapper({
+        component: widgetConfig.component,
+        title: widgetConfig.title,
+        data: widgetConfig.data,
+        chartOptions: widgetConfig.chartOptions,
+        customizations: widgetConfig.customizations,
+      });
+    });
+
+    it('should render', () => {
+      expect(findWidgetTitle().text()).toBe(widgetConfig.title);
+      expect(findWidget().props()).toStrictEqual({
+        data: widgetConfig.data,
+        chartOptions: widgetConfig.chartOptions,
+        customizations: widgetConfig.customizations,
+      });
+    });
+  });
+});
diff --git a/lib/gitlab/gon_helper.rb b/lib/gitlab/gon_helper.rb
index 814040d29e1ac5db42a959587f1014c80056eb9a..bdb7484f3d6e3c779b1ddd242d0244d11f35c7c1 100644
--- a/lib/gitlab/gon_helper.rb
+++ b/lib/gitlab/gon_helper.rb
@@ -33,6 +33,7 @@ def add_gon_variables
       gon.sprite_file_icons      = IconsHelper.sprite_file_icons_path
       gon.emoji_sprites_css_path = ActionController::Base.helpers.stylesheet_path('emoji_sprites')
       gon.select2_css_path       = ActionController::Base.helpers.stylesheet_path('lazy_bundles/select2.css')
+      gon.gridstack_css_path     = ActionController::Base.helpers.stylesheet_path('lazy_bundles/gridstack.css')
       gon.test_env               = Rails.env.test?
       gon.disable_animations     = Gitlab.config.gitlab['disable_animations']
       gon.suggested_label_colors = LabelsHelper.suggested_colors
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index a4ac7b42231a7e6c773c34274dbd9a10ea8b22f6..003f529c90dc3a29d640b26ae77607e063d65b93 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -30800,9 +30800,15 @@ msgstr ""
 msgid "Product Analytics"
 msgstr ""
 
+msgid "ProductAnalytics|Audience"
+msgstr ""
+
 msgid "ProductAnalytics|There is no data for this type of chart currently. Please see the Setup tab if you have not configured the product analytics tool already."
 msgstr ""
 
+msgid "ProductAnalytics|Widgets content"
+msgstr ""
+
 msgid "Productivity"
 msgstr ""
 
diff --git a/package.json b/package.json
index bfeb065cfd1db9abb49c0bef60ff01e1befa6460..12139224df6ea345c1e3dc6c86127c15b0f76c6c 100644
--- a/package.json
+++ b/package.json
@@ -127,6 +127,7 @@
     "fuzzaldrin-plus": "^0.6.0",
     "graphql": "^15.7.2",
     "graphql-tag": "^2.11.0",
+    "gridstack": "^7.0.0",
     "highlight.js": "^11.5.1",
     "immer": "^9.0.15",
     "ipaddr.js": "^1.9.1",
diff --git a/yarn.lock b/yarn.lock
index 3e6bb44735106e4936dd2b8a8d2e09a0a347c79b..1a66f91e7037020fa66f388b25161b9e3316d067 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6331,6 +6331,11 @@ graphql@^15.7.2:
   resolved "https://registry.yarnpkg.com/graphql/-/graphql-15.7.2.tgz#85ab0eeb83722977151b3feb4d631b5f2ab287ef"
   integrity sha512-AnnKk7hFQFmU/2I9YSQf3xw44ctnSFCfp3zE0N6W174gqe9fWG/2rKaKxROK7CcI3XtERpjEKFqts8o319Kf7A==
 
+gridstack@^7.0.0:
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-7.0.0.tgz#2d00b28efa8d22a8b9ad2640c8ab64b494bbfdc9"
+  integrity sha512-iBts/PRuqg6OQvdpv7A84p3RROxzXVSKjM3SJHrdl2pdDZKmIpGo2oxjdCHv6l+SzU2EuptcHd1Rqouocwl1Cg==
+
 gzip-size@^6.0.0:
   version "6.0.0"
   resolved "https://registry.yarnpkg.com/gzip-size/-/gzip-size-6.0.0.tgz#065367fd50c239c0671cbcbad5be3e2eeb10e462"