Created
May 31, 2022 20:48
-
-
Save Jugbot/2897fabb30d1aafd893d26d76e01faa4 to your computer and use it in GitHub Desktop.
Centered Carousel component with scroll snap and programmatic scrolling
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import debounce from 'lodash/debounce'; | |
import React, { | |
Children, | |
ComponentProps, | |
FunctionComponent, | |
UIEventHandler, | |
useEffect, | |
useRef, | |
} from 'react'; | |
import { PicnicCss, styled } from '@attentive/picnic'; | |
const scrollbarBeGone: PicnicCss = { | |
'-ms-overflow-style': 'none', | |
scrollbarWidth: 'none', | |
'&::-webkit-scrollbar': { | |
display: 'none', | |
}, | |
}; | |
const onScrollEnd = (onScroll: UIEventHandler) => debounce(onScroll, 500); | |
function childIntersectsCenter(parent: Element, child: Element): boolean { | |
const parentRect = parent.getBoundingClientRect(); | |
const childRect = child.getBoundingClientRect(); | |
const center = parentRect.left + parentRect.width / 2; | |
return childRect.right > center && childRect.left < center; | |
} | |
const CarouselSpacer = styled('div', { | |
display: 'inline-block', | |
flexShrink: 0, | |
width: '50%', | |
}); | |
const CarouselContainer = styled('ol', { | |
position: 'relative', | |
display: 'flex', | |
flexWrap: 'nowrap', | |
overflowX: 'hidden', | |
scrollSnapType: 'x mandatory', | |
scrollBehavior: 'smooth', | |
width: '100%', | |
margin: 0, | |
padding: 0, | |
variants: { | |
allowScroll: { | |
true: { | |
overflowX: 'auto', | |
}, | |
}, | |
hideScrollBar: { | |
true: scrollbarBeGone, | |
}, | |
}, | |
}); | |
const CarouselItem = styled('li', { | |
display: 'inline-block', | |
flexShrink: 0, | |
scrollSnapAlign: 'center', | |
// hardware acceleration for smooth scroll (black magic) | |
transform: 'translateZ(0)', | |
variants: { | |
decorational: { | |
true: { scrollSnapAlign: 'none' }, | |
}, | |
}, | |
}); | |
interface CarouselProps extends ComponentProps<typeof CarouselContainer> { | |
focusedIndex: number; | |
allowScroll?: boolean; | |
hideScrollBar?: boolean; | |
onIndexChange?: (index: number) => void; | |
} | |
const CarouselComponent: FunctionComponent<CarouselProps> = ({ | |
focusedIndex, | |
onIndexChange = () => {}, | |
allowScroll, | |
hideScrollBar, | |
children, | |
...rest | |
}) => { | |
const selectedItemRef = useRef<HTMLLIElement | null>(null); | |
const containerRef = useRef<HTMLOListElement | null>(null); | |
useEffect(() => { | |
selectedItemRef.current?.scrollIntoView({ inline: 'center' }); | |
}, [focusedIndex]); | |
const handleScroll = () => { | |
const containerNode = containerRef.current; | |
if (containerNode === null) return; | |
// Don't consider the "spacer" children | |
const itemsToConsider = Array.from(containerNode.children).slice(1, -1); | |
const centeredElementIndex = itemsToConsider.findIndex((node) => | |
childIntersectsCenter(containerNode, node) | |
); | |
onIndexChange(centeredElementIndex); | |
}; | |
return ( | |
<CarouselContainer | |
ref={containerRef} | |
onScroll={onScrollEnd(handleScroll)} | |
allowScroll={allowScroll} | |
hideScrollBar={hideScrollBar} | |
{...rest} | |
> | |
<CarouselSpacer /> | |
{(() => { | |
let adjustedIndex = 0; | |
return Children.map(children, (child) => { | |
if (React.isValidElement(child) && !child.props.decorational) { | |
const index = adjustedIndex; | |
adjustedIndex += 1; | |
if (index === focusedIndex) { | |
return React.cloneElement(child, { | |
ref: (ref: HTMLLIElement) => (selectedItemRef.current = ref), | |
}); | |
} | |
} | |
return child; | |
}); | |
})()} | |
<CarouselSpacer /> | |
</CarouselContainer> | |
); | |
}; | |
type ComponentType = typeof CarouselComponent; | |
interface CompositeComponent extends ComponentType { | |
Item: typeof CarouselItem; | |
} | |
const Carousel = CarouselComponent as CompositeComponent; | |
Carousel.Item = CarouselItem; | |
export { Carousel }; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment