diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json
index dca13e21b3a..097e5c3adb9 100644
--- a/packages/grafana-ui/package.json
+++ b/packages/grafana-ui/package.json
@@ -176,6 +176,7 @@
"rollup-plugin-dts": "^5.0.0",
"rollup-plugin-esbuild": "5.0.0",
"rollup-plugin-node-externals": "^5.0.0",
+ "rollup-plugin-svg-import": "1.6.0",
"sass-loader": "14.1.1",
"storybook": "7.4.5",
"storybook-addon-turbo-build": "2.0.1",
diff --git a/packages/grafana-ui/rollup.config.ts b/packages/grafana-ui/rollup.config.ts
index 3659a42c58b..76891bf60c7 100644
--- a/packages/grafana-ui/rollup.config.ts
+++ b/packages/grafana-ui/rollup.config.ts
@@ -4,6 +4,7 @@ import copy from 'rollup-plugin-copy';
import dts from 'rollup-plugin-dts';
import esbuild from 'rollup-plugin-esbuild';
import { externals } from 'rollup-plugin-node-externals';
+import svg from 'rollup-plugin-svg-import';
const icons = require('../../public/app/core/icons/cached.json');
@@ -18,6 +19,7 @@ export default [
input: 'src/index.ts',
plugins: [
externals({ deps: true, packagePath: './package.json' }),
+ svg({ stringify: true }),
resolve(),
copy({
targets: [{ src: iconSrcPaths, dest: './dist/public/' }],
diff --git a/packages/grafana-ui/src/components/EmptyState/EmptyState.mdx b/packages/grafana-ui/src/components/EmptyState/EmptyState.mdx
new file mode 100644
index 00000000000..bf57bfc37ed
--- /dev/null
+++ b/packages/grafana-ui/src/components/EmptyState/EmptyState.mdx
@@ -0,0 +1,43 @@
+import { ArgTypes } from '@storybook/blocks';
+import { EmptyState } from './EmptyState';
+
+# EmptyState
+
+Use an empty state to communicate to the user that there is no data to display, or that a search query returned no results.
+
+## variant="search"
+
+### When to use
+
+Use in place of a results table or list when a search query or filter returns no results.
+
+There are sensible defaults for the image and message, so in most cases you can use it without any additional props.
+
+```jsx
+import { EmptyState } from '@grafana/ui';
+
+;
+```
+
+### Providing custom overrides
+
+You can optionally override the message or image, and add additional information or a button (e.g. to clear the search query)
+
+```jsx
+import { Button, EmptyState } from '@grafana/ui';
+
+}
+ image={}
+ message="No playlists found"
+>
+ Optionally provide some additional information here. Maybe even a link to{' '}
+
+ documentation.
+
+;
+```
+
+## Props
+
+
diff --git a/packages/grafana-ui/src/components/EmptyState/EmptyState.story.tsx b/packages/grafana-ui/src/components/EmptyState/EmptyState.story.tsx
new file mode 100644
index 00000000000..f9fc1912120
--- /dev/null
+++ b/packages/grafana-ui/src/components/EmptyState/EmptyState.story.tsx
@@ -0,0 +1,33 @@
+import { Meta, StoryFn } from '@storybook/react';
+import React from 'react';
+
+import { EmptyState } from './EmptyState';
+import mdx from './EmptyState.mdx';
+
+const meta: Meta = {
+ title: 'General/EmptyState',
+ component: EmptyState,
+ parameters: {
+ docs: {
+ page: mdx,
+ },
+ controls: {
+ exclude: ['button', 'image', 'variant'],
+ },
+ },
+ argTypes: {
+ children: {
+ type: 'string',
+ },
+ },
+};
+
+export const Basic: StoryFn = (args) => {
+ return ;
+};
+
+Basic.args = {
+ children: 'Use this space to add any additional information',
+};
+
+export default meta;
diff --git a/packages/grafana-ui/src/components/EmptyState/EmptyState.tsx b/packages/grafana-ui/src/components/EmptyState/EmptyState.tsx
new file mode 100644
index 00000000000..f7aed1dc5a9
--- /dev/null
+++ b/packages/grafana-ui/src/components/EmptyState/EmptyState.tsx
@@ -0,0 +1,47 @@
+import React, { ReactNode } from 'react';
+
+import { t } from '../../utils/i18n';
+import { Box } from '../Layout/Box/Box';
+import { Stack } from '../Layout/Stack/Stack';
+import { Text } from '../Text/Text';
+
+import { GrotNotFound } from './GrotNotFound/GrotNotFound';
+
+interface Props {
+ /**
+ * Provide a button to render below the message
+ */
+ button?: ReactNode;
+ hideImage?: boolean;
+ /**
+ * Override the default image for the variant
+ */
+ image?: ReactNode;
+ /**
+ * Message to display to the user
+ */
+ message?: string;
+ /**
+ * Empty state variant. Possible values are 'search'.
+ */
+ variant: 'search';
+}
+
+export const EmptyState = ({
+ button,
+ children,
+ image = ,
+ message = t('grafana-ui.empty-state.search-message', 'No results found'),
+ hideImage = false,
+}: React.PropsWithChildren) => {
+ return (
+
+ {!hideImage && image}
+
+ {message}
+ {children && {children}}
+
+ {button}
+
+ );
+};
diff --git a/packages/grafana-ui/src/components/EmptyState/GrotNotFound/GrotNotFound.tsx b/packages/grafana-ui/src/components/EmptyState/GrotNotFound/GrotNotFound.tsx
new file mode 100644
index 00000000000..1f09105dbf6
--- /dev/null
+++ b/packages/grafana-ui/src/components/EmptyState/GrotNotFound/GrotNotFound.tsx
@@ -0,0 +1,72 @@
+import { css } from '@emotion/css';
+import React, { SVGProps, useEffect, useRef } from 'react';
+import SVG from 'react-inlinesvg';
+
+import { GrafanaTheme2 } from '@grafana/data';
+
+import { useStyles2 } from '../../../themes';
+
+import notFoundSvg from './grot-not-found.svg';
+
+const MIN_ARM_ROTATION = -20;
+const MAX_ARM_ROTATION = 5;
+const MIN_ARM_TRANSLATION = -5;
+const MAX_ARM_TRANSLATION = 5;
+
+export interface Props {
+ width?: SVGProps['width'];
+ height?: SVGProps['height'];
+}
+
+export const GrotNotFound = ({ width = 'auto', height }: Props) => {
+ const svgRef = useRef(null);
+ const styles = useStyles2(getStyles);
+
+ useEffect(() => {
+ const handleMouseMove = (event: MouseEvent) => {
+ const grotArm = svgRef.current?.querySelector('#grot-not-found-arm');
+ const grotMagnifier = svgRef.current?.querySelector('#grot-not-found-magnifier');
+
+ const { clientX, clientY } = event;
+ const { innerWidth, innerHeight } = window;
+ const heightRatio = clientY / innerHeight;
+ const widthRatio = clientX / innerWidth;
+ const rotation = getIntermediateValue(heightRatio, MIN_ARM_ROTATION, MAX_ARM_ROTATION);
+ const translation = getIntermediateValue(widthRatio, MIN_ARM_TRANSLATION, MAX_ARM_TRANSLATION);
+
+ window.requestAnimationFrame(() => {
+ grotArm?.setAttribute('style', `transform: rotate(${rotation}deg) translateX(${translation}%)`);
+ grotMagnifier?.setAttribute('style', `transform: rotate(${rotation}deg) translateX(${translation}%)`);
+ });
+ };
+
+ window.addEventListener('mousemove', handleMouseMove);
+
+ return () => {
+ window.removeEventListener('mousemove', handleMouseMove);
+ };
+ }, []);
+
+ return ;
+};
+
+GrotNotFound.displayName = 'GrotNotFound';
+
+const getStyles = (theme: GrafanaTheme2) => {
+ return {
+ svg: css({
+ '#grot-not-found-arm, #grot-not-found-magnifier': {
+ transformOrigin: 'center',
+ },
+ }),
+ };
+};
+
+/**
+ * Given a start value, end value, and a ratio, return the intermediate value
+ * Works with negative and inverted start/end values
+ */
+const getIntermediateValue = (ratio: number, start: number, end: number) => {
+ const value = ratio * (end - start) + start;
+ return value;
+};
diff --git a/packages/grafana-ui/src/components/EmptyState/GrotNotFound/grot-not-found.svg b/packages/grafana-ui/src/components/EmptyState/GrotNotFound/grot-not-found.svg
new file mode 100644
index 00000000000..f5574a95946
--- /dev/null
+++ b/packages/grafana-ui/src/components/EmptyState/GrotNotFound/grot-not-found.svg
@@ -0,0 +1,62 @@
+
diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts
index 13d8a7b604b..ca48dd03bc3 100644
--- a/packages/grafana-ui/src/components/index.ts
+++ b/packages/grafana-ui/src/components/index.ts
@@ -30,6 +30,7 @@ export { ColorPicker, SeriesColorPicker } from './ColorPicker/ColorPicker';
export { ColorPickerInput } from './ColorPicker/ColorPickerInput';
export { SeriesColorPickerPopover, SeriesColorPickerPopoverWithTheme } from './ColorPicker/SeriesColorPickerPopover';
export { EmptySearchResult } from './EmptySearchResult/EmptySearchResult';
+export { EmptyState } from './EmptyState/EmptyState';
export { UnitPicker } from './UnitPicker/UnitPicker';
export { StatsPicker } from './StatsPicker/StatsPicker';
export { RefreshPicker, defaultIntervals } from './RefreshPicker/RefreshPicker';
diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json
index ffbcd86e32b..fa91f787e58 100644
--- a/public/locales/en-US/grafana.json
+++ b/public/locales/en-US/grafana.json
@@ -563,6 +563,9 @@
"drawer": {
"close": "Close"
},
+ "empty-state": {
+ "search-message": "No results found"
+ },
"modal": {
"close-tooltip": "Close"
},
diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json
index bc54a2fbd02..2ed5a8a6414 100644
--- a/public/locales/pseudo-LOCALE/grafana.json
+++ b/public/locales/pseudo-LOCALE/grafana.json
@@ -563,6 +563,9 @@
"drawer": {
"close": "Cľőşę"
},
+ "empty-state": {
+ "search-message": "Ńő řęşūľŧş ƒőūʼnđ"
+ },
"modal": {
"close-tooltip": "Cľőşę"
},
diff --git a/yarn.lock b/yarn.lock
index cfc3ace0f14..6b7b990b9a9 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4341,6 +4341,7 @@ __metadata:
rollup-plugin-dts: "npm:^5.0.0"
rollup-plugin-esbuild: "npm:5.0.0"
rollup-plugin-node-externals: "npm:^5.0.0"
+ rollup-plugin-svg-import: "npm:1.6.0"
rxjs: "npm:7.8.1"
sass-loader: "npm:14.1.1"
slate: "npm:0.47.9"
@@ -7140,6 +7141,16 @@ __metadata:
languageName: node
linkType: hard
+"@rollup/pluginutils@npm:^4.1.1":
+ version: 4.2.1
+ resolution: "@rollup/pluginutils@npm:4.2.1"
+ dependencies:
+ estree-walker: "npm:^2.0.1"
+ picomatch: "npm:^2.2.2"
+ checksum: 10/503a6f0a449e11a2873ac66cfdfb9a3a0b77ffa84c5cad631f5e4bc1063c850710e8d5cd5dab52477c0d66cda2ec719865726dbe753318cd640bab3fff7ca476
+ languageName: node
+ linkType: hard
+
"@rollup/pluginutils@npm:^5.0.1, @rollup/pluginutils@npm:^5.1.0":
version: 5.1.0
resolution: "@rollup/pluginutils@npm:5.1.0"
@@ -16978,7 +16989,7 @@ __metadata:
languageName: node
linkType: hard
-"estree-walker@npm:^2.0.2":
+"estree-walker@npm:^2.0.1, estree-walker@npm:^2.0.2":
version: 2.0.2
resolution: "estree-walker@npm:2.0.2"
checksum: 10/b02109c5d46bc2ed47de4990eef770f7457b1159a229f0999a09224d2b85ffeed2d7679cffcff90aeb4448e94b0168feb5265b209cdec29aad50a3d6e93d21e2
@@ -27877,6 +27888,17 @@ __metadata:
languageName: node
linkType: hard
+"rollup-plugin-svg-import@npm:1.6.0":
+ version: 1.6.0
+ resolution: "rollup-plugin-svg-import@npm:1.6.0"
+ dependencies:
+ "@rollup/pluginutils": "npm:^4.1.1"
+ peerDependencies:
+ rollup: ">=1.29.0 <3.0.0"
+ checksum: 10/453862c39d2301563d9d07f6647c295377ff66cf3174d2a0612389fda4bfd9fa72718d90279feb782d3525f1d6f9710e1dc78641cd9d4044360a0179f88054b0
+ languageName: node
+ linkType: hard
+
"rollup@npm:2.79.1":
version: 2.79.1
resolution: "rollup@npm:2.79.1"