Creating a Synchronized Vertical and Horizontal Scrolling Component for Web Apps
Published: 2024-06-30 11:56:30

#### Introduction
Microsoft Teams' mobile agenda page offers a sleek and intuitive interface with synchronized vertical and horizontal scrolling. This design allows users to scroll through dates horizontally and see the corresponding events in a vertical list. Inspired by this elegant solution, I decided to create a similar component using modern web technologies. While there are many libraries and blogs about synchronized scrolling, they typically handle scrolling in the same direction. This article will show you how to achieve synchronized scrolling in both vertical and horizontal directions.

You can also checkout the [live demo](https://qwyt5x.csb.app/)

![Demo gif](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/rtxkjgbsw7ikcuhakbqx.gif)

#### Prerequisites
Before diving in, you should have a basic understanding of React, JavaScript, and Tailwind CSS. Make sure you have Node.js and npm installed on your machine.
##### Setting Up the Project
First, create a new React project using Create React App or your preferred method.

```
npm create vite@latest my-sync-scroll-app -- --template react
cd my-sync-scroll-app 
npm install
```

Next, install Tailwind CSS (optional).

```
npm install -D tailwindcss npx tailwindcss init
```

Configure Tailwind CSS by adding the following content to your `tailwind.config.js` file:

```
module.exports = { 
	purge: ['./src/**/*.{js,jsx,ts,tsx}', './public/index.html'], 
	darkMode: false, 
	theme: { 
		extend: {}, 
	}, 
	variants: { 
		extend: {}, 
	}, 
	plugins: [], 
};
```

Add the Tailwind directives to your CSS file (`src/index.css`):

```
@tailwind base;
@tailwind components;
@tailwind utilities;
```

#### Utility Function for Date Generation
Let's create a utility function to generate a list of dates starting from a given date.

```
export const generateDates = (startDate, days) => {
  const dates = [];
  for (let i = 0; i < days; i++) {
    const date = new Date(startDate);
    date.setDate(startDate.getDate() + i);
    dates.push(date.toISOString().split("T")[0]); // Format date as YYYY-MM-DD
  }
  return dates;
};
```

#### Creating the Horizontal Scroll Component
Let's start by creating the `HorizontalScroll` component. This component will allow users to scroll through dates horizontally and select a date.

```
import React, { useEffect, useRef } from "react";

const HorizontalScroll = ({
  dates,
  selectedDate,
  setSelectedDate,
  setSelectFromHorizontal,
}) => {
  const containerRef = useRef();

  useEffect(() => {
    // Automatically scroll to the selected date and center it in the view
    const selectedElement = containerRef.current.querySelector(`.date-item.selected`);
    if (selectedElement) {
      const containerWidth = containerRef.current.offsetWidth;
      const elementWidth = selectedElement.offsetWidth;
      const elementOffsetLeft = selectedElement.offsetLeft;
      const scrollTo = elementOffsetLeft - containerWidth / 2 + elementWidth / 2;
      containerRef.current.scrollTo({
        left: scrollTo,
        behavior: "smooth",
      });
    }
  }, [selectedDate]);

  const handleDateSelection = (index) => {
    setSelectedDate(dates[index]);
    setSelectFromHorizontal(true);
  };

  const onWheel = (e) => {
    const element = containerRef.current;
    if (element) {
      if (e.deltaY === 0) return;
      element.scrollTo({
        left: element.scrollLeft + e.deltaY,
      });
    }
  };

  return (
    <div className="w-full flex flex-row-reverse items-center gap-2 bg-gray-500 rounded-md horizontal">
      <div
        className="horizontal-scroll flex overflow-x-auto whitespace-nowrap scroll-smooth rounded-md"
        ref={containerRef}
        onWheel={onWheel}
      >
        {dates.map((date, index) => {
          const day = new Date(date).toLocaleString([], { month: "short" });
          const d = new Date(date).toLocaleString([], { day: "2-digit" });
          return (
            <div
              key={date}
              className={`date-item ${selectedDate === date ? "selected" : ""} flex flex-col items-center p-4`}
              onClick={() => handleDateSelection(index)}
              style={{
                backgroundColor: selectedDate === date ? "#90cdf4" : "#f7fafc",
                borderRadius: selectedDate === date ? "4px" : "0px",
              }}
            >
              <p className={`text-sm ${selectedDate === date ? "text-blue-600" : "text-gray-500"} font-light`}>
                {day}
              </p>
              <p className={`text-base font-semibold ${selectedDate === date ? "text-blue-700" : "text-gray-700"}`}>
                {d}
              </p>
            </div>
          );
        })}
      </div>
    </div>
  );
};

export default HorizontalScroll;
```
#### Creating the Vertical Scroll Component
Next, create the `VerticalScroll` component to display the events for the selected date. This component will synchronize with the `HorizontalScroll` component to update the displayed events when a date is selected.
```
import React, { useEffect, useRef, useState } from "react";

const VerticalScroll = ({
  dates,
  onDateChange,
  selectedDate,
  selectFromHorizontal,
  setSelectFromHorizontal,
}) => {
  const containerRef = useRef();
  const [visibleDates, setVisibleDates] = useState([]);
  const [isProgrammaticScroll, setIsProgrammaticScroll] = useState(false);

  useEffect(() => {
    const container = containerRef.current;
    const handleScroll = () => {
      if (isProgrammaticScroll) {
        setIsProgrammaticScroll(false);
        return;
      }
      if (!selectFromHorizontal) {
        // Calculate the date at the top of the vertical scroll
        const topDateIndex = Math.floor(container.scrollTop / 100);
        const topDate = dates[topDateIndex];
        onDateChange(topDate);
      }
      // Calculate the visible dates based on the current scroll position
      const start = Math.floor(container.scrollTop / 100);
      const end = start + Math.ceil(container.clientHeight / 100);
      const visible = dates.slice(start, end);
      setVisibleDates(visible);
    };

    container.addEventListener("scroll", handleScroll);

    return () => container.removeEventListener("scroll", handleScroll);
  }, [dates, isProgrammaticScroll, onDateChange]);

  useEffect(() => {
    setTimeout(() => setSelectFromHorizontal(false), 1000);
  }, [selectedDate]);

  useEffect(() => {
    const selectedIndex = dates.indexOf(selectedDate);
    if (selectedIndex !== -1) {
      // Scroll to the selected date in the vertical scroll
      const scrollTo = selectedIndex * 100;
      setIsProgrammaticScroll(true);
      containerRef.current.scrollTo({
        top: scrollTo,
        behavior: "smooth",
      });
    }
  }, [selectedDate, dates]);

  return (
    <div className="h-full overflow-y-auto" ref={containerRef}>
      {dates.map((date) => (
        <div key={date} className="my-4 h-24">
          <div className="relative flex items-center mb-2">
            <div className="flex-grow border-t border-gray-300"></div>
            <span className="flex-shrink mx-4 text-gray-500">
              {new Date(date).toLocaleString([], { month: "short", day: "2-digit", weekday: "short" })}
            </span>
            <div className="flex-grow border-t border-gray-300"></div>
          </div>
          {visibleDates.includes(date) ? (
            <DateContent date={date} />
          ) : (
            <p>No events</p>
          )}
        </div>
      ))}
    </div>
  );
};

const DateContent = ({ date }) => {
  const [data, setData] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const fetchData = async () => {
      const selectDate = new Date(date);

      selectDate.setHours(6, 0, 0, 0);
      const epochStartTimestamp = Math.floor(selectDate.getTime() / 1000);

      selectDate.setDate(selectDate.getDate() + 3);
      selectDate.setHours(23, 59, 59, 999);
      const epochEndTimestamp = Math.floor(selectDate.getTime() / 1000);

      const queryParams = `?start_timestamp=${epochStartTimestamp}&end_timestamp=${epochEndTimestamp}`;

      try {
        const response = await fetch(`https://example.com/api/upcomingShifts${queryParams}`);
        if (response.status === 200) {
          const result = await response.json();
          setLoading(false);
          setData((prevData) => [...prevData, ...result.upcomingShifts]);
        }
      } catch (error) {
        console.error("Error fetching data:", error);
      }
    };

    fetchData();
  }, [date]);

  if (!data) return <p>Loading...</p>;

  return (
    <div>
      {loading ? (
        <div className="animate-pulse h-6 bg-gray-300 rounded"></div>
      ) : (
        data.map((d) => (
          <div key={d.id} className="my-2">
            <p>{d.id}</p>
          </div>
        ))
      )}
    </div>
  );
};

