diff --git a/examples/grid_example/public/app.tsx b/examples/grid_example/public/app.tsx
index 332649720742a..0e73a76d790fd 100644
--- a/examples/grid_example/public/app.tsx
+++ b/examples/grid_example/public/app.tsx
@@ -7,53 +7,186 @@
  * License v3.0 only", or the "Server Side Public License, v 1".
  */
 
-import React from 'react';
+import { cloneDeep } from 'lodash';
+import React, { useRef, useState } from 'react';
 import ReactDOM from 'react-dom';
-import { GridLayout, type GridLayoutData } from '@kbn/grid-layout';
+import { v4 as uuidv4 } from 'uuid';
+
+import {
+  EuiBadge,
+  EuiButton,
+  EuiButtonEmpty,
+  EuiCallOut,
+  EuiFlexGroup,
+  EuiFlexItem,
+  EuiPageTemplate,
+  EuiProvider,
+  EuiSpacer,
+} from '@elastic/eui';
 import { AppMountParameters } from '@kbn/core-application-browser';
-import { EuiPageTemplate, EuiProvider } from '@elastic/eui';
+import { CoreStart } from '@kbn/core-lifecycle-browser';
+import { GridLayout, GridLayoutData, isLayoutEqual, type GridLayoutApi } from '@kbn/grid-layout';
+import { i18n } from '@kbn/i18n';
+
+import { getPanelId } from './get_panel_id';
+import {
+  clearSerializedGridLayout,
+  getSerializedGridLayout,
+  setSerializedGridLayout,
+} from './serialized_grid_layout';
+
+const DASHBOARD_MARGIN_SIZE = 8;
+const DASHBOARD_GRID_HEIGHT = 20;
+const DASHBOARD_GRID_COLUMN_COUNT = 48;
+const DEFAULT_PANEL_HEIGHT = 15;
+const DEFAULT_PANEL_WIDTH = DASHBOARD_GRID_COLUMN_COUNT / 2;
+
+export const GridExample = ({ coreStart }: { coreStart: CoreStart }) => {
+  const [hasUnsavedChanges, setHasUnsavedChanges] = useState<boolean>(false);
+
+  const [layoutKey, setLayoutKey] = useState<string>(uuidv4());
+  const [gridLayoutApi, setGridLayoutApi] = useState<GridLayoutApi | null>();
+  const savedLayout = useRef<GridLayoutData>(getSerializedGridLayout());
+  const currentLayout = useRef<GridLayoutData>(savedLayout.current);
 
-export const GridExample = () => {
   return (
     <EuiProvider>
       <EuiPageTemplate grow={false} offset={0} restrictWidth={false}>
-        <EuiPageTemplate.Header iconType={'dashboardApp'} pageTitle="Grid Layout Example" />
+        <EuiPageTemplate.Header
+          iconType={'dashboardApp'}
+          pageTitle={i18n.translate('examples.gridExample.pageTitle', {
+            defaultMessage: 'Grid Layout Example',
+          })}
+        />
         <EuiPageTemplate.Section color="subdued">
+          <EuiCallOut
+            title={i18n.translate('examples.gridExample.sessionStorageCallout', {
+              defaultMessage:
+                'This example uses session storage to persist saved state and unsaved changes',
+            })}
+          >
+            <EuiButton
+              color="accent"
+              size="s"
+              onClick={() => {
+                clearSerializedGridLayout();
+                window.location.reload();
+              }}
+            >
+              {i18n.translate('examples.gridExample.resetExampleButton', {
+                defaultMessage: 'Reset example',
+              })}
+            </EuiButton>
+          </EuiCallOut>
+          <EuiSpacer size="m" />
+          <EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
+            <EuiFlexItem grow={false}>
+              <EuiButton
+                onClick={async () => {
+                  const panelId = await getPanelId({
+                    coreStart,
+                    suggestion: `panel${(gridLayoutApi?.getPanelCount() ?? 0) + 1}`,
+                  });
+                  if (panelId)
+                    gridLayoutApi?.addPanel(panelId, {
+                      width: DEFAULT_PANEL_WIDTH,
+                      height: DEFAULT_PANEL_HEIGHT,
+                    });
+                }}
+              >
+                {i18n.translate('examples.gridExample.addPanelButton', {
+                  defaultMessage: 'Add a panel',
+                })}
+              </EuiButton>
+            </EuiFlexItem>
+            <EuiFlexItem grow={false}>
+              <EuiFlexGroup gutterSize="xs" alignItems="center">
+                {hasUnsavedChanges && (
+                  <EuiFlexItem grow={false}>
+                    <EuiBadge color="warning">
+                      {i18n.translate('examples.gridExample.unsavedChangesBadge', {
+                        defaultMessage: 'Unsaved changes',
+                      })}
+                    </EuiBadge>
+                  </EuiFlexItem>
+                )}
+                <EuiFlexItem grow={false}>
+                  <EuiButtonEmpty
+                    onClick={() => {
+                      currentLayout.current = cloneDeep(savedLayout.current);
+                      setHasUnsavedChanges(false);
+                      setLayoutKey(uuidv4()); // force remount of grid
+                    }}
+                  >
+                    {i18n.translate('examples.gridExample.resetLayoutButton', {
+                      defaultMessage: 'Reset',
+                    })}
+                  </EuiButtonEmpty>
+                </EuiFlexItem>
+                <EuiFlexItem grow={false}>
+                  <EuiButton
+                    onClick={() => {
+                      if (gridLayoutApi) {
+                        const layoutToSave = gridLayoutApi.serializeState();
+                        setSerializedGridLayout(layoutToSave);
+                        savedLayout.current = layoutToSave;
+                        setHasUnsavedChanges(false);
+                      }
+                    }}
+                  >
+                    {i18n.translate('examples.gridExample.saveLayoutButton', {
+                      defaultMessage: 'Save',
+                    })}
+                  </EuiButton>
+                </EuiFlexItem>
+              </EuiFlexGroup>
+            </EuiFlexItem>
+          </EuiFlexGroup>
+          <EuiSpacer size="m" />
           <GridLayout
+            key={layoutKey}
+            onLayoutChange={(newLayout) => {
+              currentLayout.current = cloneDeep(newLayout);
+              setHasUnsavedChanges(!isLayoutEqual(savedLayout.current, newLayout));
+            }}
+            ref={setGridLayoutApi}
             renderPanelContents={(id) => {
-              return <div style={{ padding: 8 }}>{id}</div>;
+              return (
+                <>
+                  <div style={{ padding: 8 }}>{id}</div>
+                  <EuiButtonEmpty
+                    onClick={() => {
+                      gridLayoutApi?.removePanel(id);
+                    }}
+                  >
+                    {i18n.translate('examples.gridExample.deletePanelButton', {
+                      defaultMessage: 'Delete panel',
+                    })}
+                  </EuiButtonEmpty>
+                  <EuiButtonEmpty
+                    onClick={async () => {
+                      const newPanelId = await getPanelId({
+                        coreStart,
+                        suggestion: `panel${(gridLayoutApi?.getPanelCount() ?? 0) + 1}`,
+                      });
+                      if (newPanelId) gridLayoutApi?.replacePanel(id, newPanelId);
+                    }}
+                  >
+                    {i18n.translate('examples.gridExample.replacePanelButton', {
+                      defaultMessage: 'Replace panel',
+                    })}
+                  </EuiButtonEmpty>
+                </>
+              );
             }}
             getCreationOptions={() => {
-              const initialLayout: GridLayoutData = [
-                {
-                  title: 'Large section',
-                  isCollapsed: false,
-                  panels: {
-                    panel1: { column: 0, row: 0, width: 12, height: 6, id: 'panel1' },
-                    panel2: { column: 0, row: 6, width: 8, height: 4, id: 'panel2' },
-                    panel3: { column: 8, row: 6, width: 12, height: 4, id: 'panel3' },
-                    panel4: { column: 0, row: 10, width: 48, height: 4, id: 'panel4' },
-                    panel5: { column: 12, row: 0, width: 36, height: 6, id: 'panel5' },
-                    panel6: { column: 24, row: 6, width: 24, height: 4, id: 'panel6' },
-                    panel7: { column: 20, row: 6, width: 4, height: 2, id: 'panel7' },
-                    panel8: { column: 20, row: 8, width: 4, height: 2, id: 'panel8' },
-                  },
-                },
-                {
-                  title: 'Small section',
-                  isCollapsed: false,
-                  panels: { panel9: { column: 0, row: 0, width: 12, height: 16, id: 'panel9' } },
-                },
-                {
-                  title: 'Another small section',
-                  isCollapsed: false,
-                  panels: { panel10: { column: 24, row: 0, width: 12, height: 6, id: 'panel10' } },
-                },
-              ];
-
               return {
-                gridSettings: { gutterSize: 8, rowHeight: 26, columnCount: 48 },
-                initialLayout,
+                gridSettings: {
+                  gutterSize: DASHBOARD_MARGIN_SIZE,
+                  rowHeight: DASHBOARD_GRID_HEIGHT,
+                  columnCount: DASHBOARD_GRID_COLUMN_COUNT,
+                },
+                initialLayout: cloneDeep(currentLayout.current),
               };
             }}
           />
@@ -63,8 +196,11 @@ export const GridExample = () => {
   );
 };
 
-export const renderGridExampleApp = (element: AppMountParameters['element']) => {
-  ReactDOM.render(<GridExample />, element);
+export const renderGridExampleApp = (
+  element: AppMountParameters['element'],
+  coreStart: CoreStart
+) => {
+  ReactDOM.render(<GridExample coreStart={coreStart} />, element);
 
   return () => ReactDOM.unmountComponentAtNode(element);
 };
diff --git a/examples/grid_example/public/get_panel_id.tsx b/examples/grid_example/public/get_panel_id.tsx
new file mode 100644
index 0000000000000..d83d0b232b53a
--- /dev/null
+++ b/examples/grid_example/public/get_panel_id.tsx
@@ -0,0 +1,108 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import React, { useState } from 'react';
+
+import {
+  EuiButton,
+  EuiCallOut,
+  EuiFieldText,
+  EuiModal,
+  EuiModalBody,
+  EuiModalFooter,
+  EuiModalHeader,
+  EuiModalHeaderTitle,
+  EuiSpacer,
+} from '@elastic/eui';
+import { CoreStart } from '@kbn/core-lifecycle-browser';
+import { toMountPoint } from '@kbn/react-kibana-mount';
+import { i18n } from '@kbn/i18n';
+
+const PanelIdModal = ({
+  suggestion,
+  onClose,
+  onSubmit,
+}: {
+  suggestion: string;
+  onClose: () => void;
+  onSubmit: (id: string) => void;
+}) => {
+  const [panelId, setPanelId] = useState<string>(suggestion);
+
+  return (
+    <EuiModal onClose={onClose}>
+      <EuiModalHeader>
+        <EuiModalHeaderTitle>
+          {i18n.translate('examples.gridExample.getPanelIdModalTitle', {
+            defaultMessage: 'Panel ID',
+          })}
+        </EuiModalHeaderTitle>
+      </EuiModalHeader>
+      <EuiModalBody>
+        <EuiCallOut
+          color="warning"
+          title={i18n.translate('examples.gridExample.getPanelIdWarning', {
+            defaultMessage: 'Ensure the panel ID is unique, or you may get unexpected behaviour.',
+          })}
+        />
+
+        <EuiSpacer size="m" />
+
+        <EuiFieldText
+          placeholder={suggestion}
+          value={panelId}
+          onChange={(e) => {
+            setPanelId(e.target.value ?? '');
+          }}
+        />
+      </EuiModalBody>
+      <EuiModalFooter>
+        <EuiButton
+          onClick={() => {
+            onSubmit(panelId);
+          }}
+        >
+          {i18n.translate('examples.gridExample.getPanelIdSubmitButton', {
+            defaultMessage: 'Submit',
+          })}
+        </EuiButton>
+      </EuiModalFooter>
+    </EuiModal>
+  );
+};
+
+export const getPanelId = async ({
+  coreStart,
+  suggestion,
+}: {
+  coreStart: CoreStart;
+  suggestion: string;
+}): Promise<string | undefined> => {
+  return new Promise<string | undefined>((resolve) => {
+    const session = coreStart.overlays.openModal(
+      toMountPoint(
+        <PanelIdModal
+          suggestion={suggestion}
+          onClose={() => {
+            resolve(undefined);
+            session.close();
+          }}
+          onSubmit={(newPanelId) => {
+            resolve(newPanelId);
+            session.close();
+          }}
+        />,
+        {
+          theme: coreStart.theme,
+          i18n: coreStart.i18n,
+        }
+      )
+    );
+  });
+};
diff --git a/examples/grid_example/public/plugin.ts b/examples/grid_example/public/plugin.ts
index 0f7d441a1be15..d57b06ac96017 100644
--- a/examples/grid_example/public/plugin.ts
+++ b/examples/grid_example/public/plugin.ts
@@ -26,8 +26,11 @@ export class GridExamplePlugin
       title: gridExampleTitle,
       visibleIn: [],
       async mount(params: AppMountParameters) {
-        const { renderGridExampleApp } = await import('./app');
-        return renderGridExampleApp(params.element);
+        const [{ renderGridExampleApp }, [coreStart]] = await Promise.all([
+          import('./app'),
+          core.getStartServices(),
+        ]);
+        return renderGridExampleApp(params.element, coreStart);
       },
     });
     developerExamples.register({
diff --git a/examples/grid_example/public/serialized_grid_layout.ts b/examples/grid_example/public/serialized_grid_layout.ts
new file mode 100644
index 0000000000000..2bb20052398f8
--- /dev/null
+++ b/examples/grid_example/public/serialized_grid_layout.ts
@@ -0,0 +1,52 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import { type GridLayoutData } from '@kbn/grid-layout';
+
+const STATE_SESSION_STORAGE_KEY = 'kibana.examples.gridExample.state';
+
+export function clearSerializedGridLayout() {
+  sessionStorage.removeItem(STATE_SESSION_STORAGE_KEY);
+}
+
+export function getSerializedGridLayout(): GridLayoutData {
+  const serializedStateJSON = sessionStorage.getItem(STATE_SESSION_STORAGE_KEY);
+  return serializedStateJSON ? JSON.parse(serializedStateJSON) : initialGridLayout;
+}
+
+export function setSerializedGridLayout(layout: GridLayoutData) {
+  sessionStorage.setItem(STATE_SESSION_STORAGE_KEY, JSON.stringify(layout));
+}
+
+const initialGridLayout: GridLayoutData = [
+  {
+    title: 'Large section',
+    isCollapsed: false,
+    panels: {
+      panel1: { column: 0, row: 0, width: 12, height: 6, id: 'panel1' },
+      panel2: { column: 0, row: 6, width: 8, height: 4, id: 'panel2' },
+      panel3: { column: 8, row: 6, width: 12, height: 4, id: 'panel3' },
+      panel4: { column: 0, row: 10, width: 48, height: 4, id: 'panel4' },
+      panel5: { column: 12, row: 0, width: 36, height: 6, id: 'panel5' },
+      panel6: { column: 24, row: 6, width: 24, height: 4, id: 'panel6' },
+      panel7: { column: 20, row: 6, width: 4, height: 2, id: 'panel7' },
+      panel8: { column: 20, row: 8, width: 4, height: 2, id: 'panel8' },
+    },
+  },
+  {
+    title: 'Small section',
+    isCollapsed: false,
+    panels: { panel9: { column: 0, row: 0, width: 12, height: 16, id: 'panel9' } },
+  },
+  {
+    title: 'Another small section',
+    isCollapsed: false,
+    panels: { panel10: { column: 24, row: 0, width: 12, height: 6, id: 'panel10' } },
+  },
+];
diff --git a/examples/grid_example/tsconfig.json b/examples/grid_example/tsconfig.json
index 23be45a74c2f7..ad692e9697b2d 100644
--- a/examples/grid_example/tsconfig.json
+++ b/examples/grid_example/tsconfig.json
@@ -10,5 +10,8 @@
     "@kbn/core-application-browser",
     "@kbn/core",
     "@kbn/developer-examples-plugin",
+    "@kbn/core-lifecycle-browser",
+    "@kbn/react-kibana-mount",
+    "@kbn/i18n",
   ]
 }
diff --git a/packages/kbn-grid-layout/grid/grid_height_smoother.tsx b/packages/kbn-grid-layout/grid/grid_height_smoother.tsx
index 7693fac72918a..960fe4f52e735 100644
--- a/packages/kbn-grid-layout/grid/grid_height_smoother.tsx
+++ b/packages/kbn-grid-layout/grid/grid_height_smoother.tsx
@@ -24,7 +24,7 @@ export const GridHeightSmoother = ({
       gridLayoutStateManager.interactionEvent$,
     ]).subscribe(([dimensions, interactionEvent]) => {
       if (!smoothHeightRef.current) return;
-      if (!interactionEvent || interactionEvent.type === 'drop') {
+      if (!interactionEvent) {
         smoothHeightRef.current.style.height = `${dimensions.height}px`;
         return;
       }
diff --git a/packages/kbn-grid-layout/grid/grid_layout.tsx b/packages/kbn-grid-layout/grid/grid_layout.tsx
index 7f77a476579e9..c3f9521503107 100644
--- a/packages/kbn-grid-layout/grid/grid_layout.tsx
+++ b/packages/kbn-grid-layout/grid/grid_layout.tsx
@@ -7,82 +7,110 @@
  * License v3.0 only", or the "Server Side Public License, v 1".
  */
 
-import React, { useEffect, useState } from 'react';
-import { distinctUntilChanged, map, skip } from 'rxjs';
-import { v4 as uuidv4 } from 'uuid';
+import { cloneDeep } from 'lodash';
+import React, { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
+import { combineLatest, distinctUntilChanged, filter, map, pairwise, skip } from 'rxjs';
 
 import { GridHeightSmoother } from './grid_height_smoother';
 import { GridRow } from './grid_row';
-import { GridLayoutData, GridSettings } from './types';
+import { GridLayoutApi, GridLayoutData, GridSettings } from './types';
+import { useGridLayoutApi } from './use_grid_layout_api';
 import { useGridLayoutEvents } from './use_grid_layout_events';
 import { useGridLayoutState } from './use_grid_layout_state';
+import { isLayoutEqual } from './utils/equality_checks';
 
-export const GridLayout = ({
-  getCreationOptions,
-  renderPanelContents,
-}: {
+interface GridLayoutProps {
   getCreationOptions: () => { initialLayout: GridLayoutData; gridSettings: GridSettings };
   renderPanelContents: (panelId: string) => React.ReactNode;
-}) => {
-  const { gridLayoutStateManager, setDimensionsRef } = useGridLayoutState({
-    getCreationOptions,
-  });
-  useGridLayoutEvents({ gridLayoutStateManager });
+  onLayoutChange: (newLayout: GridLayoutData) => void;
+}
 
-  const [rowCount, setRowCount] = useState<number>(
-    gridLayoutStateManager.gridLayout$.getValue().length
-  );
+export const GridLayout = forwardRef<GridLayoutApi, GridLayoutProps>(
+  ({ getCreationOptions, renderPanelContents, onLayoutChange }, ref) => {
+    const { gridLayoutStateManager, setDimensionsRef } = useGridLayoutState({
+      getCreationOptions,
+    });
+    useGridLayoutEvents({ gridLayoutStateManager });
 
-  useEffect(() => {
-    /**
-     * The only thing that should cause the entire layout to re-render is adding a new row;
-     * this subscription ensures this by updating the `rowCount` state when it changes.
-     */
-    const rowCountSubscription = gridLayoutStateManager.gridLayout$
-      .pipe(
-        skip(1), // we initialized `rowCount` above, so skip the initial emit
-        map((newLayout) => newLayout.length),
-        distinctUntilChanged()
-      )
-      .subscribe((newRowCount) => {
-        setRowCount(newRowCount);
-      });
-    return () => rowCountSubscription.unsubscribe();
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, []);
+    const gridLayoutApi = useGridLayoutApi({ gridLayoutStateManager });
+    useImperativeHandle(ref, () => gridLayoutApi, [gridLayoutApi]);
 
-  return (
-    <>
-      <GridHeightSmoother gridLayoutStateManager={gridLayoutStateManager}>
-        <div
-          ref={(divElement) => {
-            setDimensionsRef(divElement);
-          }}
-        >
-          {Array.from({ length: rowCount }, (_, rowIndex) => {
-            return (
-              <GridRow
-                key={uuidv4()}
-                rowIndex={rowIndex}
-                renderPanelContents={renderPanelContents}
-                gridLayoutStateManager={gridLayoutStateManager}
-                toggleIsCollapsed={() => {
-                  const currentLayout = gridLayoutStateManager.gridLayout$.value;
-                  currentLayout[rowIndex].isCollapsed = !currentLayout[rowIndex].isCollapsed;
-                  gridLayoutStateManager.gridLayout$.next(currentLayout);
-                }}
-                setInteractionEvent={(nextInteractionEvent) => {
-                  if (nextInteractionEvent?.type === 'drop') {
-                    gridLayoutStateManager.activePanel$.next(undefined);
-                  }
-                  gridLayoutStateManager.interactionEvent$.next(nextInteractionEvent);
-                }}
-                ref={(element) => (gridLayoutStateManager.rowRefs.current[rowIndex] = element)}
-              />
-            );
-          })}
-        </div>
-      </GridHeightSmoother>
-    </>
-  );
-};
+    const [rowCount, setRowCount] = useState<number>(
+      gridLayoutStateManager.gridLayout$.getValue().length
+    );
+
+    useEffect(() => {
+      /**
+       * The only thing that should cause the entire layout to re-render is adding a new row;
+       * this subscription ensures this by updating the `rowCount` state when it changes.
+       */
+      const rowCountSubscription = gridLayoutStateManager.gridLayout$
+        .pipe(
+          skip(1), // we initialized `rowCount` above, so skip the initial emit
+          map((newLayout) => newLayout.length),
+          distinctUntilChanged()
+        )
+        .subscribe((newRowCount) => {
+          setRowCount(newRowCount);
+        });
+
+      const onLayoutChangeSubscription = combineLatest([
+        gridLayoutStateManager.gridLayout$,
+        gridLayoutStateManager.interactionEvent$,
+      ])
+        .pipe(
+          // if an interaction event is happening, then ignore any "draft" layout changes
+          filter(([_, event]) => !Boolean(event)),
+          // once no interaction event, create pairs of "old" and "new" layouts for comparison
+          map(([layout]) => layout),
+          pairwise()
+        )
+        .subscribe(([layoutBefore, layoutAfter]) => {
+          if (!isLayoutEqual(layoutBefore, layoutAfter)) {
+            onLayoutChange(layoutAfter);
+          }
+        });
+
+      return () => {
+        rowCountSubscription.unsubscribe();
+        onLayoutChangeSubscription.unsubscribe();
+      };
+      // eslint-disable-next-line react-hooks/exhaustive-deps
+    }, []);
+
+    return (
+      <>
+        <GridHeightSmoother gridLayoutStateManager={gridLayoutStateManager}>
+          <div
+            ref={(divElement) => {
+              setDimensionsRef(divElement);
+            }}
+          >
+            {Array.from({ length: rowCount }, (_, rowIndex) => {
+              return (
+                <GridRow
+                  key={rowIndex}
+                  rowIndex={rowIndex}
+                  renderPanelContents={renderPanelContents}
+                  gridLayoutStateManager={gridLayoutStateManager}
+                  toggleIsCollapsed={() => {
+                    const newLayout = cloneDeep(gridLayoutStateManager.gridLayout$.value);
+                    newLayout[rowIndex].isCollapsed = !newLayout[rowIndex].isCollapsed;
+                    gridLayoutStateManager.gridLayout$.next(newLayout);
+                  }}
+                  setInteractionEvent={(nextInteractionEvent) => {
+                    if (!nextInteractionEvent) {
+                      gridLayoutStateManager.activePanel$.next(undefined);
+                    }
+                    gridLayoutStateManager.interactionEvent$.next(nextInteractionEvent);
+                  }}
+                  ref={(element) => (gridLayoutStateManager.rowRefs.current[rowIndex] = element)}
+                />
+              );
+            })}
+          </div>
+        </GridHeightSmoother>
+      </>
+    );
+  }
+);
diff --git a/packages/kbn-grid-layout/grid/grid_panel.tsx b/packages/kbn-grid-layout/grid/grid_panel.tsx
index 64a4a2faff403..822cb2328c4a5 100644
--- a/packages/kbn-grid-layout/grid/grid_panel.tsx
+++ b/packages/kbn-grid-layout/grid/grid_panel.tsx
@@ -30,7 +30,7 @@ export const GridPanel = forwardRef<
     rowIndex: number;
     renderPanelContents: (panelId: string) => React.ReactNode;
     interactionStart: (
-      type: PanelInteractionEvent['type'],
+      type: PanelInteractionEvent['type'] | 'drop',
       e: React.MouseEvent<HTMLDivElement, MouseEvent>
     ) => void;
     gridLayoutStateManager: GridLayoutStateManager;
@@ -190,6 +190,7 @@ export const GridPanel = forwardRef<
               border-bottom: 2px solid ${euiThemeVars.euiColorSuccess};
               border-right: 2px solid ${euiThemeVars.euiColorSuccess};
               :hover {
+                opacity: 1;
                 background-color: ${transparentize(euiThemeVars.euiColorSuccess, 0.05)};
                 cursor: se-resize;
               }
diff --git a/packages/kbn-grid-layout/grid/grid_row.tsx b/packages/kbn-grid-layout/grid/grid_row.tsx
index e797cd570550a..ff97b32efcdbc 100644
--- a/packages/kbn-grid-layout/grid/grid_row.tsx
+++ b/packages/kbn-grid-layout/grid/grid_row.tsx
@@ -91,7 +91,7 @@ export const GridRow = forwardRef<
             )}, ${rowHeight}px)`;
 
             const targetRow = interactionEvent?.targetRowIndex;
-            if (rowIndex === targetRow && interactionEvent?.type !== 'drop') {
+            if (rowIndex === targetRow && interactionEvent) {
               // apply "targetted row" styles
               const gridColor = transparentize(euiThemeVars.euiColorSuccess, 0.2);
               rowRef.style.backgroundPosition = `top -${gutterSize / 2}px left -${
@@ -122,7 +122,6 @@ export const GridRow = forwardRef<
          */
         const rowStateSubscription = gridLayoutStateManager.gridLayout$
           .pipe(
-            skip(1), // we are initializing all row state with a value, so skip the initial emit
             map((gridLayout) => {
               return {
                 title: gridLayout[rowIndex].title,
@@ -201,18 +200,22 @@ export const GridRow = forwardRef<
                   if (!panelRef) return;
 
                   const panelRect = panelRef.getBoundingClientRect();
-                  setInteractionEvent({
-                    type,
-                    id: panelId,
-                    panelDiv: panelRef,
-                    targetRowIndex: rowIndex,
-                    mouseOffsets: {
-                      top: e.clientY - panelRect.top,
-                      left: e.clientX - panelRect.left,
-                      right: e.clientX - panelRect.right,
-                      bottom: e.clientY - panelRect.bottom,
-                    },
-                  });
+                  if (type === 'drop') {
+                    setInteractionEvent(undefined);
+                  } else {
+                    setInteractionEvent({
+                      type,
+                      id: panelId,
+                      panelDiv: panelRef,
+                      targetRowIndex: rowIndex,
+                      mouseOffsets: {
+                        top: e.clientY - panelRect.top,
+                        left: e.clientX - panelRect.left,
+                        right: e.clientX - panelRect.right,
+                        bottom: e.clientY - panelRect.bottom,
+                      },
+                    });
+                  }
                 }}
                 ref={(element) => {
                   if (!gridLayoutStateManager.panelRefs.current[rowIndex]) {
diff --git a/packages/kbn-grid-layout/grid/types.ts b/packages/kbn-grid-layout/grid/types.ts
index 3a88eeb33baba..004669e69b186 100644
--- a/packages/kbn-grid-layout/grid/types.ts
+++ b/packages/kbn-grid-layout/grid/types.ts
@@ -9,11 +9,13 @@
 
 import { BehaviorSubject } from 'rxjs';
 import type { ObservedSize } from 'use-resize-observer/polyfilled';
+
+import { SerializableRecord } from '@kbn/utility-types';
+
 export interface GridCoordinate {
   column: number;
   row: number;
 }
-
 export interface GridRect extends GridCoordinate {
   width: number;
   height: number;
@@ -57,8 +59,9 @@ export interface ActivePanel {
 }
 
 export interface GridLayoutStateManager {
-  gridDimensions$: BehaviorSubject<ObservedSize>;
   gridLayout$: BehaviorSubject<GridLayoutData>;
+
+  gridDimensions$: BehaviorSubject<ObservedSize>;
   runtimeSettings$: BehaviorSubject<RuntimeGridSettings>;
   activePanel$: BehaviorSubject<ActivePanel | undefined>;
   interactionEvent$: BehaviorSubject<PanelInteractionEvent | undefined>;
@@ -74,7 +77,7 @@ export interface PanelInteractionEvent {
   /**
    * The type of interaction being performed.
    */
-  type: 'drag' | 'resize' | 'drop';
+  type: 'drag' | 'resize';
 
   /**
    * The id of the panel being interacted with.
@@ -102,3 +105,29 @@ export interface PanelInteractionEvent {
     bottom: number;
   };
 }
+
+/**
+ * The external API provided through the GridLayout component
+ */
+export interface GridLayoutApi {
+  addPanel: (panelId: string, placementSettings: PanelPlacementSettings) => void;
+  removePanel: (panelId: string) => void;
+  replacePanel: (oldPanelId: string, newPanelId: string) => void;
+
+  getPanelCount: () => number;
+  serializeState: () => GridLayoutData & SerializableRecord;
+}
+
+// TODO: Remove from Dashboard plugin as part of https://github.com/elastic/kibana/issues/190446
+export enum PanelPlacementStrategy {
+  /** Place on the very top of the grid layout, add the height of this panel to all other panels. */
+  placeAtTop = 'placeAtTop',
+  /** Look for the smallest y and x value where the default panel will fit. */
+  findTopLeftMostOpenSpace = 'findTopLeftMostOpenSpace',
+}
+
+export interface PanelPlacementSettings {
+  strategy?: PanelPlacementStrategy;
+  height: number;
+  width: number;
+}
diff --git a/packages/kbn-grid-layout/grid/use_grid_layout_api.ts b/packages/kbn-grid-layout/grid/use_grid_layout_api.ts
new file mode 100644
index 0000000000000..1a950ee934174
--- /dev/null
+++ b/packages/kbn-grid-layout/grid/use_grid_layout_api.ts
@@ -0,0 +1,109 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import { useMemo } from 'react';
+import { cloneDeep } from 'lodash';
+
+import { SerializableRecord } from '@kbn/utility-types';
+
+import { GridLayoutApi, GridLayoutData, GridLayoutStateManager } from './types';
+import { compactGridRow } from './utils/resolve_grid_row';
+import { runPanelPlacementStrategy } from './utils/run_panel_placement';
+
+export const useGridLayoutApi = ({
+  gridLayoutStateManager,
+}: {
+  gridLayoutStateManager: GridLayoutStateManager;
+}): GridLayoutApi => {
+  const api: GridLayoutApi = useMemo(() => {
+    return {
+      addPanel: (panelId, placementSettings) => {
+        const currentLayout = gridLayoutStateManager.gridLayout$.getValue();
+        const [firstRow, ...rest] = currentLayout; // currently, only adding panels to the first row is supported
+        const { columnCount: gridColumnCount } = gridLayoutStateManager.runtimeSettings$.getValue();
+        const nextRow = runPanelPlacementStrategy(
+          firstRow,
+          {
+            id: panelId,
+            width: placementSettings.width,
+            height: placementSettings.height,
+          },
+          gridColumnCount,
+          placementSettings?.strategy
+        );
+        gridLayoutStateManager.gridLayout$.next([nextRow, ...rest]);
+      },
+
+      removePanel: (panelId) => {
+        const currentLayout = gridLayoutStateManager.gridLayout$.getValue();
+
+        // find the row where the panel exists and delete it from the corresponding panels object
+        let rowIndex = 0;
+        let updatedPanels;
+        for (rowIndex; rowIndex < currentLayout.length; rowIndex++) {
+          const row = currentLayout[rowIndex];
+          if (Object.keys(row.panels).includes(panelId)) {
+            updatedPanels = { ...row.panels }; // prevent mutation of original panel object
+            delete updatedPanels[panelId];
+            break;
+          }
+        }
+
+        // if the panels were updated (i.e. the panel was successfully found and deleted), update the layout
+        if (updatedPanels) {
+          const newLayout = cloneDeep(currentLayout);
+          newLayout[rowIndex] = compactGridRow({
+            ...newLayout[rowIndex],
+            panels: updatedPanels,
+          });
+          gridLayoutStateManager.gridLayout$.next(newLayout);
+        }
+      },
+
+      replacePanel: (oldPanelId, newPanelId) => {
+        const currentLayout = gridLayoutStateManager.gridLayout$.getValue();
+
+        // find the row where the panel exists and update its ID to trigger a re-render
+        let rowIndex = 0;
+        let updatedPanels;
+        for (rowIndex; rowIndex < currentLayout.length; rowIndex++) {
+          const row = { ...currentLayout[rowIndex] };
+          if (Object.keys(row.panels).includes(oldPanelId)) {
+            updatedPanels = { ...row.panels }; // prevent mutation of original panel object
+            const oldPanel = updatedPanels[oldPanelId];
+            delete updatedPanels[oldPanelId];
+            updatedPanels[newPanelId] = { ...oldPanel, id: newPanelId };
+            break;
+          }
+        }
+
+        // if the panels were updated (i.e. the panel was successfully found and replaced), update the layout
+        if (updatedPanels) {
+          const newLayout = cloneDeep(currentLayout);
+          newLayout[rowIndex].panels = updatedPanels;
+          gridLayoutStateManager.gridLayout$.next(newLayout);
+        }
+      },
+
+      getPanelCount: () => {
+        return gridLayoutStateManager.gridLayout$.getValue().reduce((prev, row) => {
+          return prev + Object.keys(row.panels).length;
+        }, 0);
+      },
+
+      serializeState: () => {
+        const currentLayout = gridLayoutStateManager.gridLayout$.getValue();
+        return cloneDeep(currentLayout) as GridLayoutData & SerializableRecord;
+      },
+    };
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+
+  return api;
+};
diff --git a/packages/kbn-grid-layout/grid/use_grid_layout_events.ts b/packages/kbn-grid-layout/grid/use_grid_layout_events.ts
index bd6343b9e5652..22dde2fe68ced 100644
--- a/packages/kbn-grid-layout/grid/use_grid_layout_events.ts
+++ b/packages/kbn-grid-layout/grid/use_grid_layout_events.ts
@@ -7,21 +7,11 @@
  * License v3.0 only", or the "Server Side Public License, v 1".
  */
 
-import { useEffect, useRef } from 'react';
 import deepEqual from 'fast-deep-equal';
-
-import { resolveGridRow } from './resolve_grid_row';
-import { GridLayoutStateManager, GridPanelData } from './types';
-
-export const isGridDataEqual = (a?: GridPanelData, b?: GridPanelData) => {
-  return (
-    a?.id === b?.id &&
-    a?.column === b?.column &&
-    a?.row === b?.row &&
-    a?.width === b?.width &&
-    a?.height === b?.height
-  );
-};
+import { useEffect, useRef } from 'react';
+import { resolveGridRow } from './utils/resolve_grid_row';
+import { GridPanelData, GridLayoutStateManager } from './types';
+import { isGridDataEqual } from './utils/equality_checks';
 
 export const useGridLayoutEvents = ({
   gridLayoutStateManager,
@@ -37,7 +27,7 @@ export const useGridLayoutEvents = ({
   useEffect(() => {
     const { runtimeSettings$, interactionEvent$, gridLayout$ } = gridLayoutStateManager;
     const calculateUserEvent = (e: Event) => {
-      if (!interactionEvent$.value || interactionEvent$.value.type === 'drop') return;
+      if (!interactionEvent$.value) return;
       e.preventDefault();
       e.stopPropagation();
 
diff --git a/packages/kbn-grid-layout/grid/utils/equality_checks.ts b/packages/kbn-grid-layout/grid/utils/equality_checks.ts
new file mode 100644
index 0000000000000..6771baa3a1030
--- /dev/null
+++ b/packages/kbn-grid-layout/grid/utils/equality_checks.ts
@@ -0,0 +1,44 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import { GridLayoutData, GridPanelData } from '../types';
+
+export const isGridDataEqual = (a?: GridPanelData, b?: GridPanelData) => {
+  return (
+    a?.id === b?.id &&
+    a?.column === b?.column &&
+    a?.row === b?.row &&
+    a?.width === b?.width &&
+    a?.height === b?.height
+  );
+};
+
+export const isLayoutEqual = (a: GridLayoutData, b: GridLayoutData) => {
+  if (a.length !== b.length) return false;
+
+  let isEqual = true;
+  for (let rowIndex = 0; rowIndex < a.length && isEqual; rowIndex++) {
+    const rowA = a[rowIndex];
+    const rowB = b[rowIndex];
+
+    isEqual =
+      rowA.title === rowB.title &&
+      rowA.isCollapsed === rowB.isCollapsed &&
+      Object.keys(rowA.panels).length === Object.keys(rowB.panels).length;
+
+    if (isEqual) {
+      for (const panelKey of Object.keys(rowA.panels)) {
+        isEqual = isGridDataEqual(rowA.panels[panelKey], rowB.panels[panelKey]);
+        if (!isEqual) break;
+      }
+    }
+  }
+
+  return isEqual;
+};
diff --git a/packages/kbn-grid-layout/grid/resolve_grid_row.ts b/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts
similarity index 96%
rename from packages/kbn-grid-layout/grid/resolve_grid_row.ts
rename to packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts
index 4c300336c7617..3037a52c27c69 100644
--- a/packages/kbn-grid-layout/grid/resolve_grid_row.ts
+++ b/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts
@@ -7,7 +7,7 @@
  * License v3.0 only", or the "Server Side Public License, v 1".
  */
 
-import { GridPanelData, GridRowData } from './types';
+import { GridPanelData, GridRowData } from '../types';
 
 const collides = (panelA: GridPanelData, panelB: GridPanelData) => {
   if (panelA.id === panelB.id) return false; // same panel
@@ -57,7 +57,7 @@ const getKeysInOrder = (rowData: GridRowData, draggedId?: string): string[] => {
   });
 };
 
-const compactGridRow = (originalLayout: GridRowData) => {
+export const compactGridRow = (originalLayout: GridRowData) => {
   const nextRowData = { ...originalLayout, panels: { ...originalLayout.panels } };
   // compact all vertical space.
   const sortedKeysAfterMove = getKeysInOrder(nextRowData);
diff --git a/packages/kbn-grid-layout/grid/utils/run_panel_placement.ts b/packages/kbn-grid-layout/grid/utils/run_panel_placement.ts
new file mode 100644
index 0000000000000..69ecddd1f5ffb
--- /dev/null
+++ b/packages/kbn-grid-layout/grid/utils/run_panel_placement.ts
@@ -0,0 +1,116 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+import { i18n } from '@kbn/i18n';
+import { GridRowData } from '../..';
+import { GridPanelData, PanelPlacementStrategy } from '../types';
+import { compactGridRow, resolveGridRow } from './resolve_grid_row';
+
+export const runPanelPlacementStrategy = (
+  originalRowData: GridRowData,
+  newPanel: Omit<GridPanelData, 'row' | 'column'>,
+  columnCount: number,
+  strategy: PanelPlacementStrategy = PanelPlacementStrategy.findTopLeftMostOpenSpace
+): GridRowData => {
+  const nextRowData = { ...originalRowData, panels: { ...originalRowData.panels } }; // prevent mutation of original row object
+  switch (strategy) {
+    case PanelPlacementStrategy.placeAtTop:
+      // move all other panels down by the height of the new panel to make room for the new panel
+      Object.keys(nextRowData.panels).forEach((key) => {
+        const panel = nextRowData.panels[key];
+        panel.row += newPanel.height;
+      });
+
+      // some panels might need to be pushed back up because they are now floating - so, compact the row
+      return compactGridRow({
+        ...nextRowData,
+        // place the new panel at the top left corner, since there is now space
+        panels: { ...nextRowData.panels, [newPanel.id]: { ...newPanel, row: 0, column: 0 } },
+      });
+
+    case PanelPlacementStrategy.findTopLeftMostOpenSpace:
+      // find the max row
+      let maxRow = -1;
+      const currentPanelsArray = Object.values(nextRowData.panels);
+      currentPanelsArray.forEach((panel) => {
+        maxRow = Math.max(panel.row + panel.height, maxRow);
+      });
+
+      // handle case of empty grid by placing the panel at the top left corner
+      if (maxRow < 0) {
+        return {
+          ...nextRowData,
+          panels: { [newPanel.id]: { ...newPanel, row: 0, column: 0 } },
+        };
+      }
+
+      // find a spot in the grid where the entire panel will fit
+      const { row, column } = (() => {
+        // create a 2D array representation of the grid filled with zeros
+        const grid = new Array(maxRow);
+        for (let y = 0; y < maxRow; y++) {
+          grid[y] = new Array(columnCount).fill(0);
+        }
+
+        // fill in the 2D array with ones wherever a panel is
+        currentPanelsArray.forEach((panel) => {
+          for (let x = panel.column; x < panel.column + panel.width; x++) {
+            for (let y = panel.row; y < panel.row + panel.height; y++) {
+              grid[y][x] = 1;
+            }
+          }
+        });
+
+        // now find the first empty spot where there are enough zeros (unoccupied spaces) to fit the whole panel
+        for (let y = 0; y < maxRow; y++) {
+          for (let x = 0; x < columnCount; x++) {
+            if (grid[y][x] === 1) {
+              // space is filled, so skip this spot
+              continue;
+            } else {
+              for (let h = y; h < Math.min(y + newPanel.height, maxRow); h++) {
+                for (let w = x; w < Math.min(x + newPanel.width, columnCount); w++) {
+                  const spaceIsEmpty = grid[h][w] === 0;
+                  const fitsPanelWidth = w === x + newPanel.width - 1;
+                  // if the panel is taller than any other panel in the current grid, it can still fit in the space, hence
+                  // we check the minimum of maxY and the panel height.
+                  const fitsPanelHeight = h === Math.min(y + newPanel.height - 1, maxRow - 1);
+
+                  if (spaceIsEmpty && fitsPanelWidth && fitsPanelHeight) {
+                    // found an empty space where the entire panel will fit
+                    return { column: x, row: y };
+                  } else if (grid[h][w] === 1) {
+                    // x, y is already occupied - break out of the loop and move on to the next starting point
+                    break;
+                  }
+                }
+              }
+            }
+          }
+        }
+
+        return { column: 0, row: maxRow };
+      })();
+
+      // some panels might need to be pushed down to accomodate the height of the new panel;
+      // so, resolve the entire row to remove any potential collisions
+      return resolveGridRow({
+        ...nextRowData,
+        // place the new panel at the top left corner, since there is now space
+        panels: { ...nextRowData.panels, [newPanel.id]: { ...newPanel, row, column } },
+      });
+
+    default:
+      throw new Error(
+        i18n.translate('kbnGridLayout.panelPlacement.unknownStrategyError', {
+          defaultMessage: 'Unknown panel placement strategy: {strategy}',
+          values: { strategy },
+        })
+      );
+  }
+};
diff --git a/packages/kbn-grid-layout/index.ts b/packages/kbn-grid-layout/index.ts
index 009b74573e895..924369fe5ab4c 100644
--- a/packages/kbn-grid-layout/index.ts
+++ b/packages/kbn-grid-layout/index.ts
@@ -8,4 +8,12 @@
  */
 
 export { GridLayout } from './grid/grid_layout';
-export type { GridLayoutData, GridPanelData, GridRowData, GridSettings } from './grid/types';
+export type {
+  GridLayoutApi,
+  GridLayoutData,
+  GridPanelData,
+  GridRowData,
+  GridSettings,
+} from './grid/types';
+
+export { isLayoutEqual } from './grid/utils/equality_checks';
diff --git a/packages/kbn-grid-layout/tsconfig.json b/packages/kbn-grid-layout/tsconfig.json
index f0dd3232a42d5..14ab38ba76ba9 100644
--- a/packages/kbn-grid-layout/tsconfig.json
+++ b/packages/kbn-grid-layout/tsconfig.json
@@ -19,5 +19,6 @@
   "kbn_references": [
     "@kbn/ui-theme",
     "@kbn/i18n",
+    "@kbn/utility-types",
   ]
 }