diff --git a/djblets/forms/templates/djblets_forms/amount_selector_widget.html b/djblets/forms/templates/djblets_forms/amount_selector_widget.html
new file mode 100644
index 0000000000000000000000000000000000000000..d0b969c87065e21394c8b11138813e0bde50c1d1
--- /dev/null
+++ b/djblets/forms/templates/djblets_forms/amount_selector_widget.html
@@ -0,0 +1,16 @@
+{% include "django/forms/widgets/multiwidget.html" %}
+<script>
+(function($) {
+    $(document).ready(function() {
+        const id = '{{widget.attrs.id}}';
+        const $num_input = $(`#${id}_0`);
+        const $select = $(`#${id}_1`);
+
+        $num_input.toggle($select.val() != '');
+
+        $select.on('change', () => {
+            $num_input.toggle($select.val() != '');
+        })
+    });
+})($ || window.jQuery || django.jQuery)
+</script>
diff --git a/djblets/forms/tests/test_amount_selector_widget.py b/djblets/forms/tests/test_amount_selector_widget.py
new file mode 100644
index 0000000000000000000000000000000000000000..26dc6cbdeb74fcb0b09a4b0863a5083586745362
--- /dev/null
+++ b/djblets/forms/tests/test_amount_selector_widget.py
@@ -0,0 +1,147 @@
+"""Unit tests for djblets.forms.widgets.AmountSelectorWidget.
+
+Version Added:
+    3.3
+"""
+
+from djblets.forms.widgets import AmountSelectorWidget
+from djblets.testing.testcases import TestCase
+
+
+class AmountSelectorWidgetTests(TestCase):
+    """Unit tests for djblets.forms.widgets.AmountSelectorWidget."""
+
+    def test_value_from_datadict_base(self) -> None:
+        """Testing AmountSelectorWidget.value_from_datadict with a value
+        in the base unit
+        """
+        widget = AmountSelectorWidget(unit_choices=[
+            (1, 'bytes'),
+            (1024, 'kilobytes'),
+            (1048576, 'megabytes'),
+            (1073741824, 'gigabytes'),
+        ])
+
+        # 8 bytes.
+        data = {
+            'my_field_0': '8',
+            'my_field_1': '1',
+        }
+
+        self.assertEqual(
+            widget.value_from_datadict(data=data, files={}, name='my_field'),
+            8)
+
+    def test_value_from_datadict_non_base(self) -> None:
+        """Testing AmountSelectorWidget.value_from_datadict with a value
+        in one of the non base units
+        """
+        widget = AmountSelectorWidget(unit_choices=[
+            (1, 'bytes'),
+            (1024, 'kilobytes'),
+            (1048576, 'megabytes'),
+            (1073741824, 'gigabytes'),
+        ])
+
+        # 2 megabytes.
+        data = {
+            'my_field_0': '2',
+            'my_field_1': '1048576',
+        }
+
+        self.assertEqual(
+            widget.value_from_datadict(data=data, files={}, name='my_field'),
+            2097152)
+
+    def test_value_from_datadict_zero(self) -> None:
+        """Testing AmountSelectorWidget.value_from_datadict with a value
+        of 0
+        """
+        widget = AmountSelectorWidget(unit_choices=[
+            (1, 'bytes'),
+            (1024, 'kilobytes'),
+            (1048576, 'megabytes'),
+            (1073741824, 'gigabytes'),
+        ])
+
+        # 2 megabytes.
+        data = {
+            'my_field_0': '0',
+            'my_field_1': '1024',
+        }
+
+        self.assertEqual(
+            widget.value_from_datadict(data=data, files={}, name='my_field'),
+            0)
+
+    def test_value_from_datadict_none(self) -> None:
+        """Testing AmountSelectorWidget.value_from_datadict with a null
+        value
+        """
+        widget = AmountSelectorWidget(unit_choices=[
+            (1, 'bytes'),
+            (1024, 'kilobytes'),
+            (1048576, 'megabytes'),
+            (1073741824, 'gigabytes'),
+        ])
+
+        # No unit selected. The amount should be disregarded.
+        data = {
+            'my_field_0': '5',
+            'my_field_1': '',
+        }
+
+        self.assertEqual(
+            widget.value_from_datadict(data=data, files={}, name='my_field'),
+            None)
+
+    def test_decompress_base(self) -> None:
+        """Testing AmountSelectorWidget.decompress with a value in the
+        base unit
+        """
+        widget = AmountSelectorWidget(unit_choices=[
+            (1, 'bytes'),
+            (1024, 'kilobytes'),
+            (1048576, 'megabytes'),
+            (1073741824, 'gigabytes'),
+        ])
+
+        # 1500 bytes.
+        self.assertEqual(widget.decompress(1500), (1500, 1))
+
+    def test_decompress_non_base(self) -> None:
+        """Testing AmountSelectorWidget.decompress represents a value in the
+        most appropriate unit
+        """
+        widget = AmountSelectorWidget(unit_choices=[
+            (1, 'bytes'),
+            (1024, 'kilobytes'),
+            (1048576, 'megabytes'),
+            (1073741824, 'gigabytes'),
+        ])
+
+        # 2 kilobytes.
+        self.assertEqual(widget.decompress(2048), (2, 1024))
+
+    def test_decompress_zeroe(self) -> None:
+        """Testing AmountSelectorWidget.decompress with a value of 0"""
+        widget = AmountSelectorWidget(unit_choices=[
+            (1, 'bytes'),
+            (1024, 'kilobytes'),
+            (1048576, 'megabytes'),
+            (1073741824, 'gigabytes'),
+        ])
+
+        # 0 gigabytes.
+        self.assertEqual(widget.decompress(0), (0, 1073741824))
+
+    def test_decompress_none(self) -> None:
+        """Testing AmountSelectorWidget.decompress with a null value"""
+        widget = AmountSelectorWidget(unit_choices=[
+            (1, 'bytes'),
+            (1024, 'kilobytes'),
+            (1048576, 'megabytes'),
+            (1073741824, 'gigabytes'),
+        ])
+
+        self.assertEqual(widget.decompress(None), (None, None))
diff --git a/djblets/forms/widgets.py b/djblets/forms/widgets.py
index 8f946012496227b35ba130aab62d75a879c71f25..dd6758d5de2bba69fb5dc614bd7eaebe313231d7 100644
--- a/djblets/forms/widgets.py
+++ b/djblets/forms/widgets.py
@@ -4,8 +4,11 @@ This module contains widgets that correspond to fields provided in
 :py:mod:`djblets.forms.fields`.
 """
 
+from __future__ import annotations
+
 import copy
 from contextlib import contextmanager
+from typing import Any, Dict, List, Optional, Tuple
 
 from django.forms import widgets
 from django.forms.widgets import HiddenInput
@@ -18,6 +21,139 @@ from djblets.conditions.errors import (ConditionChoiceNotFoundError,
 from djblets.deprecation import RemovedInDjblets40Warning
 
 
+class AmountSelectorWidget(widgets.MultiWidget):
+    """A widget for editing an amount and its unit of measurement.
+
+    Version Added:
+        3.3
+    """
+
+    #: The name of the template used to render the widget.
+    template_name = 'djblets_forms/amount_selector_widget.html'
+
+    def __init__(
+        self,
+        unit_choices: List[Tuple[Optional[int], str]],
+        number_attrs: Optional[Dict[str, Any]] = None,
+        select_attrs: Optional[Dict[str, Any]] = None,
+        attrs: Optional[Dict[str, Any]] = None,
+    ) -> None:
+        """Initialize the widget.
+
+        Args:
+            unit_choices (list of tuple):
+                The unit choices for the field. This should be a list of
+                tuples with the following entries:
+                    Tuple:
+                        0 (int or None):
+                            The conversion factor of the unit to the base unit.
+                            The base unit must always have this value set to 1.
+                            For the rest of the units, this will be the number
+                            that you need to multiply a value in the base unit
+                            by in order to convert it to the given unit.
+
+                        1 (str):
+                            The name for the unit.
+
+                The list of unit choices should start with the base unit
+                and have the rest of the units follow in increasing order
+                of magnitude. You may set a conversion factor of ``None`` for
+                a unit choice, which will allow you to save the value for this
+                widget as ``None`` instead of as an integer amount. The
+                ``None`` unit choice should be placed at the end of the list.
+
+            number_attrs (dict, optional):
+                Additional HTML element attributes for the NumberInput widget.
+
+            select_attrs (dict, optional):
+                Additional HTML element attributes for the Select widget.
+
+            attrs (dict, optional):
+                Additional HTML element attributes for the MultiWidget parent.
+        """
+        self.widgets: Tuple[widgets.NumberInput, widgets.Select] = (
+            widgets.NumberInput(attrs=number_attrs),
+            widgets.Select(attrs=select_attrs, choices=unit_choices),
+        )
+        super().__init__(self.widgets, attrs)
+
+    def decompress(
+        self,
+        value: Optional[int],
+    ) -> Tuple[Optional[int], Optional[int]]:
+        """Break up the value into an amount and unit tuple.
+
+        This assumes that the value is stored in the base unit, and will
+        find the most appropriate unit to display and convert the amount
+        to that unit. The most appropriate unit is the largest unit where
+        the amount can be converted to a whole number.
+
+        Args:
+            value (int or None):
+                The stored value.
+
+        Returns:
+            Tuple:
+            A tuple of:
+                0 (int or None):
+                    The amount in the unit.
+
+                1 (int or None):
+                    The integer representation of the unit.
+        """
+        if value is None:
+            return None, None
+
+        widget_choices = self.widgets[1].choices
+        unit_multiplier = 1
+
+        for choice in reversed(widget_choices):
+            unit_multiplier = choice[0]
+
+            if unit_multiplier is None:
+                continue
+
+            assert isinstance(unit_multiplier, int)
+
+            if (value % unit_multiplier == 0):
+                break
+
+        return value // unit_multiplier, unit_multiplier
+
+    def value_from_datadict(
+        self,
+        data: Dict[str, Any],
+        files: Dict[str, Any],
+        name: str,
+    ) -> Optional[int]:
+        """Return a value for the field from a submitted form.
+
+        This serializes the data POSTed for the form into an integer that the
+        field can use and validate. This will convert the integer value to the
+        base unit.
+
+         Args:
+            data (dict):
+                The dictionary containing form data.
+
+            files (dict):
+                The dictionary containing uploaded files.
+
+            name (str):
+                The field name for the value to load.
+
+        Returns:
+            int or None:
+            The value to save in the field.
+        """
+        value, unit_multiplier = super().value_from_datadict(data, files, name)
+
+        if unit_multiplier == '':
+            return None
+        else:
+            return int(value) * int(unit_multiplier)
+
+
 class ConditionsWidget(widgets.Widget):
     """A widget used to request a list of conditions from the user.
 
