Skip to content

Instantly share code, notes, and snippets.

@bradleyhodges
Last active August 16, 2024 06:23
Show Gist options
  • Save bradleyhodges/acc738098a98f47183178c5a4d7d6a1b to your computer and use it in GitHub Desktop.
Save bradleyhodges/acc738098a98f47183178c5a4d7d6a1b to your computer and use it in GitHub Desktop.
Smooth Scroll Element with sticky element handling in React (Next.js), using `idiotWu/smooth-scrollbar`
// SmoothScrollContainer.tsx
'use client';
import { ElementType, forwardRef, useEffect, useRef, ReactNode, ComponentPropsWithoutRef } from 'react';
import Scrollbar from 'smooth-scrollbar';
// Use this to disable `smooth-scrollbar` for mobile devices
const disableForMobileClients = true;
// Define the props for the SmoothScrollContainer component
interface SmoothScrollContainerProps<T extends ElementType> extends ComponentPropsWithoutRef<T> {
as?: T;
maxHeight?: string;
children: ReactNode;
}
/**
* A container component that applies smooth scrolling to its content.
*
* This component uses the Smooth Scrollbar library to create a smooth scrolling experience
* for its children. It also supports sticky headers within the container.
*
* @param {ElementType} as The element type to render as (default: 'div')
* @param {string} maxHeight The maximum height of the container
* @param {ReactNode} children The content to render within the container
* @param {CSSProperties} style Additional styles to apply to the container
* @returns {JSX.Element} A container component with smooth scrolling
*
* @example <SmoothScrollContainer as="div" maxHeight="27rem" className="max-h-[27rem] overflow-y-auto">Some content here that is very long ...</SmoothScrollContainer>
*/
const SmoothScrollContainer = forwardRef(
<T extends ElementType = 'div'>(
{ as: Component = 'div', maxHeight, children, style, ...props }: SmoothScrollContainerProps<T>,
ref
) => {
// References to the container and scroll content elements
const containerRef = useRef<HTMLDivElement>(null);
const absoluteRef = useRef<HTMLDivElement>(null);
const combinedRef = ref || containerRef;
// Store references to sticky headers and their calculated positions
const headers = useRef<HTMLElement[]>([]);
const headerPositions = useRef<number[]>([]); // Store the top positions of the headers
// Initialize the smooth-scrollbar instance when the component mounts
useEffect(() => {
// Check if the containerRef is available
if (containerRef.current &&
// Check if the device is not a mobile device or smooth scrolling is enabled
((disableForMobileClients && !isMobile()) || !disableForMobileClients)
) {
// Initialize smooth-scrollbar with a damping factor for smoothness
const scrollbar = Scrollbar.init(containerRef.current, {
damping: 0.07 // Adjust the damping factor for scroll smoothness
});
// Get all sticky elements within the container
const stickyElements = containerRef.current.querySelectorAll('.sticky');
// Skip sticky logic if no sticky elements exist
if (stickyElements.length > 0) {
// Collect all sticky headers
headers.current = Array.from(stickyElements) as HTMLElement[];
// Iterate over each sticky element to create placeholders and apply transformations
stickyElements.forEach((element, index) => {
const sticky = element as HTMLElement;
// Check if the next sibling is already a placeholder to avoid duplication
const nextSibling = sticky.nextElementSibling;
if (nextSibling && nextSibling.classList.contains('sticky-placeholder')) {
return; // Placeholder already exists, skip
}
// Create a placeholder for the sticky element to avoid layout shifts
const placeholder = document.createElement('div');
placeholder.style.height = `${sticky.offsetHeight}px`; // Match the height of the sticky element
placeholder.style.visibility = 'hidden'; // Make the placeholder invisible
placeholder.classList.add('sticky-placeholder'); // Add a class for identification
// Insert the placeholder right after the sticky element
sticky.parentElement?.insertBefore(placeholder, sticky.nextSibling);
// Wait for the next frame to ensure the DOM has updated
requestAnimationFrame(() => {
// Get the position of the placeholder relative to the viewport
const placeholderPosition = placeholder.getBoundingClientRect();
// Get the offsetTop of the placeholder relative to the container
const placeholderOffsetTop = placeholder.offsetTop;
// Get the current scroll position of the container
const containerScrollTop = absoluteRef.current!.scrollTop;
// Calculate the top position of the placeholder relative to the container's content area
const placeholderTopRelativeToContainer = (placeholderOffsetTop - placeholderPosition.height) - containerScrollTop;
// Apply the translate3d transformation to the sticky element based on the adjusted position
sticky.style.transform = `translate3d(0, ${placeholderTopRelativeToContainer}px, 0)`;
sticky.style.zIndex = '1000'; // Ensure sticky elements stay above other content
sticky.style.position = 'absolute'; // Set absolute positioning within the container
sticky.style.width = '100%'; // Maintain the full width of the element
// Store the position of the placeholder in headerPositions
if (!headerPositions.current) {
headerPositions.current = [];
}
headerPositions.current[index] = placeholderTopRelativeToContainer;
});
});
// Scroll handling logic to make headers sticky during scrolling
const handleSticky = ({ offset }) => {
const scrollPosition = offset.y;
// Iterate over each sticky header to apply transformations
headers.current.forEach((header, index) => {
// Get the next header position (or Infinity if it doesn't exist)
const nextHeaderPosition = headerPositions.current[index + 1] || Infinity;
const currentHeaderPosition = headerPositions.current[index];
// Make the header sticky if the scroll position is within its range
if (scrollPosition >= currentHeaderPosition && scrollPosition < nextHeaderPosition) {
header.style.transform = `translate3d(0, ${offset.y}px, 0)`;
header.style.zIndex = '1000';
header.style.position = 'absolute';
header.style.top = '0px';
header.style.width = '100%';
}
});
};
// Attach the scroll listener to the scrollbar instance
scrollbar.addListener(handleSticky);
// Perform an initial check to apply the correct transformations based on the initial scroll position
handleSticky({ offset: { x: 0, y: 0 } });
}
// Cleanup function to destroy the scrollbar instance when the component unmounts
return () => {
scrollbar.destroy();
};
}
}, []);
// Ensure that Component is either an intrinsic element (string) or a valid React component
const Tag = Component as ElementType;
// Determine the styles to apply
let applyStyles = {};
if ((disableForMobileClients && !isMobile()) || !disableForMobileClients) {
// Apply styles for the container (the device is either not a mobile device or smooth scrolling is enabled)
applyStyles = {
maxHeight: maxHeight || 'none', // Apply maxHeight if provided
overflow: 'hidden', // Smooth Scrollbar takes over scrolling
position: 'relative', // Ensure relative positioning for internal elements
...style, // Allow custom styles to be merged
};
}
return (
<Tag
{...props}
ref={combinedRef}
style={applyStyles}
>
{/* Scroll content container with ref for absolute positioning */}
<div className="scroll-content relative" ref={absoluteRef}>
{children}
</div>
</Tag>
);
}
);
/**
* Check if the current device is a mobile device.
*
* @returns {boolean} A boolean value indicating if the current device is a mobile device
*/
const isMobile = (): boolean => {
// Check user agent for mobile devices
const userAgent = typeof navigator === 'undefined' ? '' : navigator.userAgent;
const mobileRegex = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i;
// Check for touch capabilities
const isTouchDevice =
typeof window !== 'undefined' && ('ontouchstart' in window || navigator.maxTouchPoints > 0);
// Additional check based on screen width
const isSmallScreen = typeof window !== 'undefined' && window.innerWidth <= 768;
// Return true if any of the checks are true
return mobileRegex.test(userAgent) || isTouchDevice || isSmallScreen;
};
// Set the display name for the component for easier debugging
SmoothScrollContainer.displayName = 'SmoothScrollContainer';
// Export the SmoothScrollContainer component
export default SmoothScrollContainer;
@bradleyhodges
Copy link
Author

This gist is part of a discussion: idiotWu/smooth-scrollbar#362 (comment)

@bradleyhodges
Copy link
Author

A note on margins with this example: Don't apply CSS margins to the sticky element. It will cause weird visual shift. I tried to fix it, but gave up after about two hours of fiddling. If you need to bump content with margins, apply it to the content above/below your heading. For example:

Do this

<h4 className="sticky">My sticky heading</h4>
<div style={{marginTop: '1em'}}> {/* apply styles to the elements around your heading, instead of the heading itself */}
    {/* some content. */}
</div>

Don't do this

// Don't do this. See the other example. Avoid applying margins to the sticky element.
<h4 className="sticky" style={{marginBottom: '1em'}}>My sticky heading</h4>
<div>
    {/* some content */}
</div>

This issue is not present with other styles, including padding. You can pretty much do whatever you want in terms of other styles, it's just margins that get funky.

@bradleyhodges
Copy link
Author

By default, this will disable smooth-scrollbar for mobile devices (the content will still be perfectly scrollable and sticky headings will still work if they have the position: sticky attribute applied). If this behaviour is undesired, you can force it to render the container with the library anyway by setting disableForMobileClients = false; on line 8.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment