diff --git a/source/funkin/ui/options/MenuItemEnums.hx b/source/funkin/ui/options/MenuItemEnums.hx
new file mode 100644
index 000000000..4513a92af
--- /dev/null
+++ b/source/funkin/ui/options/MenuItemEnums.hx
@@ -0,0 +1,10 @@
+package funkin.ui.options;
+
+// Add enums for use with `EnumPreferenceItem` here!
+/* Example:
+  class MyOptionEnum
+  {
+    public static inline var YuhUh = "true";  // "true" is the value's ID
+    public static inline var NuhUh = "false";
+  }
+ */
diff --git a/source/funkin/ui/options/PreferencesMenu.hx b/source/funkin/ui/options/PreferencesMenu.hx
index 783aef0ba..5fbefceed 100644
--- a/source/funkin/ui/options/PreferencesMenu.hx
+++ b/source/funkin/ui/options/PreferencesMenu.hx
@@ -8,6 +8,11 @@ import funkin.ui.AtlasText.AtlasFont;
 import funkin.ui.options.OptionsState.Page;
 import funkin.graphics.FunkinCamera;
 import funkin.ui.TextMenuList.TextMenuItem;
+import funkin.audio.FunkinSound;
+import funkin.ui.options.MenuItemEnums;
+import funkin.ui.options.items.CheckboxPreferenceItem;
+import funkin.ui.options.items.NumberPreferenceItem;
+import funkin.ui.options.items.EnumPreferenceItem;
 
 class PreferencesMenu extends Page
 {
@@ -69,11 +74,51 @@ class PreferencesMenu extends Page
     }, Preferences.autoPause);
   }
 
