DEV Community

rahul patwa
rahul patwa

Posted on

Creating a Synchronized Vertical and Horizontal Scrolling Component for Web Apps

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

Demo 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
Enter fullscreen mode Exit fullscreen mode

Next, install Tailwind CSS (optional).

npm install -D tailwindcss npx tailwindcss init
Enter fullscreen mode Exit fullscreen mode

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: [], 
};
Enter fullscreen mode Exit fullscreen mode

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

@tailwind base;
@tailwind components;
@tailwind utilities;
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)