diff --git a/app/assets/javascripts/ci/pipeline_details/manual_variables/empty_state.vue b/app/assets/javascripts/ci/pipeline_details/manual_variables/empty_state.vue
new file mode 100644
index 0000000000000000000000000000000000000000..1ea007d5c2a33fef3689ca3884a5e0478bb44042
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_details/manual_variables/empty_state.vue
@@ -0,0 +1,38 @@
+<script>
+import { GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui';
+import EMPTY_VARIABLES_SVG from '@gitlab/svgs/dist/illustrations/empty-state/empty-variables-md.svg';
+import { s__ } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
+
+export default {
+  components: {
+    GlEmptyState,
+    GlSprintf,
+    GlLink,
+  },
+  EMPTY_VARIABLES_SVG,
+  i18n: {
+    title: s__('ManualVariables|There are no manually-specified variables for this pipeline'),
+    description: s__(
+      'ManualVariables|When you %{helpPageUrlStart}run a pipeline manually%{helpPageUrlEnd}, you can specify additional CI/CD variables to use in that pipeline run.',
+    ),
+  },
+  runPipelineManuallyDocUrl: helpPagePath('ci/pipelines/index', {
+    anchor: 'run-a-pipeline-manually',
+  }),
+};
+</script>
+
+<template>
+  <gl-empty-state :svg-path="$options.EMPTY_VARIABLES_SVG" :title="$options.i18n.title">
+    <template #description>
+      <gl-sprintf :message="$options.i18n.description">
+        <template #helpPageUrl="{ content }">
+          <gl-link :href="$options.runPipelineManuallyDocUrl" target="_blank">{{
+            content
+          }}</gl-link>
+        </template>
+      </gl-sprintf>
+    </template>
+  </gl-empty-state>
+</template>
diff --git a/app/assets/javascripts/ci/pipeline_details/manual_variables/graphql/queries/get_manual_variables.query.graphql b/app/assets/javascripts/ci/pipeline_details/manual_variables/graphql/queries/get_manual_variables.query.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..200a75c634fd6a812a1febbcd701ae18907dee55
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_details/manual_variables/graphql/queries/get_manual_variables.query.graphql
@@ -0,0 +1,17 @@
+query getManualVariables($projectPath: ID!, $iid: ID!) {
+  project(fullPath: $projectPath) {
+    __typename
+    id
+    pipeline(iid: $iid) {
+      id
+      manualVariables {
+        __typename
+        nodes {
+          id
+          key
+          value
+        }
+      }
+    }
+  }
+}
diff --git a/app/assets/javascripts/ci/pipeline_details/manual_variables/manual_variables.vue b/app/assets/javascripts/ci/pipeline_details/manual_variables/manual_variables.vue
new file mode 100644
index 0000000000000000000000000000000000000000..c2288b62eb902ed3361ee1f61f5c4aed1a7269eb
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_details/manual_variables/manual_variables.vue
@@ -0,0 +1,51 @@
+<script>
+import { GlLoadingIcon } from '@gitlab/ui';
+import EmptyState from './empty_state.vue';
+import VariableTable from './variable_table.vue';
+import getManualVariablesQuery from './graphql/queries/get_manual_variables.query.graphql';
+
+export default {
+  name: 'ManualVariablesApp',
+  components: {
+    EmptyState,
+    GlLoadingIcon,
+    VariableTable,
+  },
+  inject: ['manualVariablesCount', 'projectPath', 'pipelineIid'],
+  apollo: {
+    variables: {
+      query: getManualVariablesQuery,
+      skip() {
+        return !this.hasManualVariables;
+      },
+      variables() {
+        return {
+          projectPath: this.projectPath,
+          iid: this.pipelineIid,
+        };
+      },
+      update({ project }) {
+        return project?.pipeline?.manualVariables?.nodes || [];
+      },
+    },
+  },
+  computed: {
+    loading() {
+      return this.$apollo.queries.variables.loading;
+    },
+    hasManualVariables() {
+      return Boolean(this.manualVariablesCount > 0);
+    },
+  },
+};
+</script>
+
+<template>
+  <div>
+    <div v-if="hasManualVariables" class="manual-variables-table">
+      <gl-loading-icon v-if="loading" />
+      <variable-table v-else :variables="variables" />
+    </div>
+    <empty-state v-else />
+  </div>
+</template>
diff --git a/app/assets/javascripts/ci/pipeline_details/manual_variables/variable_table.vue b/app/assets/javascripts/ci/pipeline_details/manual_variables/variable_table.vue
new file mode 100644
index 0000000000000000000000000000000000000000..3eceb12554e99b3a1494518d8fee68344661fd5b
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_details/manual_variables/variable_table.vue
@@ -0,0 +1,90 @@
+<script>
+import { GlButton, GlPagination, GlTableLite } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+// The number of items per page is based on the design mockup.
+// Please refer to https://gitlab.com/gitlab-org/gitlab/-/issues/323097/designs/TabVariables.png
+const VARIABLES_PER_PAGE = 15;
+
+export default {
+  components: {
+    GlButton,
+    GlPagination,
+    GlTableLite,
+  },
+  inject: ['manualVariablesCount', 'canReadVariables'],
+  props: {
+    variables: {
+      type: Array,
+      required: true,
+    },
+  },
+  data() {
+    return {
+      revealed: false,
+      currentPage: 1,
+      hasPermission: true,
+    };
+  },
+  computed: {
+    buttonText() {
+      return this.revealed ? __('Hide values') : __('Reveal values');
+    },
+    showPager() {
+      return this.manualVariablesCount > VARIABLES_PER_PAGE;
+    },
+    items() {
+      const start = (this.currentPage - 1) * VARIABLES_PER_PAGE;
+      const end = start + VARIABLES_PER_PAGE;
+      return this.variables.slice(start, end);
+    },
+  },
+  methods: {
+    toggleRevealed() {
+      this.revealed = !this.revealed;
+    },
+  },
+  TABLE_FIELDS: [
+    {
+      key: 'key',
+      label: __('Key'),
+    },
+    {
+      key: 'value',
+      label: __('Value'),
+    },
+  ],
+  VARIABLES_PER_PAGE,
+};
+</script>
+
+<template>
+  <!-- This negative margin top is a hack for the purpose to eliminate default padding of tab container -->
+  <!-- For context refer to: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/159206#note_1999122459 -->
+  <div class="-gl-mt-3">
+    <div v-if="canReadVariables" class="gl-p-3 gl-bg-gray-10">
+      <gl-button :aria-label="buttonText" @click="toggleRevealed">{{ buttonText }}</gl-button>
+    </div>
+    <gl-table-lite :fields="$options.TABLE_FIELDS" :items="items">
+      <template #cell(key)="{ value }">
+        <span class="gl-text-secondary">
+          {{ value }}
+        </span>
+      </template>
+      <template #cell(value)="{ value }">
+        <div class="gl-text-secondary" data-testid="manual-variable-value">
+          <span v-if="revealed">{{ value }}</span>
+          <span v-else>****</span>
+        </div>
+      </template>
+    </gl-table-lite>
+    <gl-pagination
+      v-if="showPager"
+      v-model="currentPage"
+      class="gl-mt-6"
+      :per-page="$options.VARIABLES_PER_PAGE"
+      :total-items="manualVariablesCount"
+      align="center"
+    />
+  </div>
+</template>
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index e70fe78be83d3243d3a1da9a666a860b67758b20..a50f67a75a6dc19bd8351aed0dcabcede3f5662c 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -31675,6 +31675,12 @@ msgstr ""
 msgid "ManualOrdering|Couldn't save the order of the issues"
 msgstr ""
 
+msgid "ManualVariables|There are no manually-specified variables for this pipeline"
+msgstr ""
+
+msgid "ManualVariables|When you %{helpPageUrlStart}run a pipeline manually%{helpPageUrlEnd}, you can specify additional CI/CD variables to use in that pipeline run."
+msgstr ""
+
 msgid "Manually link this issue by adding it to the linked issue section of the %{linkStart}originating vulnerability%{linkEnd}."
 msgstr ""
 
diff --git a/spec/frontend/ci/pipeline_details/manual_variables/empty_state_spec.js b/spec/frontend/ci/pipeline_details/manual_variables/empty_state_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..b82f73f36f31f238c5b2fd69663e9e1b32db200a
--- /dev/null
+++ b/spec/frontend/ci/pipeline_details/manual_variables/empty_state_spec.js
@@ -0,0 +1,23 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlEmptyState } from '@gitlab/ui';
+import EmptyState from '~/ci/pipeline_details/manual_variables/empty_state.vue';
+
+describe('ManualVariablesEmptyState', () => {
+  describe('when component is created', () => {
+    let wrapper;
+
+    const createComponent = () => {
+      wrapper = shallowMount(EmptyState);
+    };
+    const findEmptyState = () => wrapper.findComponent(GlEmptyState);
+
+    it('should render empty state with message', () => {
+      createComponent();
+
+      expect(findEmptyState().props()).toMatchObject({
+        svgPath: EmptyState.EMPTY_VARIABLES_SVG,
+        title: 'There are no manually-specified variables for this pipeline',
+      });
+    });
+  });
+});
diff --git a/spec/frontend/ci/pipeline_details/manual_variables/manual_variables_spec.js b/spec/frontend/ci/pipeline_details/manual_variables/manual_variables_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..f4d9f13c1d1f7bd7cacbf3a0645bd8df6d874555
--- /dev/null
+++ b/spec/frontend/ci/pipeline_details/manual_variables/manual_variables_spec.js
@@ -0,0 +1,67 @@
+import Vue from 'vue';
+import { shallowMount } from '@vue/test-utils';
+import VueApollo from 'vue-apollo';
+import { GlLoadingIcon } from '@gitlab/ui';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import ManualVariablesApp from '~/ci/pipeline_details/manual_variables/manual_variables.vue';
+import EmptyState from '~/ci/pipeline_details/manual_variables/empty_state.vue';
+import VariableTable from '~/ci/pipeline_details/manual_variables/variable_table.vue';
+import GetManualVariablesQuery from '~/ci/pipeline_details/manual_variables/graphql/queries/get_manual_variables.query.graphql';
+import { generateVariablePairs, mockManualVariableConnection } from './mock_data';
+
+Vue.use(VueApollo);
+
+describe('ManualVariableApp', () => {
+  let wrapper;
+  const mockResolver = jest.fn();
+  const createMockApolloProvider = (resolver) => {
+    const requestHandlers = [[GetManualVariablesQuery, resolver]];
+
+    return createMockApollo(requestHandlers);
+  };
+
+  const createComponent = (variables = []) => {
+    mockResolver.mockResolvedValue(mockManualVariableConnection(variables));
+    wrapper = shallowMount(ManualVariablesApp, {
+      provide: {
+        manualVariablesCount: variables.length,
+        projectPath: 'root/ci-project',
+        pipelineIid: '1',
+      },
+      apolloProvider: createMockApolloProvider(mockResolver),
+    });
+  };
+
+  const findEmptyState = () => wrapper.findComponent(EmptyState);
+  const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+  const findVariableTable = () => wrapper.findComponent(VariableTable);
+
+  afterEach(() => {
+    mockResolver.mockClear();
+  });
+
+  describe('when component is created', () => {
+    it('renders empty state when no variables were found', () => {
+      createComponent();
+
+      expect(findEmptyState().exists()).toBe(true);
+    });
+
+    it('renders loading state when variables were found', () => {
+      createComponent(generateVariablePairs(1));
+
+      expect(findEmptyState().exists()).toBe(false);
+      expect(findLoadingIcon().exists()).toBe(true);
+      expect(findVariableTable().exists()).toBe(false);
+    });
+
+    it('renders variable table when variables were retrieved', async () => {
+      createComponent(generateVariablePairs(1));
+      await waitForPromises();
+
+      expect(findLoadingIcon().exists()).toBe(false);
+      expect(findVariableTable().exists()).toBe(true);
+    });
+  });
+});
diff --git a/spec/frontend/ci/pipeline_details/manual_variables/mock_data.js b/spec/frontend/ci/pipeline_details/manual_variables/mock_data.js
new file mode 100644
index 0000000000000000000000000000000000000000..37d42598255cced11a17c3cec91c0219b19c9a1f
--- /dev/null
+++ b/spec/frontend/ci/pipeline_details/manual_variables/mock_data.js
@@ -0,0 +1,26 @@
+export const generateVariablePairs = (count) => {
+  return Array.from({ length: count }).map((_, index) => ({
+    key: `key_${index}`,
+    value: `value_${index}`,
+  }));
+};
+
+export const mockManualVariableConnection = (variables = []) => ({
+  data: {
+    project: {
+      __typename: 'Project',
+      id: 'root/ci-project/1',
+      pipeline: {
+        id: '1',
+        manualVariables: {
+          __typename: 'CiManualVariableConnection',
+          nodes: variables.map((variable) => ({
+            ...variable,
+            id: variable.key,
+          })),
+        },
+        __typename: 'Pipeline',
+      },
+    },
+  },
+});
diff --git a/spec/frontend/ci/pipeline_details/manual_variables/variable_table_spec.js b/spec/frontend/ci/pipeline_details/manual_variables/variable_table_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..276be0f28f05de944b1778c536fda5c173bf717f
--- /dev/null
+++ b/spec/frontend/ci/pipeline_details/manual_variables/variable_table_spec.js
@@ -0,0 +1,113 @@
+import { nextTick } from 'vue';
+import { GlPagination, GlButton } from '@gitlab/ui';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+
+import VariableTable from '~/ci/pipeline_details/manual_variables/variable_table.vue';
+import { generateVariablePairs } from './mock_data';
+
+const defaultCanReadVariables = true;
+const defaultManualVariablesCount = 0;
+
+describe('ManualVariableTable', () => {
+  let wrapper;
+
+  const createComponent = (provides = {}, variables = []) => {
+    wrapper = mountExtended(VariableTable, {
+      provide: {
+        manualVariablesCount: defaultManualVariablesCount,
+        canReadVariables: defaultCanReadVariables,
+        ...provides,
+      },
+      propsData: {
+        variables,
+      },
+    });
+  };
+
+  const findButton = () => wrapper.findComponent(GlButton);
+  const findPaginator = () => wrapper.findComponent(GlPagination);
+  const findValues = () => wrapper.findAllByTestId('manual-variable-value');
+
+  describe('when component is created', () => {
+    describe('reveal/hide button', () => {
+      it('should render the button when has permissions', () => {
+        createComponent();
+
+        expect(findButton().exists()).toBe(true);
+      });
+
+      it('should not render the button when does not have permissions', () => {
+        createComponent({
+          canReadVariables: false,
+        });
+
+        expect(findButton().exists()).toBe(false);
+      });
+    });
+
+    describe('paginator', () => {
+      it('should not render paginator without any data', () => {
+        createComponent();
+
+        expect(findPaginator().exists()).toBe(false);
+      });
+
+      it('should not render paginator with data less or equal to 15', () => {
+        const mockData = generateVariablePairs(15);
+        createComponent(
+          {
+            manualVariablesCount: mockData.length,
+          },
+          mockData,
+        );
+
+        expect(findPaginator().exists()).toBe(false);
+      });
+
+      it('should render paginator when data is greater than 15', () => {
+        const mockData = generateVariablePairs(16);
+
+        createComponent(
+          {
+            manualVariablesCount: mockData.length,
+          },
+          mockData,
+        );
+
+        expect(findPaginator().exists()).toBe(true);
+      });
+    });
+  });
+
+  describe('when click on the reveal/hide button', () => {
+    it('should toggle button text', async () => {
+      createComponent();
+      const button = findButton();
+
+      expect(button.text()).toBe('Reveal values');
+      button.vm.$emit('click');
+      await nextTick();
+      expect(button.text()).toBe('Hide values');
+    });
+
+    it('should reveal the values when click on the button', async () => {
+      const mockData = generateVariablePairs(15);
+
+      createComponent(
+        {
+          manualVariablesCount: mockData.length,
+        },
+        mockData,
+      );
+
+      const values = findValues();
+      expect(values).toHaveLength(mockData.length);
+
+      expect(values.wrappers.every((w) => w.text() === '****')).toBe(true);
+
+      await findButton().trigger('click');
+
+      expect(values.wrappers.map((w) => w.text())).toStrictEqual(mockData.map((d) => d.value));
+    });
+  });
+});