diff --git a/frontend/src/metabase/core/components/Slider/Slider.stories.tsx b/frontend/src/metabase/core/components/Slider/Slider.stories.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..255d0dde4c0e80ac0891f70a421ecc89810035dc
--- /dev/null
+++ b/frontend/src/metabase/core/components/Slider/Slider.stories.tsx
@@ -0,0 +1,15 @@
+import React, { useState } from "react";
+import { ComponentStory } from "@storybook/react";
+import Slider from "./Slider";
+
+export default {
+  title: "Core/Slider",
+  component: Slider,
+};
+
+const Template: ComponentStory<typeof Slider> = args => {
+  const [value, setValue] = useState([10, 40]);
+  return <Slider {...args} value={value} onChange={setValue} />;
+};
+
+export const Default = Template.bind({});
diff --git a/frontend/src/metabase/core/components/Slider/Slider.styled.tsx b/frontend/src/metabase/core/components/Slider/Slider.styled.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..604f82a49ac58104fb2a4ba5435dc7689a6e30ef
--- /dev/null
+++ b/frontend/src/metabase/core/components/Slider/Slider.styled.tsx
@@ -0,0 +1,59 @@
+import styled from "@emotion/styled";
+import { color, alpha } from "metabase/lib/colors";
+
+export const SliderContainer = styled.div`
+  position: relative;
+  display: flex;
+`;
+
+const thumbStyles = `
+  -webkit-appearance: none;
+  width: 1.2rem;
+  height: 1.2rem;
+  border-radius: 50%;
+  border: 2px solid ${color("brand")};
+  background-color: ${color("white")};
+  cursor: pointer;
+  box-shadow: 0 0 2px 1px ${color("brand")};
+  pointer-events: all;
+  &:active {
+    box-shadow: 0 0 4px 1px ${color("brand")};
+  }
+`;
+
+export const SliderInput = styled.input`
+  -webkit-appearance: none;
+  position: absolute;
+  width: 100%;
+  height: 0;
+  border: none;
+  outline: none;
+  background: none;
+  pointer-events: none;
+  &::-webkit-slider-thumb {
+    ${thumbStyles}
+  }
+  &::-moz-range-thumb {
+    ${thumbStyles}
+  }
+`;
+
+export const SliderTrack = styled.span`
+  width: 100%;
+  background-color: ${alpha("brand", 0.5)};
+  height: 0.2rem;
+  border-radius: 0.2rem;
+`;
+
+interface ActiveTrackProps {
+  left: number;
+  width: number;
+}
+
+export const ActiveTrack = styled.span<ActiveTrackProps>`
+  position: absolute;
+  left: ${props => props.left}%;
+  width: ${props => props.width}%;
+  background-color: ${color("brand")};
+  height: 0.2rem;
+`;
diff --git a/frontend/src/metabase/core/components/Slider/Slider.tsx b/frontend/src/metabase/core/components/Slider/Slider.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..cd779f25e93f6963a3fc318fd0da17c26ec635a5
--- /dev/null
+++ b/frontend/src/metabase/core/components/Slider/Slider.tsx
@@ -0,0 +1,93 @@
+import React, {
+  ChangeEvent,
+  InputHTMLAttributes,
+  useCallback,
+  useMemo,
+} from "react";
+
+import {
+  SliderContainer,
+  SliderInput,
+  SliderTrack,
+  ActiveTrack,
+} from "./Slider.styled";
+
+export type NumericInputAttributes = Omit<
+  InputHTMLAttributes<HTMLDivElement>,
+  "value" | "size" | "onChange"
+>;
+
+export interface SliderProps extends NumericInputAttributes {
+  value: number[];
+  onChange: (value: number[]) => void;
+  min?: number;
+  max?: number;
+  step?: number;
+}
+
+const Slider = ({
+  value,
+  onChange,
+  min = 0,
+  max = 100,
+  step = 1,
+}: SliderProps) => {
+  const [rangeMin, rangeMax] = useMemo(
+    () => [Math.min(...value, min, max), Math.max(...value, min, max)],
+    [value, min, max],
+  );
+
+  const [beforeRange, rangeWidth] = useMemo(() => {
+    const totalRange = rangeMax - rangeMin;
+
+    return [
+      ((Math.min(...value) - rangeMin) / totalRange) * 100,
+      (Math.abs(value[1] - value[0]) / totalRange) * 100,
+    ];
+  }, [value, rangeMin, rangeMax]);
+
+  const handleChange = useCallback(
+    (event: ChangeEvent<HTMLInputElement>, valueIndex: number) => {
+      const changedValue = [...value];
+      changedValue[valueIndex] = Number(event.target.value);
+      onChange(changedValue);
+    },
+    [value, onChange],
+  );
+
+  const sortValues = useCallback(() => {
+    if (value[0] > value[1]) {
+      const sortedValues = [...value].sort();
+      onChange(sortedValues);
+    }
+  }, [value, onChange]);
+
+  return (
+    <SliderContainer>
+      <SliderTrack />
+      <ActiveTrack left={beforeRange} width={rangeWidth} />
+      <SliderInput
+        type="range"
+        aria-label="min"
+        value={value[0]}
+        onChange={e => handleChange(e, 0)}
+        onMouseUp={sortValues}
+        min={rangeMin}
+        max={rangeMax}
+        step={step}
+      />
+      <SliderInput
+        type="range"
+        aria-label="max"
+        value={value[1]}
+        onChange={e => handleChange(e, 1)}
+        onMouseUp={sortValues}
+        min={rangeMin}
+        max={rangeMax}
+        step={step}
+      />
+    </SliderContainer>
+  );
+};
+
+export default Slider;
diff --git a/frontend/src/metabase/core/components/Slider/Slider.unit.spec.tsx b/frontend/src/metabase/core/components/Slider/Slider.unit.spec.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..4852a25a39c931b6450fd5af90a0f13f2b0679d1
--- /dev/null
+++ b/frontend/src/metabase/core/components/Slider/Slider.unit.spec.tsx
@@ -0,0 +1,52 @@
+import React from "react";
+import { render, fireEvent, screen } from "@testing-library/react";
+import Slider from "./Slider";
+
+describe("Slider", () => {
+  it("should render 2 range inputs", () => {
+    render(<Slider value={[10, 40]} onChange={() => null} min={0} max={100} />);
+
+    const minInput = screen.getByLabelText("min");
+    const maxInput = screen.getByLabelText("max");
+
+    expect(minInput).toHaveAttribute("type", "range");
+    expect(maxInput).toHaveAttribute("type", "range");
+  });
+
+  it("should always have values in range", () => {
+    render(
+      <Slider value={[10, 412]} onChange={() => null} min={0} max={100} />,
+    );
+
+    const minInput = screen.getByLabelText("min");
+
+    expect(minInput).toHaveAttribute("max", "412");
+  });
+
+  it("should call onChange with the new value on input change", () => {
+    const spy = jest.fn();
+    render(<Slider value={[10, 20]} onChange={spy} min={0} max={100} />);
+
+    const minInput = screen.getByLabelText("min");
+    const maxInput = screen.getByLabelText("max");
+
+    // would be nice to use userEvent when we upgrade to v14 so we mock drage events
+    fireEvent.change(minInput, { target: { value: "5" } });
+    fireEvent.change(maxInput, { target: { value: "15" } });
+
+    const [firstCall, secondCall] = spy.mock.calls;
+
+    expect(firstCall[0]).toEqual([5, 20]);
+    expect(secondCall[0]).toEqual([10, 15]);
+  });
+
+  it("should sort input values on mouse up", () => {
+    const spy = jest.fn();
+    render(<Slider value={[2, 1]} onChange={spy} min={0} max={100} />);
+
+    const minInput = screen.getByLabelText("min");
+
+    fireEvent.mouseUp(minInput);
+    expect(spy.mock.calls[0][0]).toEqual([1, 2]);
+  });
+});
diff --git a/frontend/src/metabase/core/components/Slider/index.ts b/frontend/src/metabase/core/components/Slider/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..8e1091006b314d7fbb210c1e61e57f265e32dac1
--- /dev/null
+++ b/frontend/src/metabase/core/components/Slider/index.ts
@@ -0,0 +1 @@
+export { default } from "./Slider";