diff --git a/app/assets/javascripts/work_items/components/item_title.vue b/app/assets/javascripts/work_items/components/item_title.vue
index 19fbad4eaa3313b55b7e2dacf5b2baa90c6f2c3c..1cdc9c28f05255fde95e538062b917a77fb4af8c 100644
--- a/app/assets/javascripts/work_items/components/item_title.vue
+++ b/app/assets/javascripts/work_items/components/item_title.vue
@@ -1,5 +1,4 @@
 <script>
-import { escape } from 'lodash';
 import { __ } from '~/locale';
 
 export default {
@@ -21,15 +20,11 @@ export default {
     },
   },
   methods: {
-    getSanitizedTitle(inputEl) {
-      const { innerText } = inputEl;
-      return escape(innerText);
-    },
     handleBlur({ target }) {
-      this.$emit('title-changed', this.getSanitizedTitle(target));
+      this.$emit('title-changed', target.innerText);
     },
     handleInput({ target }) {
-      this.$emit('title-input', this.getSanitizedTitle(target));
+      this.$emit('title-input', target.innerText);
     },
     handleSubmit() {
       this.$refs.titleEl.blur();
diff --git a/app/assets/javascripts/work_items/components/work_item_detail.vue b/app/assets/javascripts/work_items/components/work_item_detail.vue
index 606d65a08f8431fb2a36cb06dead2d87d0d39e6e..f52b3bf549e8b0fc105650f9abebf265a39ab28f 100644
--- a/app/assets/javascripts/work_items/components/work_item_detail.vue
+++ b/app/assets/javascripts/work_items/components/work_item_detail.vue
@@ -142,7 +142,14 @@ export default {
           :work-item-id="workItem.id"
           :assignees="workItemAssignees.nodes"
         />
-        <work-item-weight v-if="workItemWeight" :weight="workItemWeight.weight" />
+        <work-item-weight
+          v-if="workItemWeight"
+          class="gl-mb-5"
+          :can-update="canUpdate"
+          :weight="workItemWeight.weight"
+          :work-item-id="workItem.id"
+          :work-item-type="workItemType"
+        />
       </template>
       <work-item-description
         v-if="hasDescriptionWidget"
diff --git a/app/assets/javascripts/work_items/components/work_item_weight.vue b/app/assets/javascripts/work_items/components/work_item_weight.vue
index 72e678151e91ba7068227cf94318159f9ea9ff2f..30e2c1e56b865501835702eff02592100fdaac20 100644
--- a/app/assets/javascripts/work_items/components/work_item_weight.vue
+++ b/app/assets/javascripts/work_items/components/work_item_weight.vue
@@ -1,28 +1,142 @@
 <script>
+import { GlForm, GlFormGroup, GlFormInput } from '@gitlab/ui';
 import { __ } from '~/locale';
+import Tracking from '~/tracking';
+import { TRACKING_CATEGORY_SHOW } from '../constants';
+import localUpdateWorkItemMutation from '../graphql/local_update_work_item.mutation.graphql';
+
+/* eslint-disable @gitlab/require-i18n-strings */
+const allowedKeys = [
+  'Alt',
+  'ArrowDown',
+  'ArrowLeft',
+  'ArrowRight',
+  'ArrowUp',
+  'Backspace',
+  'Control',
+  'Delete',
+  'End',
+  'Enter',
+  'Home',
+  'Meta',
+  'PageDown',
+  'PageUp',
+  'Tab',
+  '0',
+  '1',
+  '2',
+  '3',
+  '4',
+  '5',
+  '6',
+  '7',
+  '8',
+  '9',
+];
+/* eslint-enable @gitlab/require-i18n-strings */
 
 export default {
+  inputId: 'weight-widget-input',
+  components: {
+    GlForm,
+    GlFormGroup,
+    GlFormInput,
+  },
+  mixins: [Tracking.mixin()],
   inject: ['hasIssueWeightsFeature'],
   props: {
+    canUpdate: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
     weight: {
       type: Number,
       required: false,
       default: undefined,
     },
+    workItemId: {
+      type: String,
+      required: true,
+    },
+    workItemType: {
+      type: String,
+      required: true,
+    },
+  },
+  data() {
+    return {
+      isEditing: false,
+    };
   },
   computed: {
-    weightText() {
-      return this.weight ?? __('None');
+    placeholder() {
+      return this.canUpdate && this.isEditing ? __('Enter a number') : __('None');
+    },
+    tracking() {
+      return {
+        category: TRACKING_CATEGORY_SHOW,
+        label: 'item_weight',
+        property: `type_${this.workItemType}`,
+      };
+    },
+    type() {
+      return this.canUpdate && this.isEditing ? 'number' : 'text';
+    },
+  },
+  methods: {
+    blurInput() {
+      this.$refs.input.$el.blur();
+    },
+    handleFocus() {
+      this.isEditing = true;
+    },
+    handleKeydown(event) {
+      if (!allowedKeys.includes(event.key)) {
+        event.preventDefault();
+      }
+    },
+    updateWeight(event) {
+      this.isEditing = false;
+      this.track('updated_weight');
+      this.$apollo.mutate({
+        mutation: localUpdateWorkItemMutation,
+        variables: {
+          input: {
+            id: this.workItemId,
+            weight: event.target.value === '' ? null : Number(event.target.value),
+          },
+        },
+      });
     },
   },
 };
 </script>
 
 <template>
-  <div v-if="hasIssueWeightsFeature" class="gl-mb-5 form-row">
-    <span class="gl-font-weight-bold col-lg-2 col-3 gl-overflow-wrap-break">{{
-      __('Weight')
-    }}</span>
-    <span class="gl-ml-5">{{ weightText }}</span>
-  </div>
+  <gl-form v-if="hasIssueWeightsFeature" @submit.prevent="blurInput">
+    <gl-form-group
+      class="gl-align-items-center"
+      :label="__('Weight')"
+      :label-for="$options.inputId"
+      label-class="gl-pb-0! gl-overflow-wrap-break"
+      label-cols="3"
+      label-cols-lg="2"
+    >
+      <gl-form-input
+        :id="$options.inputId"
+        ref="input"
+        min="0"
+        :placeholder="placeholder"
+        :readonly="!canUpdate"
+        size="sm"
+        :type="type"
+        :value="weight"
+        @blur="updateWeight"
+        @focus="handleFocus"
+        @keydown="handleKeydown"
+        @keydown.exact.esc.stop="blurInput"
+      />
+    </gl-form-group>
+  </gl-form>
 </template>
diff --git a/app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql
index 0d31ecef6f8256213b18bf30e4a83752497fae20..43c92cf89eccdd2a205759962a3bea7ad659cba8 100644
--- a/app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql
+++ b/app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql
@@ -1,6 +1,6 @@
 #import "./work_item.fragment.graphql"
 
-mutation localUpdateWorkItem($input: LocalWorkItemAssigneesInput) {
+mutation localUpdateWorkItem($input: LocalUpdateWorkItemInput) {
   localUpdateWorkItem(input: $input) @client {
     workItem {
       ...WorkItem
diff --git a/app/assets/javascripts/work_items/graphql/provider.js b/app/assets/javascripts/work_items/graphql/provider.js
index 9266b4cdccba3e79c588cd4700d17e186f814f0d..80d8c98e75db783e84dd623af041ca046fbbdb13 100644
--- a/app/assets/javascripts/work_items/graphql/provider.js
+++ b/app/assets/javascripts/work_items/graphql/provider.js
@@ -2,7 +2,7 @@ import produce from 'immer';
 import Vue from 'vue';
 import VueApollo from 'vue-apollo';
 import createDefaultClient from '~/lib/graphql';
-import { WIDGET_TYPE_ASSIGNEE } from '../constants';
+import { WIDGET_TYPE_ASSIGNEE, WIDGET_TYPE_WEIGHT } from '../constants';
 import typeDefs from './typedefs.graphql';
 import workItemQuery from './work_item.query.graphql';
 
@@ -10,7 +10,7 @@ export const temporaryConfig = {
   typeDefs,
   cacheConfig: {
     possibleTypes: {
-      LocalWorkItemWidget: ['LocalWorkItemAssignees'],
+      LocalWorkItemWidget: ['LocalWorkItemAssignees', 'LocalWorkItemWeight'],
     },
     typePolicies: {
       WorkItem: {
@@ -46,7 +46,7 @@ export const temporaryConfig = {
                   {
                     __typename: 'LocalWorkItemWeight',
                     type: 'WEIGHT',
-                    weight: 0,
+                    weight: null,
                   },
                 ]
               );
@@ -67,10 +67,19 @@ export const resolvers = {
       });
 
       const data = produce(sourceData, (draftData) => {
-        const assigneesWidget = draftData.workItem.mockWidgets.find(
-          (widget) => widget.type === WIDGET_TYPE_ASSIGNEE,
-        );
-        assigneesWidget.nodes = [...input.assignees];
+        if (input.assignees) {
+          const assigneesWidget = draftData.workItem.mockWidgets.find(
+            (widget) => widget.type === WIDGET_TYPE_ASSIGNEE,
+          );
+          assigneesWidget.nodes = [...input.assignees];
+        }
+
+        if (input.weight != null) {
+          const weightWidget = draftData.workItem.mockWidgets.find(
+            (widget) => widget.type === WIDGET_TYPE_WEIGHT,
+          );
+          weightWidget.weight = input.weight;
+        }
       });
 
       cache.writeQuery({
diff --git a/app/assets/javascripts/work_items/graphql/typedefs.graphql b/app/assets/javascripts/work_items/graphql/typedefs.graphql
index de4bdad565964322c00af8b953ad9cacf2240e98..71ac263a02ebb57ee52e7ed6bbd4bfabd0bb6830 100644
--- a/app/assets/javascripts/work_items/graphql/typedefs.graphql
+++ b/app/assets/javascripts/work_items/graphql/typedefs.graphql
@@ -21,9 +21,10 @@ extend type WorkItem {
   mockWidgets: [LocalWorkItemWidget]
 }
 
-type LocalWorkItemAssigneesInput {
+input LocalUpdateWorkItemInput {
   id: WorkItemID!
   assignees: [UserCore!]
+  weight: Int
 }
 
 type LocalWorkItemPayload {
@@ -32,5 +33,5 @@ type LocalWorkItemPayload {
 }
 
 extend type Mutation {
-  localUpdateWorkItem(input: LocalWorkItemAssigneesInput!): LocalWorkItemPayload
+  localUpdateWorkItem(input: LocalUpdateWorkItemInput!): LocalWorkItemPayload
 }
diff --git a/app/assets/javascripts/work_items/index.js b/app/assets/javascripts/work_items/index.js
index 33e28831b5449a56aafdf1f05932957436c0d794..6437df597b47011d93b875696fad22ec80735904 100644
--- a/app/assets/javascripts/work_items/index.js
+++ b/app/assets/javascripts/work_items/index.js
@@ -10,6 +10,7 @@ export const initWorkItemsRoot = () => {
 
   return new Vue({
     el,
+    name: 'WorkItemsRoot',
     router: createRouter(el.dataset.fullPath),
     apolloProvider: createApolloProvider(),
     provide: {
diff --git a/app/assets/stylesheets/_page_specific_files.scss b/app/assets/stylesheets/_page_specific_files.scss
index cf4a415446e066ade264865621028481d8d88465..be72ec334658090e60e09b7582f1adb384247b61 100644
--- a/app/assets/stylesheets/_page_specific_files.scss
+++ b/app/assets/stylesheets/_page_specific_files.scss
@@ -32,4 +32,3 @@
 @import './pages/storage_quota';
 @import './pages/tree';
 @import './pages/users';
-@import './pages/work_items';
diff --git a/app/assets/stylesheets/page_bundles/work_items.scss b/app/assets/stylesheets/page_bundles/work_items.scss
new file mode 100644
index 0000000000000000000000000000000000000000..af019fb091bb3c9666399f3b3462a2b44d1eaeed
--- /dev/null
+++ b/app/assets/stylesheets/page_bundles/work_items.scss
@@ -0,0 +1,15 @@
+@import 'mixins_and_variables_and_functions';
+
+.gl-token-selector-token-container {
+  display: flex;
+  align-items: center;
+}
+
+#weight-widget-input:not(:hover, :focus),
+#weight-widget-input[readonly] {
+  box-shadow: inset 0 0 0 $gl-border-size-1 var(--white, $white);
+}
+
+#weight-widget-input[readonly] {
+  background-color: var(--white, $white);
+}
diff --git a/app/assets/stylesheets/pages/work_items.scss b/app/assets/stylesheets/pages/work_items.scss
deleted file mode 100644
index b98f55df1ed01fc3ffc0b1e0847197c6b53eab84..0000000000000000000000000000000000000000
--- a/app/assets/stylesheets/pages/work_items.scss
+++ /dev/null
@@ -1,4 +0,0 @@
-.gl-token-selector-token-container {
-  display: flex;
-  align-items: center;
-}
diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml
index 3572d1d6556cfb287218becd03046b7a19bcdc1a..06c422fc4d69d33bd1b1e3fd75efa621df6cb505 100644
--- a/app/views/projects/issues/show.html.haml
+++ b/app/views/projects/issues/show.html.haml
@@ -3,6 +3,7 @@
 - breadcrumb_title @issue.to_reference
 - page_title "#{@issue.title} (#{@issue.to_reference})", _("Issues")
 - add_page_specific_style 'page_bundles/issues_show'
+- add_page_specific_style 'page_bundles/work_items'
 
 = render 'projects/issuable/show', issuable: @issue, api_awards_path: award_emoji_issue_api_path(@issue)
 = render 'projects/invite_members_modal', project: @project
diff --git a/app/views/projects/work_items/index.html.haml b/app/views/projects/work_items/index.html.haml
index 1f36afc48aa9835bd460678034933be4263962b5..d8b6ae96826a039811f79d12dcc0478188320fc5 100644
--- a/app/views/projects/work_items/index.html.haml
+++ b/app/views/projects/work_items/index.html.haml
@@ -1,3 +1,4 @@
 - page_title s_('WorkItem|Work Items')
+- add_page_specific_style 'page_bundles/work_items'
 
 #js-work-items{ data: work_items_index_data(@project) }
diff --git a/config/application.rb b/config/application.rb
index 5e18d5fdd969f86a3a03517f1becc9a01eac27d1..6b4a55d6d05c4b9a7364db692b469926426f4631 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -304,6 +304,7 @@ class Application < Rails::Application
     config.assets.precompile << "page_bundles/terms.css"
     config.assets.precompile << "page_bundles/todos.css"
     config.assets.precompile << "page_bundles/wiki.css"
+    config.assets.precompile << "page_bundles/work_items.css"
     config.assets.precompile << "page_bundles/xterm.css"
     config.assets.precompile << "lazy_bundles/cropper.css"
     config.assets.precompile << "lazy_bundles/select2.css"
diff --git a/spec/frontend/work_items/components/item_title_spec.js b/spec/frontend/work_items/components/item_title_spec.js
index 2c3f6ef8634c4f3b46099bf55aa83763bd883a6e..a55f448c9a208ab462f2c98f446e5a1c601078e9 100644
--- a/spec/frontend/work_items/components/item_title_spec.js
+++ b/spec/frontend/work_items/components/item_title_spec.js
@@ -1,5 +1,4 @@
 import { shallowMount } from '@vue/test-utils';
-import { escape } from 'lodash';
 import ItemTitle from '~/work_items/components/item_title.vue';
 
 jest.mock('lodash/escape', () => jest.fn((fn) => fn));
@@ -51,6 +50,5 @@ describe('ItemTitle', () => {
     await findInputEl().trigger(sourceEvent);
 
     expect(wrapper.emitted(eventName)).toBeTruthy();
-    expect(escape).toHaveBeenCalledWith(mockUpdatedTitle);
   });
 });
diff --git a/spec/frontend/work_items/components/work_item_weight_spec.js b/spec/frontend/work_items/components/work_item_weight_spec.js
index 80a1d032ad72c4e3015948a7a45ccde8cd3171ad..c3bbea26cda7c43a31b1b2a826fa15b934442b62 100644
--- a/spec/frontend/work_items/components/work_item_weight_spec.js
+++ b/spec/frontend/work_items/components/work_item_weight_spec.js
@@ -1,21 +1,51 @@
-import { shallowMount } from '@vue/test-utils';
+import { GlForm, GlFormInput } from '@gitlab/ui';
+import { nextTick } from 'vue';
+import { mockTracking } from 'helpers/tracking_helper';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import { __ } from '~/locale';
 import WorkItemWeight from '~/work_items/components/work_item_weight.vue';
+import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants';
+import localUpdateWorkItemMutation from '~/work_items/graphql/local_update_work_item.mutation.graphql';
 
-describe('WorkItemAssignees component', () => {
+describe('WorkItemWeight component', () => {
   let wrapper;
 
-  const createComponent = ({ weight, hasIssueWeightsFeature = true } = {}) => {
-    wrapper = shallowMount(WorkItemWeight, {
+  const mutateSpy = jest.fn();
+  const workItemId = 'gid://gitlab/WorkItem/1';
+  const workItemType = 'Task';
+
+  const findForm = () => wrapper.findComponent(GlForm);
+  const findInput = () => wrapper.findComponent(GlFormInput);
+
+  const createComponent = ({
+    canUpdate = false,
+    hasIssueWeightsFeature = true,
+    isEditing = false,
+    weight,
+  } = {}) => {
+    wrapper = mountExtended(WorkItemWeight, {
       propsData: {
+        canUpdate,
         weight,
+        workItemId,
+        workItemType,
       },
       provide: {
         hasIssueWeightsFeature,
       },
+      mocks: {
+        $apollo: {
+          mutate: mutateSpy,
+        },
+      },
     });
+
+    if (isEditing) {
+      findInput().vm.$emit('focus');
+    }
   };
 
-  describe('weight licensed feature', () => {
+  describe('`issue_weights` licensed feature', () => {
     describe.each`
       description             | hasIssueWeightsFeature | exists
       ${'when available'}     | ${true}                | ${true}
@@ -24,23 +54,111 @@ describe('WorkItemAssignees component', () => {
       it(hasIssueWeightsFeature ? 'renders component' : 'does not render component', () => {
         createComponent({ hasIssueWeightsFeature });
 
-        expect(wrapper.find('div').exists()).toBe(exists);
+        expect(findForm().exists()).toBe(exists);
       });
     });
   });
 
-  describe('weight text', () => {
-    describe.each`
-      description       | weight       | text
-      ${'renders 1'}    | ${1}         | ${'1'}
-      ${'renders 0'}    | ${0}         | ${'0'}
-      ${'renders None'} | ${null}      | ${'None'}
-      ${'renders None'} | ${undefined} | ${'None'}
-    `('when weight is $weight', ({ description, weight, text }) => {
-      it(description, () => {
-        createComponent({ weight });
-
-        expect(wrapper.text()).toContain(text);
+  describe('weight input', () => {
+    it('has "Weight" label', () => {
+      createComponent();
+
+      expect(wrapper.findByLabelText(__('Weight')).exists()).toBe(true);
+    });
+
+    describe('placeholder attribute', () => {
+      describe.each`
+        description                             | isEditing | canUpdate | value
+        ${'when not editing and cannot update'} | ${false}  | ${false}  | ${__('None')}
+        ${'when editing and cannot update'}     | ${true}   | ${false}  | ${__('None')}
+        ${'when not editing and can update'}    | ${false}  | ${true}   | ${__('None')}
+        ${'when editing and can update'}        | ${true}   | ${true}   | ${__('Enter a number')}
+      `('$description', ({ isEditing, canUpdate, value }) => {
+        it(`has a value of "${value}"`, async () => {
+          createComponent({ canUpdate, isEditing });
+          await nextTick();
+
+          expect(findInput().attributes('placeholder')).toBe(value);
+        });
+      });
+    });
+
+    describe('readonly attribute', () => {
+      describe.each`
+        description             | canUpdate | value
+        ${'when cannot update'} | ${false}  | ${'readonly'}
+        ${'when can update'}    | ${true}   | ${undefined}
+      `('$description', ({ canUpdate, value }) => {
+        it(`renders readonly=${value}`, () => {
+          createComponent({ canUpdate });
+
+          expect(findInput().attributes('readonly')).toBe(value);
+        });
+      });
+    });
+
+    describe('type attribute', () => {
+      describe.each`
+        description                             | isEditing | canUpdate | type
+        ${'when not editing and cannot update'} | ${false}  | ${false}  | ${'text'}
+        ${'when editing and cannot update'}     | ${true}   | ${false}  | ${'text'}
+        ${'when not editing and can update'}    | ${false}  | ${true}   | ${'text'}
+        ${'when editing and can update'}        | ${true}   | ${true}   | ${'number'}
+      `('$description', ({ isEditing, canUpdate, type }) => {
+        it(`has a value of "${type}"`, async () => {
+          createComponent({ canUpdate, isEditing });
+          await nextTick();
+
+          expect(findInput().attributes('type')).toBe(type);
+        });
+      });
+    });
+
+    describe('value attribute', () => {
+      describe.each`
+        weight       | value
+        ${1}         | ${'1'}
+        ${0}         | ${'0'}
+        ${null}      | ${''}
+        ${undefined} | ${''}
+      `('when `weight` prop is "$weight"', ({ weight, value }) => {
+        it(`value is "${value}"`, () => {
+          createComponent({ weight });
+
+          expect(findInput().element.value).toBe(value);
+        });
+      });
+    });
+
+    describe('when blurred', () => {
+      it('calls a mutation to update the weight', () => {
+        const weight = 0;
+        createComponent({ isEditing: true, weight });
+
+        findInput().trigger('blur');
+
+        expect(mutateSpy).toHaveBeenCalledWith({
+          mutation: localUpdateWorkItemMutation,
+          variables: {
+            input: {
+              id: workItemId,
+              weight,
+            },
+          },
+        });
+      });
+
+      it('tracks updating the weight', () => {
+        const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+        createComponent();
+
+        findInput().trigger('blur');
+
+        expect(trackingSpy).toHaveBeenCalledWith(TRACKING_CATEGORY_SHOW, 'updated_weight', {
+          category: TRACKING_CATEGORY_SHOW,
+          label: 'item_weight',
+          property: 'type_Task',
+        });
       });
     });
   });