diff --git a/frontend/src/metabase/parameters/components/ParameterValueWidget.jsx b/frontend/src/metabase/parameters/components/ParameterValueWidget.jsx
index c045eff320ae4051a55e130270c55206afa1693d..381761e28d0bff1ed696b0badd4ad5ddfeb1bd8f 100644
--- a/frontend/src/metabase/parameters/components/ParameterValueWidget.jsx
+++ b/frontend/src/metabase/parameters/components/ParameterValueWidget.jsx
@@ -7,7 +7,11 @@ import _ from "underscore";
 import { getParameterIconName } from "metabase/parameters/utils/ui";
 import { isDashboardParameterWithoutMapping } from "metabase/parameters/utils/dashboards";
 import { isOnlyMappedToFields } from "metabase/parameters/utils/fields";
-import { isDateParameter } from "metabase/parameters/utils/parameter-type";
+import {
+  isDateParameter,
+  isNumberParameter,
+} from "metabase/parameters/utils/parameter-type";
+import { getNumberParameterArity } from "metabase/parameters/utils/operators";
 import PopoverWithTrigger from "metabase/components/PopoverWithTrigger";
 import Icon from "metabase/components/Icon";
 import DateSingleWidget from "metabase/components/DateSingleWidget";
@@ -20,6 +24,7 @@ import Tooltip from "metabase/components/Tooltip";
 import TextWidget from "metabase/components/TextWidget";
 import WidgetStatusIcon from "metabase/parameters/components/WidgetStatusIcon";
 import FormattedParameterValue from "metabase/parameters/components/FormattedParameterValue";
+import NumberInputWidget from "metabase/parameters/components/widgets/NumberInputWidget";
 
 import ParameterFieldWidget from "./widgets/ParameterFieldWidget/ParameterFieldWidget";
 import S from "./ParameterWidget.css";
@@ -230,12 +235,35 @@ function Widget({
     );
   }
 