+  override function update(elapsed:Float):Void
+  {
+    super.update(elapsed);
+
+    // Indent the selected item.
+    items.forEach(function(daItem:TextMenuItem) {
+      var thyOffset:Int = 0;
+
+      // Initializing thy text width (if thou text present)
+      var thyTextWidth:Int = 0;
+      if (Std.isOfType(daItem, EnumPreferenceItem)) thyTextWidth = cast(daItem, EnumPreferenceItem).lefthandText.getWidth();
+      else if (Std.isOfType(daItem, NumberPreferenceItem)) thyTextWidth = cast(daItem, NumberPreferenceItem).lefthandText.getWidth();
+
+      if (thyTextWidth != 0)
+      {
+        // Magic number because of the weird offset thats being added by default
+        thyOffset += thyTextWidth - 75;
+      }
+
+      if (items.selectedItem == daItem)
+      {
+        thyOffset += 150;
+      }
+      else
+      {
+        thyOffset += 120;
+      }
+
+      daItem.x = thyOffset;
+    });
+  }
+
+  // - Preference item creation methods -
+  // Should be moved into a separate PreferenceItems class but you can't access PreferencesMenu.items and PreferencesMenu.preferenceItems from outside.
+
+  /**
+   * Creates a pref item that works with booleans
+   * @param onChange Gets called every time the player changes the value; use this to apply the value
+   * @param defaultValue The value that is loaded in when the pref item is created (usually your Preferences.settingVariable)
+   */
   function createPrefItemCheckbox(prefName:String, prefDesc:String, onChange:Bool->Void, defaultValue:Bool):Void
   {
     var checkbox:CheckboxPreferenceItem = new CheckboxPreferenceItem(0, 120 * (items.length - 1 + 1), defaultValue);
 
-    items.createItem(120, (120 * items.length) + 30, prefName, AtlasFont.BOLD, function() {
+    items.createItem(0, (120 * items.length) + 30, prefName, AtlasFont.BOLD, function() {
       var value = !checkbox.currentValue;
       onChange(value);
       checkbox.currentValue = value;
@@ -82,62 +127,54 @@ class PreferencesMenu extends Page
     preferenceItems.add(checkbox);
   }
 
-  override function update(elapsed:Float)
+  /**
+   * Creates a pref item that works with general numbers
+   * @param onChange Gets called every time the player changes the value; use this to apply the value
+   * @param valueFormatter Will get called every time the game needs to display the float value; use this to change how the displayed value looks
+   * @param defaultValue The value that is loaded in when the pref item is created (usually your Preferences.settingVariable)
+   * @param min Minimum value (example: 0)
+   * @param max Maximum value (example: 10)
+   * @param step The value to increment/decrement by (default = 0.1)
+   * @param precision Rounds decimals up to a `precision` amount of digits (ex: 4 -> 0.1234, 2 -> 0.12)
+   */
+  function createPrefItemNumber(prefName:String, prefDesc:String, onChange:Float->Void, ?valueFormatter:Float->String, defaultValue:Int, min:Int, max:Int,
+      step:Float = 0.1, precision:Int):Void
   {
-    super.update(elapsed);
+    var item = new NumberPreferenceItem(0, (120 * items.length) + 30, prefName, defaultValue, min, max, step, precision, onChange, valueFormatter);
+    items.addItem(prefName, item);
+    preferenceItems.add(item.lefthandText);
+  }
 
-    // Indent the selected item.
-    // TODO: Only do this on menu change?
-    items.forEach(function(daItem:TextMenuItem) {
-      if (items.selectedItem == daItem) daItem.x = 150;
-      else
-        daItem.x = 120;
-    });
-  }
-}
-
-class CheckboxPreferenceItem extends FlxSprite
-{
-  public var currentValue(default, set):Bool;
-
-  public function new(x:Float, y:Float, defaultValue:Bool = false)
-  {
-    super(x, y);
-
-    frames = Paths.getSparrowAtlas('checkboxThingie');
-    animation.addByPrefix('static', 'Check Box unselected', 24, false);
-    animation.addByPrefix('checked', 'Check Box selecting animation', 24, false);
-
-    setGraphicSize(Std.int(width * 0.7));
-    updateHitbox();
-
-    this.currentValue = defaultValue;
-  }
-
-  override function update(elapsed:Float)
-  {
-    super.update(elapsed);
-
-    switch (animation.curAnim.name)
-    {
-      case 'static':
-        offset.set();
-      case 'checked':
-        offset.set(17, 70);
-    }
-  }
-
-  function set_currentValue(value:Bool):Bool
-  {
-    if (value)
-    {
-      animation.play('checked', true);
-    }
-    else
-    {
-      animation.play('static');
-    }
-
-    return currentValue = value;
+  /**
+   * Creates a pref item that works with number percentages
+   * @param onChange Gets called every time the player changes the value; use this to apply the value
+   * @param defaultValue The value that is loaded in when the pref item is created (usually your Preferences.settingVariable)
+   * @param min Minimum value (default = 0)
+   * @param max Maximum value (default = 100)
+   */
+  function createPrefItemPercentage(prefName:String, prefDesc:String, onChange:Int->Void, defaultValue:Int, min:Int = 0, max:Int = 100):Void
+  {
+    var newCallback = function(value:Float) {
+      onChange(Std.int(value));
+    };
+    var formatter = function(value:Float) {
+      return '${value}%';
+    };
+    var item = new NumberPreferenceItem(0, (120 * items.length) + 30, prefName, defaultValue, min, max, 10, 0, newCallback, formatter);
+    items.addItem(prefName, item);
+    preferenceItems.add(item.lefthandText);
+  }
+
+  /**
+   * Creates a pref item that works with enums
+   * @param values Maps enum values to display strings _(ex: `NoteHitSoundType.PingPong => "Ping pong"`)_
+   * @param onChange Gets called every time the player changes the value; use this to apply the value
+   * @param defaultValue The value that is loaded in when the pref item is created (usually your Preferences.settingVariable)
+   */
+  function createPrefItemEnum(prefName:String, prefDesc:String, values:Map<String, String>, onChange:String->Void, defaultValue:String):Void
+  {
+    var item = new EnumPreferenceItem(0, (120 * items.length) + 30, prefName, values, defaultValue, onChange);
+    items.addItem(prefName, item);
+    preferenceItems.add(item.lefthandText);
   }
 }
diff --git a/source/funkin/ui/options/items/CheckboxPreferenceItem.hx b/source/funkin/ui/options/items/CheckboxPreferenceItem.hx
new file mode 100644
index 000000000..88c4fb6b0
--- /dev/null
+++ b/source/funkin/ui/options/items/CheckboxPreferenceItem.hx
@@ -0,0 +1,49 @@
+package funkin.ui.options.items;
+
+import flixel.FlxSprite.FlxSprite;
+
+class CheckboxPreferenceItem extends FlxSprite
+{
+  public var currentValue(default, set):Bool;
+
+  public function new(x:Float, y:Float, defaultValue:Bool = false)
+  {
+    super(x, y);
+
+    frames = Paths.getSparrowAtlas('checkboxThingie');
+    animation.addByPrefix('static', 'Check Box unselected', 24, false);
+    animation.addByPrefix('checked', 'Check Box selecting animation', 24, false);
+
+    setGraphicSize(Std.int(width * 0.7));
+    updateHitbox();
+
+    this.currentValue = defaultValue;
+  }
+
+  override function update(elapsed:Float)
+  {
+    super.update(elapsed);
+
+    switch (animation.curAnim.name)
+    {
+      case 'static':
+        offset.set();
+      case 'checked':
+        offset.set(17, 70);
+    }
+  }
+
+  function set_currentValue(value:Bool):Bool
+  {
+    if (value)
+    {
+      animation.play('checked', true);
+    }
+    else
+    {
+      animation.play('static');
+    }
+
+    return currentValue = value;
+  }
+}
diff --git a/source/funkin/ui/options/items/EnumPreferenceItem.hx b/source/funkin/ui/options/items/EnumPreferenceItem.hx
new file mode 100644
index 000000000..02a273353
--- /dev/null
+++ b/source/funkin/ui/options/items/EnumPreferenceItem.hx
@@ -0,0 +1,84 @@
+package funkin.ui.options.items;
+
+import funkin.ui.TextMenuList;
+import funkin.ui.AtlasText;
+import funkin.input.Controls;
+import funkin.ui.options.MenuItemEnums;
+import haxe.EnumTools;
+
+/**
+ * Preference item that allows the player to pick a value from an enum (list of values)
+ */
+class EnumPreferenceItem extends TextMenuItem
+{
+  function controls():Controls
+  {
+    return PlayerSettings.player1.controls;
+  }
+
+  public var lefthandText:AtlasText;
+
+  public var currentValue:String;
+  public var onChangeCallback:Null<String->Void>;
+  public var map:Map<String, String>;
+  public var keys:Array<String> = [];
+
+  var index = 0;
+
+  public function new(x:Float, y:Float, name:String, map:Map<String, String>, defaultValue:String, ?callback:String->Void)
+  {
+    super(x, y, name, function() {
+      callback(this.currentValue);
+    });
+
+    updateHitbox();
+
+    this.map = map;
+    this.currentValue = defaultValue;
+    this.onChangeCallback = callback;
+
+    var i:Int = 0;
+    for (key in map.keys())
+    {
+      this.keys.push(key);
+      if (this.currentValue == key) index = i;
+      i += 1;
+    }
+
+    lefthandText = new AtlasText(15, y, formatted(defaultValue), AtlasFont.DEFAULT);
+  }
+
+  override function update(elapsed:Float):Void
+  {
+    super.update(elapsed);
+
+    // var fancyTextFancyColor:Color;
+    if (selected)
+    {
+      var shouldDecrease:Bool = controls().UI_LEFT_P;
+      var shouldIncrease:Bool = controls().UI_RIGHT_P;
+
+      if (shouldDecrease) index -= 1;
+      if (shouldIncrease) index += 1;
+
+      if (index > keys.length - 1) index = 0;
+      if (index < 0) index = keys.length - 1;
+
+      currentValue = keys[index];
+      if (onChangeCallback != null && (shouldIncrease || shouldDecrease))
+      {
+        onChangeCallback(currentValue);
+      }
+    }
+
+    lefthandText.text = formatted(currentValue);
+  }
+
+  function formatted(value:String):String
+  {
+    // FIXME: Can't add arrows around the text because the font doesn't support < >
+    // var leftArrow:String = selected ? '<' : '';
+    // var rightArrow:String = selected ? '>' : '';
+    return '${map.get(value) ?? value}';
+  }
+}
diff --git a/source/funkin/ui/options/items/NumberPreferenceItem.hx b/source/funkin/ui/options/items/NumberPreferenceItem.hx
new file mode 100644
index 000000000..f3cd3cd46
--- /dev/null
+++ b/source/funkin/ui/options/items/NumberPreferenceItem.hx
@@ -0,0 +1,136 @@
+package funkin.ui.options.items;
+
+import funkin.ui.TextMenuList;
+import funkin.ui.AtlasText;
+import funkin.input.Controls;
+
+/**
+ * Preference item that allows the player to pick a value between min and max
+ */
+class NumberPreferenceItem extends TextMenuItem
+{
+  function controls():Controls
+  {
+    return PlayerSettings.player1.controls;
+  }
+
+  // Widgets
+  public var lefthandText:AtlasText;
+
+  // Constants
+  static final HOLD_DELAY:Float = 0.3; // seconds
+  static final CHANGE_RATE:Float = 0.08; // seconds
+
+  // Constructor-initialized variables
+  public var currentValue:Float;
+  public var min:Float;
+  public var max:Float;
+  public var step:Float;
+  public var precision:Int;
+  public var onChangeCallback:Null<Float->Void>;
+  public var valueFormatter:Null<Float->String>;
+
+  // Variables
+  var holdDelayTimer:Float = HOLD_DELAY; // seconds
+  var changeRateTimer:Float = 0.0; // seconds
+
+  /**
+   * @param min Minimum value (example: 0)
+   * @param max Maximum value (example: 100)
+   * @param step The value to increment/decrement by (example: 10)
+   * @param callback Will get called every time the user changes the setting; use this to apply/save the setting.
+   * @param valueFormatter Will get called every time the game needs to display the float value; use this to change how the displayed string looks
+   */
+  public function new(x:Float, y:Float, name:String, defaultValue:Float, min:Float, max:Float, step:Float, precision:Int, ?callback:Float->Void,
+      ?valueFormatter:Float->String):Void
+  {
+    super(x, y, name, function() {
+      callback(this.currentValue);
+    });
+    lefthandText = new AtlasText(15, y, formatted(defaultValue), AtlasFont.DEFAULT);
+
+    updateHitbox();
+
+    this.currentValue = defaultValue;
+    this.min = min;
+    this.max = max;
+    this.step = step;
+    this.precision = precision;
+    this.onChangeCallback = callback;
+    this.valueFormatter = valueFormatter;
+  }
+
+  override function update(elapsed:Float):Void
+  {
+    super.update(elapsed);
+
+    // var fancyTextFancyColor:Color;
+    if (selected)
+    {
+      holdDelayTimer -= elapsed;
+      if (holdDelayTimer <= 0.0)
+      {
+        changeRateTimer -= elapsed;
+      }
+
+      var jpLeft:Bool = controls().UI_LEFT_P;
+      var jpRight:Bool = controls().UI_RIGHT_P;
+
+      if (jpLeft || jpRight)
+      {
+        holdDelayTimer = HOLD_DELAY;
+        changeRateTimer = 0.0;
+      }
+
+      var shouldDecrease:Bool = jpLeft;
+      var shouldIncrease:Bool = jpRight;
+
+      if (controls().UI_LEFT && holdDelayTimer <= 0.0 && changeRateTimer <= 0.0)
+      {
+        shouldDecrease = true;
+        changeRateTimer = CHANGE_RATE;
+      }
+      else if (controls().UI_RIGHT && holdDelayTimer <= 0.0 && changeRateTimer <= 0.0)
+      {
+        shouldIncrease = true;
+        changeRateTimer = CHANGE_RATE;
+      }
+
+      // Actually increasing/decreasing the value
+      if (shouldDecrease)
+      {
+        var isBelowMin:Bool = currentValue - step < min;
+        currentValue = (currentValue - step).clamp(min, max);
+        if (onChangeCallback != null && !isBelowMin) onChangeCallback(currentValue);
+      }
+      else if (shouldIncrease)
+      {
+        var isAboveMax:Bool = currentValue + step > max;
+        currentValue = (currentValue + step).clamp(min, max);
+        if (onChangeCallback != null && !isAboveMax) onChangeCallback(currentValue);
+      }
+    }
+
+    lefthandText.text = formatted(currentValue);
+  }
+
+  /** Turns the float into a string */
+  function formatted(value:Float):String
+  {
+    var float:Float = toFixed(value);
+    if (valueFormatter != null)
+    {
+      return valueFormatter(float);
+    }
+    else
+    {
+      return '${float}';
+    }
+  }
+
+  function toFixed(value:Float):Float
+  {
+    var multiplier:Float = Math.pow(10, precision);
+    return Math.floor(value * multiplier) / multiplier;
+  }
+}