diff --git a/.storybook/components/Roadmap/data.ts b/.storybook/components/Roadmap/data.ts
index 7e3fd461..1b9ce183 100644
--- a/.storybook/components/Roadmap/data.ts
+++ b/.storybook/components/Roadmap/data.ts
@@ -356,4 +356,10 @@ export const rows: Rows = [
stage: '🔵 experimental',
planned: 'Q2 2026',
},
+ {
+ component: 'EmptyState',
+ status: '✅ Done',
+ stage: '🔵 experimental',
+ planned: 'Q2 2026',
+ },
];
diff --git a/package.json b/package.json
index 12c22814..119d67fe 100644
--- a/package.json
+++ b/package.json
@@ -45,11 +45,13 @@
"@chromatic-com/storybook": "^5.1.2",
"@commitlint/cli": "^19.8.1",
"@commitlint/config-conventional": "^19.8.1",
+ "@eslint/js": "^9.39.4",
"@internationalized/date": "^3.12.1",
"@koobiq/date-adapter": "^3.5.1",
"@koobiq/date-formatter": "^3.5.1",
"@koobiq/design-tokens": "^3.17.2",
"@koobiq/internationalized-date-adapter": "^3.5.1",
+ "@koobiq/visuals": "^12.1.0",
"@microsoft/api-extractor": "^7.56.0",
"@rushstack/node-core-library": "^3.66.1",
"@storybook/addon-a11y": "^10.3.6",
@@ -58,7 +60,6 @@
"@storybook/addon-mcp": "^0.6.0",
"@storybook/react": "^10.3.6",
"@storybook/react-vite": "^10.3.6",
- "@eslint/js": "^9.39.4",
"@stylistic/eslint-plugin": "^5.10.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
@@ -72,8 +73,8 @@
"@vueless/storybook-dark-mode": "^10.0.8",
"browserslist": "^4.28.1",
"browserslist-to-esbuild": "^2.1.1",
- "chalk": "^4.1.2",
"bumpp": "^10.1.0",
+ "chalk": "^4.1.2",
"commit-and-tag-version": "^12.5.0",
"dotenv": "^16.4.7",
"eslint": "^9.39.4",
diff --git a/packages/components/src/components/EmptyState/EmptyState.mdx b/packages/components/src/components/EmptyState/EmptyState.mdx
new file mode 100644
index 00000000..5823cdf1
--- /dev/null
+++ b/packages/components/src/components/EmptyState/EmptyState.mdx
@@ -0,0 +1,101 @@
+import {
+ Meta,
+ Story,
+ Props,
+ Status,
+} from '../../../../../.storybook/components';
+
+import * as Stories from './EmptyState.stories';
+
+
+
+# EmptyState
+
+
+
+EmptyState communicates that there is no data to display and, optionally,
+suggests the next action the user can take.
+
+## Import
+
+```tsx
+import { EmptyState } from '@koobiq/react-components';
+```
+
+## Usage
+
+
+
+## Props
+
+
+
+## Anatomy
+
+The component is composed of four optional slots. Render only the ones you need.
+
+```tsx
+
+
+
+
+
+
+```
+
+- `EmptyState.Media` — an illustration or icon.
+- `EmptyState.Title` — the heading.
+- `EmptyState.Content` — the supporting text.
+- `EmptyState.Actions` — buttons, links or pseudo-links.
+
+## Size
+
+Use the `size` prop to switch between `big`, `normal` (default) and `compact`.
+The size adjusts the typography, paddings and the max width of the content.
+
+
+
+## Invalid
+
+Set the `isInvalid` prop to paint the title, text and media with the error color —
+for example, when data failed to load.
+
+
+
+## Alignment
+
+Use the `align` prop to align the content to the `start` or `center` (default)
+of the available block space.
+
+
+
+## With illustration
+
+Put any visual into `EmptyState.Media` — an icon, an `IconItem` or a raster
+illustration. The example below uses the illustrations from the
+[`@koobiq/visuals`](https://www.npmjs.com/package/@koobiq/visuals) package and
+switches the `light` / `dark` asset to match the active theme.
+
+
+
+## Text only
+
+Every slot is optional. Drop the media and actions for a minimal, text-only
+empty state.
+
+
+
+## Accessibility
+
+- If the empty state appears dynamically (e.g. after a search returns nothing),
+ add `role="status"` so screen readers announce it:
+
+ ```tsx
+
+ Nothing found
+ Try a different search query.
+
+ ```
+
+- `EmptyState.Title` is an `h3` by default — use `as` to fit the page headings.
+- Add `alt` to illustrations, or `alt=""` if they are decorative.
diff --git a/packages/components/src/components/EmptyState/EmptyState.module.css b/packages/components/src/components/EmptyState/EmptyState.module.css
new file mode 100644
index 00000000..e5c81342
--- /dev/null
+++ b/packages/components/src/components/EmptyState/EmptyState.module.css
@@ -0,0 +1,29 @@
+.base {
+ box-sizing: border-box;
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ align-items: center;
+ inline-size: 100%;
+ text-align: center;
+}
+
+/* align */
+.center {
+ justify-content: center;
+}
+
+.start {
+ justify-content: flex-start;
+}
+
+/* size */
+.big {
+ padding-block: var(--kbq-size-5xl);
+ padding-inline: var(--kbq-size-6xl);
+}
+
+.normal,
+.compact {
+ padding: var(--kbq-size-3xl);
+}
diff --git a/packages/components/src/components/EmptyState/EmptyState.stories.tsx b/packages/components/src/components/EmptyState/EmptyState.stories.tsx
new file mode 100644
index 00000000..1de3a6cd
--- /dev/null
+++ b/packages/components/src/components/EmptyState/EmptyState.stories.tsx
@@ -0,0 +1,168 @@
+import { IconBell16, IconTriangleExclamation16 } from '@koobiq/react-icons';
+import emptyDark from '@koobiq/visuals/dark/empty_256.webp';
+import emptyDark2x from '@koobiq/visuals/dark/empty_256@2x.webp';
+import emptyLight from '@koobiq/visuals/light/empty_256.webp';
+import emptyLight2x from '@koobiq/visuals/light/empty_256@2x.webp';
+import type { Meta, StoryObj } from '@storybook/react';
+import { useDarkMode } from '@vueless/storybook-dark-mode';
+
+import { Button } from '../Button';
+import { FlexBox } from '../FlexBox';
+import { IconItem } from '../IconItem';
+import { Link } from '../Link';
+import { Typography } from '../Typography';
+
+import { EmptyState } from './index';
+
+const meta = {
+ title: 'Components/EmptyState',
+ component: EmptyState,
+ subcomponents: {
+ 'EmptyState.Media': EmptyState.Media,
+ 'EmptyState.Title': EmptyState.Title,
+ 'EmptyState.Content': EmptyState.Content,
+ 'EmptyState.Actions': EmptyState.Actions,
+ },
+ tags: ['status:new', 'date:2026-06-26'],
+ parameters: {
+ layout: 'centered',
+ },
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Base: Story = {
+ render: (args) => (
+
+
+
+
+
+
+ No documents yet
+
+ Create your first document to get started, or import an existing one.
+
+
+
+
+
+
+ ),
+};
+
+export const Sizes: Story = {
+ parameters: { layout: 'padded' },
+ render: (args) => (
+
+ {(['big', 'normal', 'compact'] as const).map((size) => (
+
+
+
+
+
+
+ No documents yet
+
+ Create your first document to get started.
+
+
+
+
+
+ ))}
+
+ ),
+};
+
+export const Invalid: Story = {
+ render: (args) => (
+
+
+
+
+
+
+ Failed to load documents
+
+ Something went wrong while loading the data. Please try again.
+
+
+
+
+
+ ),
+};
+
+export const Align: Story = {
+ parameters: { layout: 'padded' },
+ render: (args) => (
+
+
+
+
+
+
+ No documents yet
+
+ The content is aligned to the start of the available space.
+
+
+ ),
+};
+
+export const WithIllustration: Story = {
+ render: function Render(args) {
+ const isDark = useDarkMode();
+
+ const src = isDark ? emptyDark : emptyLight;
+ const src2x = isDark ? emptyDark2x : emptyLight2x;
+
+ return (
+
+
+
+
+ No documents yet
+
+ {
+ 'Create your first document to get started,\nor import an existing one.'
+ }
+
+
+
+
+
+ );
+ },
+};
+
+export const TextOnly: Story = {
+ render: (args) => (
+
+ Nothing found
+
+
+ Try changing the search query or
+
+
+ reset the filters
+
+ .
+
+
+ ),
+};
diff --git a/packages/components/src/components/EmptyState/EmptyState.test.tsx b/packages/components/src/components/EmptyState/EmptyState.test.tsx
new file mode 100644
index 00000000..31c5c7bc
--- /dev/null
+++ b/packages/components/src/components/EmptyState/EmptyState.test.tsx
@@ -0,0 +1,192 @@
+import { createRef } from 'react';
+
+import { screen, render } from '@testing-library/react';
+import { describe, it, expect } from 'vitest';
+
+import { EmptyState } from './index';
+
+describe('EmptyState', () => {
+ const baseProps = { 'data-testid': 'empty-state' };
+
+ it('should accept the ref', () => {
+ const ref = createRef();
+
+ render();
+
+ expect(ref.current).toBe(screen.getByTestId('empty-state'));
+ });
+
+ it('should merge a custom class name with the default ones', () => {
+ render();
+
+ expect(screen.getByTestId('empty-state')).toHaveClass('foo');
+ });
+
+ it('should render the subcomponents', () => {
+ render(
+
+
+ Title
+ Content
+ Actions
+
+ );
+
+ expect(screen.getByTestId('media')).toBeInTheDocument();
+ expect(screen.getByTestId('title')).toHaveTextContent('Title');
+ expect(screen.getByTestId('content')).toHaveTextContent('Content');
+ expect(screen.getByTestId('actions')).toHaveTextContent('Actions');
+ });
+
+ it('should default to the "normal" size, centered and non-error', () => {
+ render();
+
+ const root = screen.getByTestId('empty-state');
+
+ expect(root).toHaveAttribute('data-size', 'normal');
+ expect(root).toHaveAttribute('data-align', 'center');
+ expect(root).not.toHaveAttribute('data-invalid');
+ });
+
+ it('should reflect the align prop', () => {
+ render();
+
+ expect(screen.getByTestId('empty-state')).toHaveAttribute(
+ 'data-align',
+ 'start'
+ );
+ });
+
+ it('should support the polymorphic "as" prop on the root', () => {
+ render();
+
+ expect(screen.getByTestId('empty-state').tagName).toBe('SECTION');
+ });
+
+ it('should propagate size and invalid state to the subcomponents via context', () => {
+ render(
+
+ Title
+
+ );
+
+ const title = screen.getByTestId('title');
+
+ expect(title).toHaveAttribute('data-size', 'big');
+ expect(title).toHaveAttribute('data-invalid');
+ });
+
+ it('should render Title as "h3" and Content as "p" by default', () => {
+ render(
+
+ Title
+ Content
+
+ );
+
+ expect(screen.getByTestId('title').tagName).toBe('H3');
+ expect(screen.getByTestId('content').tagName).toBe('P');
+ });
+
+ it('should support the polymorphic "as" prop on Title and Content', () => {
+ render(
+
+
+ Title
+
+
+ Content
+
+
+ );
+
+ expect(screen.getByTestId('title').tagName).toBe('H1');
+ expect(screen.getByTestId('content').tagName).toBe('SPAN');
+ });
+});
+
+describe('EmptyState subcomponents', () => {
+ it('should forward refs to the underlying elements', () => {
+ const mediaRef = createRef();
+ const titleRef = createRef();
+ const contentRef = createRef();
+ const actionsRef = createRef();
+
+ render(
+
+
+
+ Title
+
+
+ Content
+
+
+
+ );
+
+ expect(mediaRef.current).toBe(screen.getByTestId('media'));
+ expect(titleRef.current).toBe(screen.getByTestId('title'));
+ expect(contentRef.current).toBe(screen.getByTestId('content'));
+ expect(actionsRef.current).toBe(screen.getByTestId('actions'));
+ });
+
+ it('should merge a custom class name on each subcomponent', () => {
+ render(
+
+
+
+ Title
+
+
+ Content
+
+
+
+ );
+
+ expect(screen.getByTestId('media')).toHaveClass('m');
+ expect(screen.getByTestId('title')).toHaveClass('t');
+ expect(screen.getByTestId('content')).toHaveClass('c');
+ expect(screen.getByTestId('actions')).toHaveClass('a');
+ });
+
+ it('should propagate the size to every subcomponent', () => {
+ render(
+
+
+ Title
+ Content
+
+
+ );
+
+ for (const id of ['media', 'title', 'content', 'actions']) {
+ expect(screen.getByTestId(id)).toHaveAttribute('data-size', 'compact');
+ }
+ });
+
+ it('should propagate the align value to the media', () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByTestId('media')).toHaveAttribute('data-align', 'start');
+ });
+
+ it('should mark media, title and content as invalid', () => {
+ render(
+
+
+ Title
+ Content
+
+ );
+
+ expect(screen.getByTestId('media')).toHaveAttribute('data-invalid');
+ expect(screen.getByTestId('title')).toHaveAttribute('data-invalid');
+ expect(screen.getByTestId('content')).toHaveAttribute('data-invalid');
+ });
+});
diff --git a/packages/components/src/components/EmptyState/EmptyState.tsx b/packages/components/src/components/EmptyState/EmptyState.tsx
new file mode 100644
index 00000000..ef2dbd98
--- /dev/null
+++ b/packages/components/src/components/EmptyState/EmptyState.tsx
@@ -0,0 +1,69 @@
+'use client';
+
+import { useMemo } from 'react';
+
+import { clsx, polymorphicForwardRef } from '@koobiq/react-core';
+
+import {
+ EmptyStateActions,
+ EmptyStateContent,
+ EmptyStateMedia,
+ EmptyStateTitle,
+} from './components';
+import s from './EmptyState.module.css';
+import { EmptyStateContext } from './EmptyStateContext';
+import type { EmptyStateBaseProps } from './types';
+
+const EmptyStateComponent = polymorphicForwardRef<'div', EmptyStateBaseProps>(
+ (props, ref) => {
+ const {
+ as: Tag = 'div',
+ size = 'normal',
+ isInvalid = false,
+ align = 'center',
+ className,
+ children,
+ ...other
+ } = props;
+
+ const contextValue = useMemo(
+ () => ({ size, isInvalid, align }),
+ [size, isInvalid, align]
+ );
+
+ return (
+
+
+ {children}
+
+
+ );
+ }
+);
+
+EmptyStateComponent.displayName = 'EmptyState';
+
+type CompoundedComponent = typeof EmptyStateComponent & {
+ Media: typeof EmptyStateMedia;
+ Title: typeof EmptyStateTitle;
+ Content: typeof EmptyStateContent;
+ Actions: typeof EmptyStateActions;
+};
+
+/**
+ * EmptyState communicates that there is no data to display and, optionally,
+ * suggests the next action the user can take.
+ */
+export const EmptyState = EmptyStateComponent as CompoundedComponent;
+
+EmptyState.Media = EmptyStateMedia;
+EmptyState.Title = EmptyStateTitle;
+EmptyState.Content = EmptyStateContent;
+EmptyState.Actions = EmptyStateActions;
diff --git a/packages/components/src/components/EmptyState/EmptyStateContext.ts b/packages/components/src/components/EmptyState/EmptyStateContext.ts
new file mode 100644
index 00000000..8e313a7d
--- /dev/null
+++ b/packages/components/src/components/EmptyState/EmptyStateContext.ts
@@ -0,0 +1,17 @@
+'use client';
+
+import { createContext } from 'react';
+
+import type { EmptyStatePropAlign, EmptyStatePropSize } from './types';
+
+export type EmptyStateContextValue = {
+ size: EmptyStatePropSize;
+ isInvalid: boolean;
+ align: EmptyStatePropAlign;
+};
+
+export const EmptyStateContext = createContext({
+ size: 'normal',
+ isInvalid: false,
+ align: 'center',
+});
diff --git a/packages/components/src/components/EmptyState/components/EmptyStateActions/EmptyStateActions.module.css b/packages/components/src/components/EmptyState/components/EmptyStateActions/EmptyStateActions.module.css
new file mode 100644
index 00000000..2b8be205
--- /dev/null
+++ b/packages/components/src/components/EmptyState/components/EmptyStateActions/EmptyStateActions.module.css
@@ -0,0 +1,16 @@
+.base {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: center;
+ gap: var(--kbq-size-s);
+}
+
+.compact,
+.normal {
+ max-inline-size: 320px;
+}
+
+.big {
+ max-inline-size: 480px;
+}
diff --git a/packages/components/src/components/EmptyState/components/EmptyStateActions/EmptyStateActions.tsx b/packages/components/src/components/EmptyState/components/EmptyStateActions/EmptyStateActions.tsx
new file mode 100644
index 00000000..8f01849b
--- /dev/null
+++ b/packages/components/src/components/EmptyState/components/EmptyStateActions/EmptyStateActions.tsx
@@ -0,0 +1,34 @@
+'use client';
+
+import type { ComponentPropsWithRef } from 'react';
+import { forwardRef, useContext } from 'react';
+
+import { clsx, mergeProps } from '@koobiq/react-core';
+
+import { EmptyStateContext } from '../../EmptyStateContext';
+
+import s from './EmptyStateActions.module.css';
+import type { EmptyStateActionsProps } from './types';
+
+/** EmptyState.Actions — the actions slot (buttons, links) of the EmptyState. */
+export const EmptyStateActions = forwardRef<
+ HTMLDivElement,
+ EmptyStateActionsProps
+>((props, ref) => {
+ const { className, children, ...other } = props;
+
+ const { size } = useContext(EmptyStateContext);
+
+ const rootProps = mergeProps[]>(
+ { className: clsx(s.base, s[size], className) },
+ other
+ );
+
+ return (
+
+ {children}
+
+ );
+});
+
+EmptyStateActions.displayName = 'EmptyState.Actions';
diff --git a/packages/components/src/components/EmptyState/components/EmptyStateActions/index.ts b/packages/components/src/components/EmptyState/components/EmptyStateActions/index.ts
new file mode 100644
index 00000000..cc529b6f
--- /dev/null
+++ b/packages/components/src/components/EmptyState/components/EmptyStateActions/index.ts
@@ -0,0 +1,2 @@
+export * from './EmptyStateActions';
+export * from './types';
diff --git a/packages/components/src/components/EmptyState/components/EmptyStateActions/types.ts b/packages/components/src/components/EmptyState/components/EmptyStateActions/types.ts
new file mode 100644
index 00000000..fb606a39
--- /dev/null
+++ b/packages/components/src/components/EmptyState/components/EmptyStateActions/types.ts
@@ -0,0 +1,6 @@
+import type { ComponentPropsWithRef } from 'react';
+
+import type { DataAttributeProps } from '@koobiq/react-core';
+
+export type EmptyStateActionsProps = ComponentPropsWithRef<'div'> &
+ DataAttributeProps;
diff --git a/packages/components/src/components/EmptyState/components/EmptyStateContent/EmptyStateContent.module.css b/packages/components/src/components/EmptyState/components/EmptyStateContent/EmptyStateContent.module.css
new file mode 100644
index 00000000..fad02526
--- /dev/null
+++ b/packages/components/src/components/EmptyState/components/EmptyStateContent/EmptyStateContent.module.css
@@ -0,0 +1,35 @@
+@import url('../../../../styles/mixins.css');
+
+.base {
+ margin: 0;
+ color: var(--kbq-foreground-contrast-secondary);
+}
+
+.invalid {
+ color: var(--kbq-foreground-error);
+}
+
+.compact {
+ @mixin typography text-compact;
+
+ max-inline-size: 320px;
+ margin-block-end: var(--kbq-size-s);
+}
+
+.normal {
+ @mixin typography text-normal;
+
+ max-inline-size: 320px;
+ margin-block-end: var(--kbq-size-s);
+}
+
+.big {
+ @mixin typography text-big;
+
+ max-inline-size: 480px;
+ margin-block-end: var(--kbq-size-xl);
+}
+
+.base:last-child {
+ margin-block-end: 0;
+}
diff --git a/packages/components/src/components/EmptyState/components/EmptyStateContent/EmptyStateContent.tsx b/packages/components/src/components/EmptyState/components/EmptyStateContent/EmptyStateContent.tsx
new file mode 100644
index 00000000..9058c307
--- /dev/null
+++ b/packages/components/src/components/EmptyState/components/EmptyStateContent/EmptyStateContent.tsx
@@ -0,0 +1,34 @@
+'use client';
+
+import { useContext } from 'react';
+
+import { clsx, polymorphicForwardRef } from '@koobiq/react-core';
+
+import { EmptyStateContext } from '../../EmptyStateContext';
+
+import s from './EmptyStateContent.module.css';
+import type { EmptyStateContentProps } from './types';
+
+/** EmptyState.Content — the supporting text slot of the EmptyState. */
+export const EmptyStateContent = polymorphicForwardRef<
+ 'p',
+ EmptyStateContentProps
+>((props, ref) => {
+ const { as: Tag = 'p', className, children, ...other } = props;
+
+ const { size, isInvalid } = useContext(EmptyStateContext);
+
+ return (
+
+ {children}
+
+ );
+});
+
+EmptyStateContent.displayName = 'EmptyState.Content';
diff --git a/packages/components/src/components/EmptyState/components/EmptyStateContent/index.ts b/packages/components/src/components/EmptyState/components/EmptyStateContent/index.ts
new file mode 100644
index 00000000..65e412d3
--- /dev/null
+++ b/packages/components/src/components/EmptyState/components/EmptyStateContent/index.ts
@@ -0,0 +1,2 @@
+export * from './EmptyStateContent';
+export * from './types';
diff --git a/packages/components/src/components/EmptyState/components/EmptyStateContent/types.ts b/packages/components/src/components/EmptyState/components/EmptyStateContent/types.ts
new file mode 100644
index 00000000..0cedbf13
--- /dev/null
+++ b/packages/components/src/components/EmptyState/components/EmptyStateContent/types.ts
@@ -0,0 +1,15 @@
+import type { ElementType, ReactNode } from 'react';
+
+import type { DataAttributeProps } from '@koobiq/react-core';
+
+export type EmptyStateContentProps = {
+ /**
+ * The HTML element to render as.
+ * @default 'p'
+ */
+ as?: ElementType;
+ /** Additional CSS-classes. */
+ className?: string;
+ /** The content of the text. */
+ children?: ReactNode;
+} & DataAttributeProps;
diff --git a/packages/components/src/components/EmptyState/components/EmptyStateMedia/EmptyStateMedia.module.css b/packages/components/src/components/EmptyState/components/EmptyStateMedia/EmptyStateMedia.module.css
new file mode 100644
index 00000000..0b8e4624
--- /dev/null
+++ b/packages/components/src/components/EmptyState/components/EmptyStateMedia/EmptyStateMedia.module.css
@@ -0,0 +1,25 @@
+.base {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.invalid {
+ color: var(--kbq-foreground-error);
+}
+
+.big {
+ margin-block-end: var(--kbq-size-3xl);
+}
+
+.normal {
+ margin-block-end: var(--kbq-size-xl);
+}
+
+.compact {
+ margin-block-end: var(--kbq-size-m);
+}
+
+.base:last-child {
+ margin-block-end: 0;
+}
diff --git a/packages/components/src/components/EmptyState/components/EmptyStateMedia/EmptyStateMedia.tsx b/packages/components/src/components/EmptyState/components/EmptyStateMedia/EmptyStateMedia.tsx
new file mode 100644
index 00000000..4739adcb
--- /dev/null
+++ b/packages/components/src/components/EmptyState/components/EmptyStateMedia/EmptyStateMedia.tsx
@@ -0,0 +1,35 @@
+'use client';
+
+import { useContext } from 'react';
+
+import { clsx, polymorphicForwardRef } from '@koobiq/react-core';
+
+import { EmptyStateContext } from '../../EmptyStateContext';
+
+import s from './EmptyStateMedia.module.css';
+import type { EmptyStateMediaProps } from './types';
+
+/** EmptyState.Media — the illustration or icon slot of the EmptyState. */
+export const EmptyStateMedia = polymorphicForwardRef<
+ 'div',
+ EmptyStateMediaProps
+>((props, ref) => {
+ const { as: Tag = 'div', className, children, ...other } = props;
+
+ const { size, align, isInvalid } = useContext(EmptyStateContext);
+
+ return (
+
+ {children}
+
+ );
+});
+
+EmptyStateMedia.displayName = 'EmptyState.Media';
diff --git a/packages/components/src/components/EmptyState/components/EmptyStateMedia/index.ts b/packages/components/src/components/EmptyState/components/EmptyStateMedia/index.ts
new file mode 100644
index 00000000..998d3ddd
--- /dev/null
+++ b/packages/components/src/components/EmptyState/components/EmptyStateMedia/index.ts
@@ -0,0 +1,2 @@
+export * from './EmptyStateMedia';
+export * from './types';
diff --git a/packages/components/src/components/EmptyState/components/EmptyStateMedia/types.ts b/packages/components/src/components/EmptyState/components/EmptyStateMedia/types.ts
new file mode 100644
index 00000000..0011cf80
--- /dev/null
+++ b/packages/components/src/components/EmptyState/components/EmptyStateMedia/types.ts
@@ -0,0 +1,15 @@
+import type { ElementType, ReactNode } from 'react';
+
+import type { DataAttributeProps } from '@koobiq/react-core';
+
+export type EmptyStateMediaProps = {
+ /**
+ * The HTML element to render as.
+ * @default 'div'
+ */
+ as?: ElementType;
+ /** Additional CSS-classes. */
+ className?: string;
+ /** The content of the slot. */
+ children?: ReactNode;
+} & DataAttributeProps;
diff --git a/packages/components/src/components/EmptyState/components/EmptyStateTitle/EmptyStateTitle.module.css b/packages/components/src/components/EmptyState/components/EmptyStateTitle/EmptyStateTitle.module.css
new file mode 100644
index 00000000..f6a3b971
--- /dev/null
+++ b/packages/components/src/components/EmptyState/components/EmptyStateTitle/EmptyStateTitle.module.css
@@ -0,0 +1,35 @@
+@import url('../../../../styles/mixins.css');
+
+.base {
+ margin: 0;
+ color: var(--kbq-foreground-contrast);
+}
+
+.invalid {
+ color: var(--kbq-foreground-error);
+}
+
+.compact {
+ @mixin typography text-normal;
+
+ max-inline-size: 320px;
+ margin-block-end: var(--kbq-size-xxs);
+}
+
+.normal {
+ @mixin typography subheading;
+
+ max-inline-size: 320px;
+ margin-block-end: var(--kbq-size-xxs);
+}
+
+.big {
+ @mixin typography headline;
+
+ max-inline-size: 480px;
+ margin-block-end: var(--kbq-size-l);
+}
+
+.base:last-child {
+ margin-block-end: 0;
+}
diff --git a/packages/components/src/components/EmptyState/components/EmptyStateTitle/EmptyStateTitle.tsx b/packages/components/src/components/EmptyState/components/EmptyStateTitle/EmptyStateTitle.tsx
new file mode 100644
index 00000000..94291d6a
--- /dev/null
+++ b/packages/components/src/components/EmptyState/components/EmptyStateTitle/EmptyStateTitle.tsx
@@ -0,0 +1,34 @@
+'use client';
+
+import { useContext } from 'react';
+
+import { clsx, polymorphicForwardRef } from '@koobiq/react-core';
+
+import { EmptyStateContext } from '../../EmptyStateContext';
+
+import s from './EmptyStateTitle.module.css';
+import type { EmptyStateTitleProps } from './types';
+
+/** EmptyState.Title — the heading slot of the EmptyState. */
+export const EmptyStateTitle = polymorphicForwardRef<
+ 'h3',
+ EmptyStateTitleProps
+>((props, ref) => {
+ const { as: Tag = 'h3', className, children, ...other } = props;
+
+ const { size, isInvalid } = useContext(EmptyStateContext);
+
+ return (
+
+ {children}
+
+ );
+});
+
+EmptyStateTitle.displayName = 'EmptyState.Title';
diff --git a/packages/components/src/components/EmptyState/components/EmptyStateTitle/index.ts b/packages/components/src/components/EmptyState/components/EmptyStateTitle/index.ts
new file mode 100644
index 00000000..141836a3
--- /dev/null
+++ b/packages/components/src/components/EmptyState/components/EmptyStateTitle/index.ts
@@ -0,0 +1,2 @@
+export * from './EmptyStateTitle';
+export * from './types';
diff --git a/packages/components/src/components/EmptyState/components/EmptyStateTitle/types.ts b/packages/components/src/components/EmptyState/components/EmptyStateTitle/types.ts
new file mode 100644
index 00000000..f27d4090
--- /dev/null
+++ b/packages/components/src/components/EmptyState/components/EmptyStateTitle/types.ts
@@ -0,0 +1,15 @@
+import type { ElementType, ReactNode } from 'react';
+
+import type { DataAttributeProps } from '@koobiq/react-core';
+
+export type EmptyStateTitleProps = {
+ /**
+ * The HTML element to render as.
+ * @default 'h3'
+ */
+ as?: ElementType;
+ /** Additional CSS-classes. */
+ className?: string;
+ /** The content of the title. */
+ children?: ReactNode;
+} & DataAttributeProps;
diff --git a/packages/components/src/components/EmptyState/components/index.ts b/packages/components/src/components/EmptyState/components/index.ts
new file mode 100644
index 00000000..0edbd298
--- /dev/null
+++ b/packages/components/src/components/EmptyState/components/index.ts
@@ -0,0 +1,4 @@
+export * from './EmptyStateMedia';
+export * from './EmptyStateTitle';
+export * from './EmptyStateContent';
+export * from './EmptyStateActions';
diff --git a/packages/components/src/components/EmptyState/index.ts b/packages/components/src/components/EmptyState/index.ts
new file mode 100644
index 00000000..523c8144
--- /dev/null
+++ b/packages/components/src/components/EmptyState/index.ts
@@ -0,0 +1,3 @@
+export * from './EmptyState';
+export * from './types';
+export * from './components';
diff --git a/packages/components/src/components/EmptyState/types.ts b/packages/components/src/components/EmptyState/types.ts
new file mode 100644
index 00000000..99f55f77
--- /dev/null
+++ b/packages/components/src/components/EmptyState/types.ts
@@ -0,0 +1,38 @@
+import type { ElementType, ReactNode } from 'react';
+
+import type { DataAttributeProps } from '@koobiq/react-core';
+
+export const emptyStatePropSize = ['big', 'normal', 'compact'] as const;
+
+export type EmptyStatePropSize = (typeof emptyStatePropSize)[number];
+
+export const emptyStatePropAlign = ['start', 'center'] as const;
+
+export type EmptyStatePropAlign = (typeof emptyStatePropAlign)[number];
+
+export type EmptyStateBaseProps = {
+ /**
+ * The size of the component.
+ * @default 'normal'
+ */
+ size?: EmptyStatePropSize;
+ /**
+ * Whether the EmptyState represents an invalid (error) state.
+ * Paints the title, text and media with the error color.
+ */
+ isInvalid?: boolean;
+ /**
+ * The block alignment of the content within the available space.
+ * @default 'center'
+ */
+ align?: EmptyStatePropAlign;
+ /**
+ * The HTML element to render as.
+ * @default 'div'
+ */
+ as?: ElementType;
+ /** Additional CSS-classes. */
+ className?: string;
+ /** The content of the component. */
+ children?: ReactNode;
+} & DataAttributeProps;
diff --git a/packages/components/src/components/index.ts b/packages/components/src/components/index.ts
index c8b199a6..9cb9d87c 100644
--- a/packages/components/src/components/index.ts
+++ b/packages/components/src/components/index.ts
@@ -53,6 +53,7 @@ export * from './Navbar';
export * from './ActionsPanel';
export * from './Tree';
export * from './TreeSelect';
+export * from './EmptyState';
export * from './layout';
export {
useListData,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index cb670559..21abf963 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -43,6 +43,9 @@ importers:
'@koobiq/internationalized-date-adapter':
specifier: ^3.5.1
version: 3.5.1(@internationalized/date@3.12.1)(@koobiq/date-adapter@3.5.1)
+ '@koobiq/visuals':
+ specifier: ^12.1.0
+ version: 12.1.0
'@microsoft/api-extractor':
specifier: ^7.56.0
version: 7.58.7(@types/node@25.8.0)
@@ -126,7 +129,7 @@ importers:
version: 9.1.2(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-import:
specifier: ^2.32.0
- version: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
+ version: 2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-jsdoc:
specifier: ^51.4.1
version: 51.4.1(eslint@9.39.4(jiti@2.6.1))
@@ -533,7 +536,7 @@ importers:
version: 9.39.4(jiti@2.6.1)
eslint-config-next:
specifier: ^16.2.9
- version: 16.2.9(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
+ version: 16.2.9(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
jsdom:
specifier: ^25.0.1
version: 25.0.1
@@ -1831,6 +1834,9 @@ packages:
react: ^19.0.0
react-dom: ^19.0.0
+ '@koobiq/visuals@12.1.0':
+ resolution: {integrity: sha512-JdrGcYrH6EpEXC+POWRa8uT8zv8XH9H6EG9Aib9PxS5jOa0zvkcqLz09dLasQOKrQe3tRb3wksruMH2YxQad7Q==}
+
'@mdx-js/react@3.1.1':
resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==}
peerDependencies:
@@ -10163,6 +10169,8 @@ snapshots:
react-aria-components: 1.16.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
react-dom: 19.2.3(react@19.2.3)
+ '@koobiq/visuals@12.1.0': {}
+
'@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.3)':
dependencies:
'@types/mdx': 2.0.13
@@ -11947,10 +11955,10 @@ snapshots:
'@types/unist@3.0.3': {}
- '@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.3.0(jiti@2.6.1))(typescript@6.0.3))(eslint@10.3.0(jiti@2.6.1))(typescript@6.0.3)':
+ '@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@10.3.0(jiti@2.6.1))(typescript@6.0.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
- '@typescript-eslint/parser': 8.61.0(eslint@10.3.0(jiti@2.6.1))(typescript@6.0.3)
+ '@typescript-eslint/parser': 8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
'@typescript-eslint/scope-manager': 8.61.0
'@typescript-eslint/type-utils': 8.61.0(eslint@10.3.0(jiti@2.6.1))(typescript@6.0.3)
'@typescript-eslint/utils': 8.61.0(eslint@10.3.0(jiti@2.6.1))(typescript@6.0.3)
@@ -12196,7 +12204,7 @@ snapshots:
obug: 2.1.3
std-env: 4.1.0
tinyrainbow: 3.1.0
- vitest: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@25.8.0)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1)(vite@8.0.13(@types/node@25.8.0)(esbuild@0.27.7)(jiti@2.6.1)(sugarss@5.0.1(postcss@8.5.15))(tsx@4.19.3)(yaml@2.9.0))
+ vitest: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@25.8.0)(@vitest/coverage-v8@4.1.9)(jsdom@25.0.1)(vite@7.3.3(@types/node@25.8.0)(jiti@2.6.1)(lightningcss@1.29.3)(sugarss@5.0.1(postcss@8.5.15))(tsx@4.19.3)(yaml@2.9.0))
'@vitest/expect@3.2.4':
dependencies:
@@ -13831,13 +13839,13 @@ snapshots:
optionalDependencies:
source-map: 0.6.1
- eslint-config-next@16.2.9(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3):
+ eslint-config-next@16.2.9(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3):
dependencies:
'@next/eslint-plugin-next': 16.2.9
eslint: 9.39.4(jiti@2.6.1)
eslint-import-resolver-node: 0.3.10
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))
- eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-react-hooks: 7.1.1(eslint@9.39.4(jiti@2.6.1))
@@ -13871,7 +13879,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)):
+ eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.3
@@ -13882,21 +13890,22 @@ snapshots:
tinyglobby: 0.2.17
unrs-resolver: 1.12.2
optionalDependencies:
- eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
transitivePeerDependencies:
- supports-color
- eslint-module-utils@2.12.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)):
+ eslint-module-utils@2.12.1(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)):
dependencies:
debug: 3.2.7
optionalDependencies:
+ '@typescript-eslint/parser': 8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
eslint: 9.39.4(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))
transitivePeerDependencies:
- supports-color
- eslint-plugin-import@2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)):
+ eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@@ -13907,7 +13916,7 @@ snapshots:
doctrine: 2.1.0
eslint: 9.39.4(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
- eslint-module-utils: 2.12.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))
+ eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))
hasown: 2.0.3
is-core-module: 2.16.1
is-glob: 4.0.3
@@ -13918,6 +13927,8 @@ snapshots:
semver: 6.3.1
string.prototype.trimend: 1.0.9
tsconfig-paths: 3.15.0
+ optionalDependencies:
+ '@typescript-eslint/parser': 8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)
transitivePeerDependencies:
- eslint-import-resolver-typescript
- eslint-import-resolver-webpack
@@ -18349,7 +18360,7 @@ snapshots:
typescript-eslint@8.61.0(eslint@10.3.0(jiti@2.6.1))(typescript@6.0.3):
dependencies:
- '@typescript-eslint/eslint-plugin': 8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.3.0(jiti@2.6.1))(typescript@6.0.3))(eslint@10.3.0(jiti@2.6.1))(typescript@6.0.3)
+ '@typescript-eslint/eslint-plugin': 8.61.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@10.3.0(jiti@2.6.1))(typescript@6.0.3)
'@typescript-eslint/parser': 8.61.0(eslint@10.3.0(jiti@2.6.1))(typescript@6.0.3)
'@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3)
'@typescript-eslint/utils': 8.61.0(eslint@10.3.0(jiti@2.6.1))(typescript@6.0.3)
diff --git a/tools/api-extractor/config.json b/tools/api-extractor/config.json
index 4d9716fc..202e10d5 100644
--- a/tools/api-extractor/config.json
+++ b/tools/api-extractor/config.json
@@ -17,6 +17,7 @@
"DateInput",
"DatePicker",
"Divider",
+ "EmptyState",
"FlexBox",
"Form",
"FormField",
diff --git a/tools/public_api_guard/components/EmptyState.api.md b/tools/public_api_guard/components/EmptyState.api.md
new file mode 100644
index 00000000..8bae650d
--- /dev/null
+++ b/tools/public_api_guard/components/EmptyState.api.md
@@ -0,0 +1,80 @@
+## API Report File for "koobiq-react"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+
+import type { ComponentPropsWithRef } from 'react';
+import type { DataAttributeProps } from '@koobiq/react-core';
+import { ElementType } from 'react';
+import { ForwardRefExoticComponent } from 'react';
+import { PolyForwardComponent } from '@koobiq/react-core';
+import type { ReactNode } from 'react';
+import { RefAttributes } from 'react';
+
+// Warning: (ae-forgotten-export) The symbol "CompoundedComponent" needs to be exported by the entry point index.d.ts
+//
+// @public
+export const EmptyState: CompoundedComponent;
+
+// @public
+export const EmptyStateActions: ForwardRefExoticComponent & RefAttributes>;
+
+// @public (undocumented)
+export type EmptyStateActionsProps = ComponentPropsWithRef<'div'> & DataAttributeProps;
+
+// @public (undocumented)
+export type EmptyStateBaseProps = {
+ size?: EmptyStatePropSize;
+ isInvalid?: boolean;
+ align?: EmptyStatePropAlign;
+ as?: ElementType;
+ className?: string;
+ children?: ReactNode;
+} & DataAttributeProps;
+
+// @public
+export const EmptyStateContent: PolyForwardComponent<"p", EmptyStateContentProps, ElementType>;
+
+// @public (undocumented)
+export type EmptyStateContentProps = {
+ as?: ElementType;
+ className?: string;
+ children?: ReactNode;
+} & DataAttributeProps;
+
+// @public
+export const EmptyStateMedia: PolyForwardComponent<"div", EmptyStateMediaProps, ElementType>;
+
+// @public (undocumented)
+export type EmptyStateMediaProps = {
+ as?: ElementType;
+ className?: string;
+ children?: ReactNode;
+} & DataAttributeProps;
+
+// @public (undocumented)
+export type EmptyStatePropAlign = (typeof emptyStatePropAlign)[number];
+
+// @public (undocumented)
+export const emptyStatePropAlign: readonly ["start", "center"];
+
+// @public (undocumented)
+export type EmptyStatePropSize = (typeof emptyStatePropSize)[number];
+
+// @public (undocumented)
+export const emptyStatePropSize: readonly ["big", "normal", "compact"];
+
+// @public
+export const EmptyStateTitle: PolyForwardComponent<"h3", EmptyStateTitleProps, ElementType>;
+
+// @public (undocumented)
+export type EmptyStateTitleProps = {
+ as?: ElementType;
+ className?: string;
+ children?: ReactNode;
+} & DataAttributeProps;
+
+// (No @packageDocumentation comment for this package)
+
+```