-  const DateWidget = DATE_WIDGETS[parameter.type];
-  if (DateWidget) {
+  if (isDateParameter(parameter)) {
+    const DateWidget = DATE_WIDGETS[parameter.type];
     return (
       <DateWidget value={value} setValue={setValue} onClose={onPopoverClose} />
     );
   } else if (isOnlyMappedToFields(parameter)) {
+    const normalizedValue = Array.isArray(value)
+      ? value
+      : [value].filter(v => v != null);
+
+    if (isNumberParameter(parameter)) {
+      const arity = getNumberParameterArity(parameter);
+      return (
+        <NumberInputWidget
+          value={normalizedValue}
+          setValue={value => {
+            setValue(value);
+            onPopoverClose();
+          }}
+          arity={arity}
+          infixText={
+            typeof arity === "number" && arity > 1 ? t`and` : undefined
+          }
+          autoFocus
+          placeholder={isEditing ? t`Enter a default value…` : undefined}
+        />
+      );
+    }
+
     return (
       <ParameterFieldWidget
         target={target}
@@ -243,7 +271,7 @@ function Widget({
         parameters={parameters}
         dashboard={dashboard}
         placeholder={placeholder}
-        value={value}
+        value={normalizedValue}
         fields={parameter.fields}
         setValue={value => {
           setValue(value);
@@ -278,6 +306,9 @@ function getWidgetDefinition(parameter) {
   if (DATE_WIDGETS[parameter.type]) {
     return DATE_WIDGETS[parameter.type];
   } else if (isOnlyMappedToFields(parameter)) {
+    if (isNumberParameter(parameter)) {
+      return NumberInputWidget;
+    }
     return ParameterFieldWidget;
   } else {
     return TextWidget;
diff --git a/frontend/src/metabase/parameters/components/widgets/NumberInputWidget/NumberInputWidget.stories.tsx b/frontend/src/metabase/parameters/components/widgets/NumberInputWidget/NumberInputWidget.stories.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b518fbdcfaa839eb638ebb4d3b2faab24c6ab28d
--- /dev/null
+++ b/frontend/src/metabase/parameters/components/widgets/NumberInputWidget/NumberInputWidget.stories.tsx
@@ -0,0 +1,48 @@
+import React from "react";
+import { ComponentStory } from "@storybook/react";
+import { useArgs } from "@storybook/client-api";
+import NumberInputWidget from "./NumberInputWidget";
+
+export default {
+  title: "Parameters/NumberInputWidget",
+  component: NumberInputWidget,
+};
+
+const Template: ComponentStory<typeof NumberInputWidget> = args => {
+  const [{ value }, updateArgs] = useArgs();
+
+  const handleSetValue = (v: number[] | undefined) => {
+    updateArgs({ value: v });
+  };
+
+  return (
+    <NumberInputWidget {...args} value={value} setValue={handleSetValue} />
+  );
+};
+
+export const Default = Template.bind({});
+Default.args = {
+  value: [1],
+};
+
+export const TwoArgs = Template.bind({});
+TwoArgs.args = {
+  value: [1, 2],
+  arity: 2,
+  infixText: "and",
+};
+
+export const ThreeArgs = Template.bind({});
+ThreeArgs.args = {
+  value: [1, 2],
+  arity: 3,
+  infixText: "foo",
+  autoFocus: true,
+};
+
+export const NArgs = Template.bind({});
+NArgs.args = {
+  value: [1, 2, 3, 4, 5, 6],
+  arity: "n",
+  autoFocus: true,
+};
diff --git a/frontend/src/metabase/parameters/components/widgets/NumberInputWidget/NumberInputWidget.tsx b/frontend/src/metabase/parameters/components/widgets/NumberInputWidget/NumberInputWidget.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..468cd39e04d93e887bb2a2b0d702271b69a76fc6
--- /dev/null
+++ b/frontend/src/metabase/parameters/components/widgets/NumberInputWidget/NumberInputWidget.tsx
@@ -0,0 +1,109 @@
+import React, { useState } from "react";
+import { t } from "ttag";
+import { times, isEqual, isNumber, isUndefined } from "lodash";
+
+import TokenField, { parseNumberValue } from "metabase/components/TokenField";
+import NumericInput from "metabase/core/components/NumericInput";
+import {
+  WidgetRoot,
+  Footer,
+  UpdateButton,
+} from "metabase/parameters/components/widgets/Widget.styled";
+
+export type NumberInputWidgetProps = {
+  value: number[] | undefined;
+  setValue: (value: number[] | undefined) => void;
+  className?: string;
+  arity?: "n" | number;
+  infixText?: string;
+  autoFocus?: boolean;
+  placeholder?: string;
+};
+
+const OPTIONS: any[] = [];
+
+function NumberInputWidget({
+  value,
+  setValue,
+  className,
+  arity = 1,
+  infixText,
+  autoFocus,
+  placeholder = t`Enter a number`,
+}: NumberInputWidgetProps) {
+  const arrayValue = normalize(value);
+  const [unsavedArrayValue, setUnsavedArrayValue] = useState<
+    (number | undefined)[]
+  >(arrayValue);
+  const hasValueChanged = !isEqual(arrayValue, unsavedArrayValue);
+  const allValuesUnset = unsavedArrayValue.every(isUndefined);
+  const allValuesSet = unsavedArrayValue.every(isNumber);
+  const isValid =
+    (arity === "n" || unsavedArrayValue.length === arity) &&
+    (allValuesUnset || allValuesSet);
+
+  const onClick = () => {
+    if (isValid) {
+      if (allValuesUnset || unsavedArrayValue.length === 0) {
+        setValue(undefined);
+      } else {
+        setValue(unsavedArrayValue);
+      }
+    }
+  };
+
+  return (
+    <WidgetRoot className={className}>
+      {arity === "n" ? (
+        <TokenField
+          multi
+          updateOnInputChange
+          autoFocus={autoFocus}
+          value={unsavedArrayValue}
+          parseFreeformValue={parseNumberValue}
+          onChange={newValue => {
+            setUnsavedArrayValue(newValue);
+          }}
+          options={OPTIONS}
+          placeholder={placeholder}
+        />
+      ) : (
+        times(arity, i => (
+          <div className="inline-block" key={i}>
+            <NumericInput
+              className="p1"
+              autoFocus={autoFocus && i === 0}
+              value={unsavedArrayValue[i]}
+              onChange={newValue => {
+                setUnsavedArrayValue(unsavedArrayValue => {
+                  const newUnsavedValue = [...unsavedArrayValue];
+                  newUnsavedValue[i] = newValue;
+                  return newUnsavedValue;
+                });
+              }}
+              placeholder={placeholder}
+            />
+            {infixText && i !== arity - 1 && (
+              <span className="px1">{infixText}</span>
+            )}
+          </div>
+        ))
+      )}
+      <Footer>
+        <UpdateButton disabled={!isValid || !hasValueChanged} onClick={onClick}>
+          {arrayValue.length ? t`Update filter` : t`Add filter`}
+        </UpdateButton>
+      </Footer>
+    </WidgetRoot>
+  );
+}
+
+export default NumberInputWidget;
+
+function normalize(value: number[] | undefined): number[] {
+  if (Array.isArray(value)) {
+    return value;
+  } else {
+    return [];
+  }
+}
diff --git a/frontend/src/metabase/parameters/components/widgets/NumberInputWidget/NumberInputWidget.unit.spec.tsx b/frontend/src/metabase/parameters/components/widgets/NumberInputWidget/NumberInputWidget.unit.spec.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..793f58ab0215d4d92a037cd7747df3df790dc825
--- /dev/null
+++ b/frontend/src/metabase/parameters/components/widgets/NumberInputWidget/NumberInputWidget.unit.spec.tsx
@@ -0,0 +1,184 @@
+import React from "react";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+
+import NumberInputWidget from "./NumberInputWidget";
+
+const mockSetValue = jest.fn();
+
+describe("NumberInputWidget", () => {
+  beforeEach(() => {
+    jest.resetAllMocks();
+  });
+
+  describe("arity of 1", () => {
+    it("should render an input populated with a value", () => {
+      render(<NumberInputWidget value={[123]} setValue={mockSetValue} />);
+
+      const textbox = screen.getByRole("textbox");
+      expect(textbox).toBeInTheDocument();
+      expect(textbox).toHaveValue("123");
+    });
+
+    it("should render an empty input", () => {
+      render(<NumberInputWidget value={undefined} setValue={mockSetValue} />);
+
+      const textbox = screen.getByRole("textbox");
+      expect(textbox).toBeInTheDocument();
+      expect(textbox).toHaveAttribute("placeholder", "Enter a number");
+    });
+
+    it("should render a disabled update button, until the value is changed", () => {
+      render(<NumberInputWidget value={[123]} setValue={mockSetValue} />);
+
+      const button = screen.getByRole("button", { name: "Update filter" });
+      expect(button).toBeInTheDocument();
+      expect(button).toHaveAttribute("disabled");
+
+      userEvent.type(screen.getByRole("textbox"), "456");
+      expect(button).not.toHaveAttribute("disabled");
+    });
+
+    it("should let you update the input with a new value", () => {
+      render(<NumberInputWidget value={[123]} setValue={mockSetValue} />);
+
+      const textbox = screen.getByRole("textbox");
+      userEvent.clear(textbox);
+      userEvent.type(textbox, "456");
+      const button = screen.getByRole("button", { name: "Update filter" });
+      userEvent.click(button);
+      expect(mockSetValue).toHaveBeenCalledWith([456]);
+    });
+
+    it("should let you update the input with an undefined value", () => {
+      render(<NumberInputWidget value={[1]} setValue={mockSetValue} />);
+
+      const textbox = screen.getByRole("textbox");
+      const button = screen.getByRole("button", { name: "Update filter" });
+      userEvent.type(textbox, "{backspace}");
+      userEvent.click(button);
+      expect(mockSetValue).toHaveBeenCalledWith(undefined);
+    });
+  });
+
+  describe("arity of 2", () => {
+    it("should render an input populated with a value", () => {
+      render(
+        <NumberInputWidget
+          arity={2}
+          value={[123, 456]}
+          setValue={mockSetValue}
+        />,
+      );
+
+      const [textbox1, textbox2] = screen.getAllByRole("textbox");
+      expect(textbox1).toBeInTheDocument();
+      expect(textbox1).toHaveValue("123");
+
+      expect(textbox2).toBeInTheDocument();
+      expect(textbox2).toHaveValue("456");
+    });
+
+    it("should be invalid when one of the inputs is empty", () => {
+      render(
+        <NumberInputWidget
+          arity={2}
+          value={[123, 456]}
+          setValue={mockSetValue}
+        />,
+      );
+
+      const [textbox1] = screen.getAllByRole("textbox");
+      userEvent.clear(textbox1);
+      const button = screen.getByRole("button", { name: "Update filter" });
+      expect(button).toHaveAttribute("disabled");
+    });
+
+    it("should be settable", () => {
+      render(
+        <NumberInputWidget
+          arity={2}
+          value={undefined}
+          setValue={mockSetValue}
+        />,
+      );
+
+      const [textbox1, textbox2] = screen.getAllByRole("textbox");
+      userEvent.type(textbox1, "1");
+      userEvent.type(textbox2, "2");
+
+      const button = screen.getByRole("button", { name: "Add filter" });
+      userEvent.click(button);
+
+      expect(mockSetValue).toHaveBeenCalledWith([1, 2]);
+    });
+
+    it("should be clearable by emptying all inputs", () => {
+      render(
+        <NumberInputWidget
+          arity={2}
+          value={[123, 456]}
+          setValue={mockSetValue}
+        />,
+      );
+
+      const [textbox1, textbox2] = screen.getAllByRole("textbox");
+      userEvent.clear(textbox1);
+      userEvent.clear(textbox2);
+
+      const button = screen.getByRole("button", { name: "Update filter" });
+      userEvent.click(button);
+
+      expect(mockSetValue).toHaveBeenCalledWith(undefined);
+    });
+  });
+
+  describe("arity of n", () => {
+    it("should render a token field input", () => {
+      render(
+        <NumberInputWidget
+          arity="n"
+          value={[1, 2, 3, 4]}
+          setValue={mockSetValue}
+        />,
+      );
+
+      const values = screen.getAllByRole("list")[0];
+      expect(values.textContent).toEqual("1234");
+    });
+
+    it("should correctly parse number inputs", () => {
+      render(
+        <NumberInputWidget
+          arity="n"
+          value={undefined}
+          setValue={mockSetValue}
+        />,
+      );
+
+      const input = screen.getByRole("textbox");
+      userEvent.type(input, "foo{enter}123abc{enter}456{enter}");
+
+      const values = screen.getAllByRole("list")[0];
+      expect(values.textContent).toEqual("123456");
+
+      const button = screen.getByRole("button", { name: "Add filter" });
+      userEvent.click(button);
+      expect(mockSetValue).toHaveBeenCalledWith([123, 456]);
+    });
+
+    it("should be unsettable", () => {
+      render(
+        <NumberInputWidget arity="n" value={[1, 2]} setValue={mockSetValue} />,
+      );
+
+      const input = screen.getByRole("textbox");
+      userEvent.type(input, "{backspace}{backspace}");
+
+      const button = screen.getByRole("button", { name: "Update filter" });
+
+      userEvent.click(button);
+      expect(mockSetValue).toHaveBeenCalledWith(undefined);
+    });
+  });
+});
diff --git a/frontend/src/metabase/parameters/components/widgets/NumberInputWidget/index.ts b/frontend/src/metabase/parameters/components/widgets/NumberInputWidget/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..087e5e0a76ccc931b7f58ae3ba5ca2f8d3ebce1b
--- /dev/null
+++ b/frontend/src/metabase/parameters/components/widgets/NumberInputWidget/index.ts
@@ -0,0 +1 @@
+export { default } from "./NumberInputWidget";
diff --git a/frontend/src/metabase/parameters/utils/operators.js b/frontend/src/metabase/parameters/utils/operators.js
index 3a4b4a580cddd0b60f7f8b28654671a672160968..1d2d239f169a2cffb069ae627d18cecaae1968ac 100644
--- a/frontend/src/metabase/parameters/utils/operators.js
+++ b/frontend/src/metabase/parameters/utils/operators.js
@@ -64,3 +64,15 @@ export function buildTypedOperatorOptions(
     };
   });
 }
+
+export function getNumberParameterArity(parameter) {
+  switch (parameter.type) {
+    case "number/=":
+    case "number/!=":
+      return "n";
+    case "number/between":
+      return 2;
+    default:
+      return 1;
+  }
+}
diff --git a/frontend/src/metabase/parameters/utils/parameter-type.ts b/frontend/src/metabase/parameters/utils/parameter-type.ts
index 4da9d9a7b0322968c56d63de85028858bf247706..5100ee8812994c00d00617c671762d17cc8b66b3 100644
--- a/frontend/src/metabase/parameters/utils/parameter-type.ts
+++ b/frontend/src/metabase/parameters/utils/parameter-type.ts
@@ -28,6 +28,11 @@ export function isDateParameter(parameter: Parameter | string) {
   return type === "date";
 }
 
+export function isNumberParameter(parameter: Parameter) {
+  const type = getParameterType(parameter);
+  return type === "number";
+}
+
 export function isFieldFilterParameter(
   parameter: Parameter,
 ): parameter is FieldFilterUiParameter {