diff --git a/packages/dashboard-v2/src/components/Slider/Bullets.js b/packages/dashboard-v2/src/components/Slider/Bullets.js
new file mode 100644
index 00000000..ab33f64a
--- /dev/null
+++ b/packages/dashboard-v2/src/components/Slider/Bullets.js
@@ -0,0 +1,28 @@
+import PropTypes from "prop-types";
+
+export default function Bullets({ visibleSlides, activeIndex, allSlides, changeSlide }) {
+ if (allSlides <= visibleSlides) {
+ return null;
+ }
+
+ return (
+
+ {Array(allSlides - visibleSlides + 1)
+ .fill(null)
+ .map((_, i) => (
+ changeSlide(i)}
+ />
+ ))}
+
+ );
+}
+
+Bullets.propTypes = {
+ allSlides: PropTypes.number.isRequired,
+ activeIndex: PropTypes.number.isRequired,
+ visibleSlides: PropTypes.number.isRequired,
+ changeSlide: PropTypes.func.isRequired,
+};
diff --git a/packages/dashboard-v2/src/components/Slider/Slide.js b/packages/dashboard-v2/src/components/Slider/Slide.js
new file mode 100644
index 00000000..4f700502
--- /dev/null
+++ b/packages/dashboard-v2/src/components/Slider/Slide.js
@@ -0,0 +1,12 @@
+import styled from "styled-components";
+import PropTypes from "prop-types";
+
+const Slide = styled.div.attrs(({ isVisible }) => ({
+ className: `slider-slide transition-opacity ${isVisible ? "" : "opacity-50 cursor-pointer"}`,
+}))``;
+
+Slide.propTypes = {
+ isVisible: PropTypes.bool.isRequired,
+};
+
+export default Slide;
diff --git a/packages/dashboard-v2/src/components/Slider/Slider.js b/packages/dashboard-v2/src/components/Slider/Slider.js
new file mode 100644
index 00000000..bb4654b2
--- /dev/null
+++ b/packages/dashboard-v2/src/components/Slider/Slider.js
@@ -0,0 +1,105 @@
+import * as React from "react";
+import PropTypes from "prop-types";
+import styled, { css } from "styled-components";
+import theme from "../../lib/theme";
+
+import useActiveBreakpoint from "./useActiveBreakpoint";
+import Bullets from "./Bullets";
+import Slide from "./Slide";
+
+const Container = styled.div.attrs({
+ className: "slider w-full",
+})``;
+
+/**
+ * Styles applied to the movable element when the number of slide elements
+ * exceeds the number of visible slides for the current breakpoint
+ * */
+const scrollableStyles = css`
+ ${({ $allSlides, $visibleSlides, $activeIndex }) => `
+ transform: translateX(calc(-1 * ${$activeIndex} * ((100% + 1rem) / ${$visibleSlides})));
+ grid-template-columns: repeat(${$allSlides}, calc((100% - ${$visibleSlides - 1}rem) / ${$visibleSlides}));
+ `}
+`;
+
+const Scroller = styled.div.attrs({
+ className: "slider-scroller grid gap-4 transition-transform",
+})`
+ ${({ $scrollable }) => ($scrollable ? scrollableStyles : "")}
+`;
+
+const Slider = ({ slides, breakpoints }) => {
+ const { visibleSlides, scrollable } = useActiveBreakpoint(breakpoints);
+ const [activeIndex, setActiveIndex] = React.useState(0);
+ const changeSlide = React.useCallback(
+ (index) => {
+ setActiveIndex(Math.min(index, slides.length - visibleSlides)); // Don't let it scroll too far
+ },
+ [slides, visibleSlides, setActiveIndex]
+ );
+
+ return (
+
+
+ {slides.map((slide, i) => {
+ const isVisible = i >= activeIndex && i < activeIndex + visibleSlides;
+
+ return (
+
+ changeSlide(i) : null}
+ >
+ {slide}
+
+
+ );
+ })}
+
+
+
+ );
+};
+
+Slider.propTypes = {
+ slides: PropTypes.arrayOf(PropTypes.node.isRequired),
+ breakpoints: PropTypes.arrayOf(
+ PropTypes.shape({
+ minWidth: PropTypes.number.isRequired,
+ visibleSlides: PropTypes.number.isRequired,
+ })
+ ),
+};
+
+Slider.defaultProps = {
+ breakpoints: [
+ {
+ minWidth: parseInt(theme.screens.xl),
+ scrollable: true,
+ visibleSlides: 3,
+ },
+ {
+ minWidth: parseInt(theme.screens.md, 10),
+ scrollable: true,
+ visibleSlides: 2,
+ },
+ {
+ // For the smallest screens, we won't scroll but instead stack the slides vertically.
+ minWidth: -Infinity,
+ scrollable: false,
+ visibleSlides: 1,
+ },
+ ],
+};
+
+export default Slider;
diff --git a/packages/dashboard-v2/src/components/Slider/index.js b/packages/dashboard-v2/src/components/Slider/index.js
new file mode 100644
index 00000000..a5890919
--- /dev/null
+++ b/packages/dashboard-v2/src/components/Slider/index.js
@@ -0,0 +1 @@
+export * from "./Slider";
diff --git a/packages/dashboard-v2/src/components/Slider/useActiveBreakpoint.js b/packages/dashboard-v2/src/components/Slider/useActiveBreakpoint.js
new file mode 100644
index 00000000..e35e1f19
--- /dev/null
+++ b/packages/dashboard-v2/src/components/Slider/useActiveBreakpoint.js
@@ -0,0 +1,23 @@
+import { useEffect, useMemo, useCallback, useState } from "react";
+import { useWindowSize } from "react-use";
+
+export default function useActiveBreakpoint(breakpoints) {
+ const { width: windowWidth } = useWindowSize();
+ // Since our breakpoints are setup with min-width rule, we need to sort them from largest to smallest
+ const monitoredBreakpoints = useMemo(
+ () => breakpoints.slice().sort(({ minWidth: widthA }, { minWidth: widthB }) => widthB - widthA),
+ [breakpoints]
+ );
+ const findActiveBreakpoint = useCallback(
+ () => monitoredBreakpoints.find((breakpoint) => windowWidth >= breakpoint.minWidth),
+ [monitoredBreakpoints, windowWidth]
+ );
+
+ const [activeBreakpoint, setActiveBreakpoint] = useState(findActiveBreakpoint());
+
+ useEffect(() => {
+ setActiveBreakpoint(findActiveBreakpoint());
+ }, [findActiveBreakpoint]);
+
+ return activeBreakpoint;
+}