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
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.
Top comments (0)