export default VerticalScroll;
```

#### Bringing It All Together
Now, let's integrate these components in the main `App` component.

```
import React, { useState } from "react";
import HorizontalScroll from "./components/HorizontalScroll";
import VerticalScroll from "./components/VerticalScroll";

const App = () => {
	const dates = generateDates(new Date(), 90);
	const [selectedDate, setSelectedDate] = useState(dates[0]);
	const [selectFromHorizontal, setSelectFromHorizontal] = useState(false);

  // Function to handle date changes from the vertical scroll component
  const handleDateChange = (date) => {
    if (!selectFromHorizontal) {
      setSelectedDate(date);
    }
  };

  return (
    <div className="flex flex-col h-screen p-4">
      <HorizontalScroll
        dates={dates}
        selectedDate={selectedDate}
        setSelectedDate={setSelectedDate}
        setSelectFromHorizontal={setSelectFromHorizontal}
      />
      <VerticalScroll
        dates={dates}
        selectedDate={selectedDate}
        onDateChange={handleDateChange}
        selectFromHorizontal={selectFromHorizontal}
        setSelectFromHorizontal={setSelectFromHorizontal}
      />
    </div>
  );
};

export default App;
```
### Conclusion

By following this guide, you can create a synchronized vertical and horizontal scrolling component for your web application. This design pattern, inspired by Microsoft Teams' mobile agenda page, enhances the user experience by providing an intuitive and efficient way to navigate through dates and events. Experiment with the components, adjust the styles, and integrate them into your projects to meet your specific needs. Happy coding!

### Live Demo

For a live demonstration of the synchronized vertical and horizontal scrolling component, you can explore the demo on CodeSandbox. This interactive sandbox allows you to see the code in action and experiment with the functionality described in this blog.