diff --git a/packages/design-system/src/components/N8nDatatable/Datatable.stories.ts b/packages/design-system/src/components/N8nDatatable/Datatable.stories.ts
new file mode 100644
index 0000000000000..3166530de182c
--- /dev/null
+++ b/packages/design-system/src/components/N8nDatatable/Datatable.stories.ts
@@ -0,0 +1,21 @@
+import N8nDatatable from './Datatable.vue';
+import type { StoryFn } from '@storybook/vue';
+import { rows, columns } from './__tests__/data';
+
+export default {
+ title: 'Atoms/Datatable',
+ component: N8nDatatable,
+};
+
+export const Default: StoryFn = (args, { argTypes }) => ({
+ props: Object.keys(argTypes),
+ components: {
+ N8nDatatable,
+ },
+ template: '',
+});
+
+Default.args = {
+ columns,
+ rows,
+};
diff --git a/packages/design-system/src/components/N8nDatatable/Datatable.vue b/packages/design-system/src/components/N8nDatatable/Datatable.vue
new file mode 100644
index 0000000000000..475e14e8df7d3
--- /dev/null
+++ b/packages/design-system/src/components/N8nDatatable/Datatable.vue
@@ -0,0 +1,200 @@
+
+
+
+
+
+
+
+
+ {{ column.label }}
+ |
+
+
+
+
+
+
+ {{ getTdValue(row, column) }}
+ |
+
+
+
+
+
+
+
+
+
+ {{ t('datatable.pageSize') }}
+
+
+
+
+
+
+
+
+
diff --git a/packages/design-system/src/components/N8nDatatable/__tests__/Datatable.spec.ts b/packages/design-system/src/components/N8nDatatable/__tests__/Datatable.spec.ts
new file mode 100644
index 0000000000000..3322246e37ca3
--- /dev/null
+++ b/packages/design-system/src/components/N8nDatatable/__tests__/Datatable.spec.ts
@@ -0,0 +1,23 @@
+import { render } from '@testing-library/vue';
+import N8nDatatable from '../Datatable.vue';
+import { rows, columns } from './data';
+
+const stubs = ['n8n-select', 'n8n-option', 'n8n-button', 'n8n-pagination'];
+
+describe('components', () => {
+ describe('N8nDatatable', () => {
+ it('should render correctly', () => {
+ const wrapper = render(N8nDatatable, {
+ propsData: {
+ columns,
+ rows,
+ },
+ stubs,
+ });
+
+ expect(wrapper.container.querySelectorAll('tbody tr').length).toEqual(10);
+ expect(wrapper.container.querySelectorAll('thead tr').length).toEqual(1);
+ expect(wrapper.html()).toMatchSnapshot();
+ });
+ });
+});
diff --git a/packages/design-system/src/components/N8nDatatable/__tests__/__snapshots__/Datatable.spec.ts.snap b/packages/design-system/src/components/N8nDatatable/__tests__/__snapshots__/Datatable.spec.ts.snap
new file mode 100644
index 0000000000000..3b419dbc691c8
--- /dev/null
+++ b/packages/design-system/src/components/N8nDatatable/__tests__/__snapshots__/Datatable.spec.ts.snap
@@ -0,0 +1,100 @@
+// Vitest Snapshot v1
+
+exports[`components > N8nDatatable > should render correctly 1`] = `
+"
+
+
+
+
+ 1 |
+ Richard Hendricks |
+ 29 |
+ |
+
+
+ 2 |
+ Bertram Gilfoyle |
+ 44 |
+ |
+
+
+ 3 |
+ Dinesh Chugtai |
+ 31 |
+ |
+
+
+ 4 |
+ Jared Dunn |
+ 38 |
+ |
+
+
+ 5 |
+ Richard Hendricks |
+ 29 |
+ |
+
+
+ 6 |
+ Bertram Gilfoyle |
+ 44 |
+ |
+
+
+ 7 |
+ Dinesh Chugtai |
+ 31 |
+ |
+
+
+ 8 |
+ Jared Dunn |
+ 38 |
+ |
+
+
+ 9 |
+ Richard Hendricks |
+ 29 |
+ |
+
+
+ 10 |
+ Bertram Gilfoyle |
+ 44 |
+ |
+
+
+
+
+
"
+`;
diff --git a/packages/design-system/src/components/N8nDatatable/__tests__/data.ts b/packages/design-system/src/components/N8nDatatable/__tests__/data.ts
new file mode 100644
index 0000000000000..b1a1315dd0c5c
--- /dev/null
+++ b/packages/design-system/src/components/N8nDatatable/__tests__/data.ts
@@ -0,0 +1,45 @@
+import { defineComponent, h, PropType } from 'vue';
+import { DatatableRow } from '../mixins';
+import N8nButton from '../../N8nButton';
+
+// eslint-disable-next-line @typescript-eslint/naming-convention
+export const ActionComponent = defineComponent({
+ props: {
+ row: {
+ type: Object as PropType,
+ default: () => ({}),
+ },
+ },
+ setup(props) {
+ return () => h(N8nButton, {}, [`Button ${props.row.id}`]);
+ },
+});
+
+export const columns = [
+ { id: 'id', path: 'id', label: 'ID' },
+ { id: 'name', path: 'name', label: 'Name' },
+ { id: 'age', path: 'meta.age', label: 'Age' },
+ {
+ id: 'action',
+ label: 'Action',
+ render: ActionComponent,
+ },
+];
+
+export const rows = [
+ { id: 1, name: 'Richard Hendricks', meta: { age: 29 } },
+ { id: 2, name: 'Bertram Gilfoyle', meta: { age: 44 } },
+ { id: 3, name: 'Dinesh Chugtai', meta: { age: 31 } },
+ { id: 4, name: 'Jared Dunn ', meta: { age: 38 } },
+ { id: 5, name: 'Richard Hendricks', meta: { age: 29 } },
+ { id: 6, name: 'Bertram Gilfoyle', meta: { age: 44 } },
+ { id: 7, name: 'Dinesh Chugtai', meta: { age: 31 } },
+ { id: 8, name: 'Jared Dunn ', meta: { age: 38 } },
+ { id: 9, name: 'Richard Hendricks', meta: { age: 29 } },
+ { id: 10, name: 'Bertram Gilfoyle', meta: { age: 44 } },
+ { id: 11, name: 'Dinesh Chugtai', meta: { age: 31 } },
+ { id: 12, name: 'Jared Dunn ', meta: { age: 38 } },
+ { id: 13, name: 'Richard Hendricks', meta: { age: 29 } },
+ { id: 14, name: 'Bertram Gilfoyle', meta: { age: 44 } },
+ { id: 15, name: 'Dinesh Chugtai', meta: { age: 31 } },
+];
diff --git a/packages/design-system/src/components/N8nDatatable/index.ts b/packages/design-system/src/components/N8nDatatable/index.ts
new file mode 100644
index 0000000000000..78a4e7dff43ad
--- /dev/null
+++ b/packages/design-system/src/components/N8nDatatable/index.ts
@@ -0,0 +1,3 @@
+import N8nDatatable from './Datatable.vue';
+
+export default N8nDatatable;
diff --git a/packages/design-system/src/components/N8nDatatable/mixins.ts b/packages/design-system/src/components/N8nDatatable/mixins.ts
new file mode 100644
index 0000000000000..907b86c98c9c8
--- /dev/null
+++ b/packages/design-system/src/components/N8nDatatable/mixins.ts
@@ -0,0 +1,16 @@
+import { VNode } from 'vue';
+
+export type DatatableRowDataType = string | number | boolean | null | undefined;
+
+export interface DatatableRow {
+ id: string | number;
+
+ [key: string]: DatatableRowDataType;
+}
+
+export interface DatatableColumn {
+ id: string | number;
+ path: string;
+ label: string;
+ render: (row: DatatableRow) => (() => VNode | VNode[]) | DatatableRowDataType;
+}
diff --git a/packages/design-system/src/components/N8nPagination/Pagination.stories.ts b/packages/design-system/src/components/N8nPagination/Pagination.stories.ts
new file mode 100644
index 0000000000000..3fa4d573b7d7f
--- /dev/null
+++ b/packages/design-system/src/components/N8nPagination/Pagination.stories.ts
@@ -0,0 +1,23 @@
+import type { StoryFn } from '@storybook/vue';
+import N8nPagination from './Pagination.vue';
+
+export default {
+ title: 'Atoms/Pagination',
+ component: N8nPagination,
+};
+
+const Template: StoryFn = (args, { argTypes }) => ({
+ props: Object.keys(argTypes),
+ components: {
+ N8nPagination,
+ },
+ template: '',
+});
+
+export const Pagination: StoryFn = Template.bind({});
+Pagination.args = {
+ currentPage: 1,
+ pagerCount: 5,
+ pageSize: 10,
+ total: 100,
+};
diff --git a/packages/design-system/src/components/N8nPagination/Pagination.vue b/packages/design-system/src/components/N8nPagination/Pagination.vue
new file mode 100644
index 0000000000000..e5bc468151ad7
--- /dev/null
+++ b/packages/design-system/src/components/N8nPagination/Pagination.vue
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
diff --git a/packages/design-system/src/components/N8nPagination/index.ts b/packages/design-system/src/components/N8nPagination/index.ts
new file mode 100644
index 0000000000000..0241347c6135a
--- /dev/null
+++ b/packages/design-system/src/components/N8nPagination/index.ts
@@ -0,0 +1,3 @@
+import N8nPagination from './Pagination.vue';
+
+export default N8nPagination;
diff --git a/packages/design-system/src/composables/index.ts b/packages/design-system/src/composables/index.ts
new file mode 100644
index 0000000000000..19ab1d994e6cb
--- /dev/null
+++ b/packages/design-system/src/composables/index.ts
@@ -0,0 +1 @@
+export * from './useI18n';
diff --git a/packages/design-system/src/composables/useI18n.ts b/packages/design-system/src/composables/useI18n.ts
new file mode 100644
index 0000000000000..9c18f3e6acac9
--- /dev/null
+++ b/packages/design-system/src/composables/useI18n.ts
@@ -0,0 +1,7 @@
+import { t } from '../locale';
+
+export function useI18n() {
+ return {
+ t: (path: string, options: string[] = []) => t(path, options),
+ };
+}
diff --git a/packages/design-system/src/locale/lang/en.js b/packages/design-system/src/locale/lang/en.js
index faaa58e830047..3038b3b248716 100644
--- a/packages/design-system/src/locale/lang/en.js
+++ b/packages/design-system/src/locale/lang/en.js
@@ -18,4 +18,5 @@ export default {
'8+ characters, at least 1 number and 1 capital letter',
'sticky.markdownHint': `You can style with Markdown`,
'tags.showMore': (count) => `+${count} more`,
+ 'datatable.pageSize': 'Page size',
};
diff --git a/packages/design-system/src/utils/__tests__/valueByPath.spec.ts b/packages/design-system/src/utils/__tests__/valueByPath.spec.ts
new file mode 100644
index 0000000000000..fc5bfea6ada41
--- /dev/null
+++ b/packages/design-system/src/utils/__tests__/valueByPath.spec.ts
@@ -0,0 +1,43 @@
+import { getValueByPath } from '@/utils';
+
+describe('getValueByPath()', () => {
+ const object = {
+ id: '1',
+ name: 'Richard Hendricks',
+ address: {
+ city: 'Palo Alto',
+ state: 'California',
+ country: 'United States',
+ },
+ };
+
+ it('should return direct field from object', () => {
+ const path = 'name';
+
+ expect(getValueByPath(object, path)).toEqual(object.name);
+ });
+
+ it('should return nested field from object', () => {
+ const path = 'address.country';
+
+ expect(getValueByPath(object, path)).toEqual(object.address.country);
+ });
+
+ it('should return undefined if direct field does not exist', () => {
+ const path = 'other';
+
+ expect(getValueByPath(object, path)).toEqual(undefined);
+ });
+
+ it('should return undefined if nested field does not exist', () => {
+ const path = 'address.other';
+
+ expect(getValueByPath(object, path)).toEqual(undefined);
+ });
+
+ it('should return undefined if path does not exist', () => {
+ const path = 'other.other';
+
+ expect(getValueByPath(object, path)).toEqual(undefined);
+ });
+});
diff --git a/packages/design-system/src/utils/index.ts b/packages/design-system/src/utils/index.ts
index a601378f5dfc5..b2e1c681299cd 100644
--- a/packages/design-system/src/utils/index.ts
+++ b/packages/design-system/src/utils/index.ts
@@ -1,2 +1,3 @@
export * from './markdown';
export * from './uid';
+export * from './valueByPath';
diff --git a/packages/design-system/src/utils/valueByPath.ts b/packages/design-system/src/utils/valueByPath.ts
new file mode 100644
index 0000000000000..a596df7098c51
--- /dev/null
+++ b/packages/design-system/src/utils/valueByPath.ts
@@ -0,0 +1,14 @@
+/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access */
+
+/**
+ * Get a deeply nested value based on a given path string
+ *
+ * @param object
+ * @param path
+ * @returns {T}
+ */
+export function getValueByPath(object: any, path: string): T {
+ return path.split('.').reduce((acc, part) => {
+ return acc && acc[part];
+ }, object);
+}