-
-
Save bradleyhodges/acc738098a98f47183178c5a4d7d6a1b to your computer and use it in GitHub Desktop.
// 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; |
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.
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.
This gist is part of a discussion: idiotWu/smooth-scrollbar#362 (comment)