diff --git a/app/assets/javascripts/pipeline_wizard/components/widgets/text.vue b/app/assets/javascripts/pipeline_wizard/components/widgets/text.vue
new file mode 100644
index 0000000000000000000000000000000000000000..26235b20ce97d54a3c2a2238717e43dd19ba4fdb
--- /dev/null
+++ b/app/assets/javascripts/pipeline_wizard/components/widgets/text.vue
@@ -0,0 +1,126 @@
+<script>
+import { GlFormGroup, GlFormInput } from '@gitlab/ui';
+import { uniqueId } from 'lodash';
+import { s__ } from '~/locale';
+
+const VALIDATION_STATE = {
+  NO_VALIDATION: null,
+  INVALID: false,
+  VALID: true,
+};
+
+export default {
+  name: 'TextWidget',
+  components: {
+    GlFormGroup,
+    GlFormInput,
+  },
+  props: {
+    label: {
+      type: String,
+      required: true,
+    },
+    description: {
+      type: String,
+      required: false,
+      default: null,
+    },
+    placeholder: {
+      type: String,
+      required: false,
+      default: null,
+    },
+    invalidFeedback: {
+      type: String,
+      required: false,
+      default: s__('PipelineWizardInputValidation|This value is not valid'),
+    },
+    id: {
+      type: String,
+      required: false,
+      default: () => uniqueId('textWidget-'),
+    },
+    pattern: {
+      type: String,
+      required: false,
+      default: null,
+    },
+    validate: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
+    required: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
+    default: {
+      type: String,
+      required: false,
+      default: null,
+    },
+  },
+  data() {
+    return {
+      touched: false,
+      value: this.default,
+    };
+  },
+  computed: {
+    validationState() {
+      if (!this.showValidationState) return VALIDATION_STATE.NO_VALIDATION;
+      if (this.isRequiredButEmpty) return VALIDATION_STATE.INVALID;
+      return this.needsValidationAndPasses ? VALIDATION_STATE.VALID : VALIDATION_STATE.INVALID;
+    },
+    showValidationState() {
+      return this.touched || this.validate;
+    },
+    isRequiredButEmpty() {
+      return this.required && !this.value;
+    },
+    needsValidationAndPasses() {
+      return !this.pattern || new RegExp(this.pattern).test(this.value);
+    },
+    invalidFeedbackMessage() {
+      return this.isRequiredButEmpty
+        ? s__('PipelineWizardInputValidation|This field is required')
+        : this.invalidFeedback;
+    },
+  },
+  watch: {
+    validationState(v) {
+      this.$emit('update:valid', v);
+    },
+    value(v) {
+      this.$emit('input', v.trim());
+    },
+  },
+  created() {
+    if (this.default) {
+      this.$emit('input', this.value);
+    }
+  },
+};
+</script>
+
+<template>
+  <div data-testid="text-widget">
+    <gl-form-group
+      :description="description"
+      :invalid-feedback="invalidFeedbackMessage"
+      :label="label"
+      :label-for="id"
+      :state="validationState"
+    >
+      <gl-form-input
+        :id="id"
+        v-model="value"
+        :placeholder="placeholder"
+        :state="validationState"
+        type="text"
+        @blur="touched = true"
+      />
+    </gl-form-group>
+  </div>
+</template>
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 371e0649c5d4aab8f24084d4d54e8c0407f5a0b6..c3642ec7270b7f97ec61cd5f43074363ba00f060 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -26577,6 +26577,12 @@ msgstr ""
 msgid "PipelineStatusTooltip|Pipeline: %{ci_status}"
 msgstr ""
 
+msgid "PipelineWizardInputValidation|This field is required"
+msgstr ""
+
+msgid "PipelineWizardInputValidation|This value is not valid"
+msgstr ""
+
 msgid "Pipelines"
 msgstr ""
 
diff --git a/spec/frontend/pipeline_wizard/components/widgets/text_spec.js b/spec/frontend/pipeline_wizard/components/widgets/text_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..a11c0214d156f32a87fc4b6ca280d02eca05a59b
--- /dev/null
+++ b/spec/frontend/pipeline_wizard/components/widgets/text_spec.js
@@ -0,0 +1,152 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlFormGroup, GlFormInput } from '@gitlab/ui';
+import TextWidget from '~/pipeline_wizard/components/widgets/text.vue';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+
+describe('Pipeline Wizard - Text Widget', () => {
+  const defaultProps = {
+    label: 'This label',
+    description: 'some description',
+    placeholder: 'some placeholder',
+    pattern: '^[a-z]+$',
+    invalidFeedback: 'some feedback',
+  };
+
+  let wrapper;
+
+  const findGlFormGroup = () => wrapper.findComponent(GlFormGroup);
+  const findGlFormGroupInvalidFeedback = () => findGlFormGroup().find('.invalid-feedback');
+  const findGlFormInput = () => wrapper.findComponent(GlFormInput);
+
+  const createComponent = (props = {}, mountFn = mountExtended) => {
+    wrapper = mountFn(TextWidget, {
+      propsData: {
+        ...defaultProps,
+        ...props,
+      },
+    });
+  };
+
+  afterEach(() => {
+    if (wrapper) {
+      wrapper.destroy();
+    }
+  });
+
+  it('creates an input element with the correct label', () => {
+    createComponent();
+
+    expect(wrapper.findByLabelText(defaultProps.label).exists()).toBe(true);
+  });
+
+  it('passes the description', () => {
+    createComponent({}, shallowMount);
+
+    expect(findGlFormGroup().attributes('description')).toBe(defaultProps.description);
+  });
+
+  it('sets the "text" type on the input component', () => {
+    createComponent();
+
+    expect(findGlFormInput().attributes('type')).toBe('text');
+  });
+
+  it('passes the placeholder', () => {
+    createComponent();
+
+    expect(findGlFormInput().attributes('placeholder')).toBe(defaultProps.placeholder);
+  });
+
+  it('emits an update event on input', async () => {
+    createComponent();
+
+    const localValue = 'somevalue';
+    await findGlFormInput().setValue(localValue);
+
+    expect(wrapper.emitted('input')).toEqual([[localValue]]);
+  });
+
+  it('passes invalid feedback message', () => {
+    createComponent();
+
+    expect(findGlFormGroupInvalidFeedback().text()).toBe(defaultProps.invalidFeedback);
+  });
+
+  it('provides invalid feedback', async () => {
+    createComponent({ validate: true });
+
+    await findGlFormInput().setValue('invalid%99');
+
+    expect(findGlFormGroup().classes()).toContain('is-invalid');
+    expect(findGlFormInput().classes()).toContain('is-invalid');
+  });
+
+  it('provides valid feedback', async () => {
+    createComponent({ validate: true });
+
+    await findGlFormInput().setValue('valid');
+
+    expect(findGlFormGroup().classes()).toContain('is-valid');
+    expect(findGlFormInput().classes()).toContain('is-valid');
+  });
+
+  it('does not show validation state when untouched', () => {
+    createComponent({ value: 'invalid99' });
+
+    expect(findGlFormGroup().classes()).not.toContain('is-valid');
+    expect(findGlFormGroup().classes()).not.toContain('is-invalid');
+  });
+
+  it('shows invalid state on blur', async () => {
+    createComponent();
+
+    await findGlFormInput().setValue('invalid%99');
+
+    expect(findGlFormGroup().classes()).not.toContain('is-invalid');
+
+    await findGlFormInput().trigger('blur');
+
+    expect(findGlFormInput().classes()).toContain('is-invalid');
+    expect(findGlFormGroup().classes()).toContain('is-invalid');
+  });
+
+  it('shows invalid state when toggling `validate` prop', async () => {
+    createComponent({
+      required: true,
+      validate: false,
+    });
+
+    expect(findGlFormGroup().classes()).not.toContain('is-invalid');
+
+    await wrapper.setProps({ validate: true });
+
+    expect(findGlFormGroup().classes()).toContain('is-invalid');
+  });
+
+  it('does not update validation if not required', async () => {
+    createComponent({
+      pattern: null,
+      validate: true,
+    });
+
+    expect(findGlFormGroup().classes()).not.toContain('is-invalid');
+  });
+
+  it('sets default value', () => {
+    const defaultValue = 'foo';
+    createComponent({
+      default: defaultValue,
+    });
+
+    expect(wrapper.findByLabelText(defaultProps.label).element.value).toBe(defaultValue);
+  });
+
+  it('emits default value on setup', () => {
+    const defaultValue = 'foo';
+    createComponent({
+      default: defaultValue,
+    });
+
+    expect(wrapper.emitted('input')).toEqual([[defaultValue]]);
+  });
+});