Created
February 25, 2024 20:49
-
-
Save mhsattarian/616c8fea5feb30aaa31b323c10e94995 to your computer and use it in GitHub Desktop.
A Radix Carousel component styled via PandaCSS
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
/** | |
* Adopted from an internal component of Radix-ui | |
* | |
* @link https://github.com/radix-ui/website/blob/6cf13bab6e56e8814f8e5cac156587c0fefe994d/components/marketing/Carousel.tsx | |
*/ | |
'use client'; | |
import { useCallback, useEffect, useRef, useState } from 'react'; | |
import debounce from 'lodash.debounce'; | |
// import smoothscroll from 'smoothscroll-polyfill'; | |
import { css, cx } from '@/styled-system/css'; | |
import { useComposedRefs } from '@radix-ui/react-compose-refs'; | |
import { createContext } from '@radix-ui/react-context'; | |
import { useCallbackRef } from '@radix-ui/react-use-callback-ref'; | |
import { composeEventHandlers } from '@radix-ui/primitive'; | |
import { Slot } from '@radix-ui/react-slot'; | |
import { styled } from '@/styled-system/jsx'; | |
import { token } from '@/styled-system/tokens'; | |
const [CarouselProvider, useCarouselContext] = createContext<{ | |
_: any; | |
slideListRef: React.RefObject<HTMLElement>; | |
onNextClick(): void; | |
onPrevClick(): void; | |
nextDisabled: boolean; | |
prevDisabled: boolean; | |
}>('Carousel'); | |
export const Carousel = (props: any) => { | |
const { children, ...carouselProps } = props; | |
const ref = useRef<HTMLDivElement>(null); | |
const slideListRef = useRef<HTMLElement>(null); | |
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(); | |
const navigationUpdateDelay = useRef(100); | |
const [_, force] = useState({}); | |
const [nextDisabled, setNextDisabled] = useState(false); | |
const [prevDisabled, setPrevDisabled] = useState(true); | |
const isRtl = slideListRef.current?.dir !== 'ltr'; | |
const styleDirection = slideListRef.current | |
? getComputedStyle(slideListRef.current).flexDirection | |
: undefined; | |
const actAsRTL = isRtl | |
? styleDirection !== 'row-reverse' | |
: styleDirection === 'row-reverse'; | |
// useEffect(() => smoothscroll.polyfill(), []); | |
const getSlideInDirection = useCallbackRef((direction: 1 | -1) => { | |
const slides = ref.current?.querySelectorAll<HTMLElement>( | |
'[data-slide-intersection-ratio]' | |
); | |
if (slides) { | |
const slidesArray = Array.from(slides.values()); | |
if (direction === 1) { | |
slidesArray.reverse(); | |
} | |
return slidesArray.find( | |
(slide) => slide.dataset.slideIntersectionRatio !== '0' | |
); | |
} | |
}); | |
const handleNextClick = useCallback(() => { | |
const nextSlide = getSlideInDirection(1); | |
if (nextSlide) { | |
const { scrollLeft, scrollWidth, clientWidth } = slideListRef.current!; | |
const itemWidth = nextSlide.clientWidth; | |
const itemsToScroll = | |
itemWidth * 2.5 < document.documentElement.offsetWidth ? 2 : 1; | |
let nextPos = | |
Math.floor(scrollLeft / itemWidth) * itemWidth + | |
itemWidth * itemsToScroll; | |
slideListRef.current!.scrollTo({ left: nextPos, behavior: 'smooth' }); | |
if (actAsRTL) nextPos = -nextPos; | |
const LeftEnd = nextPos <= 0; | |
const RightEnd = scrollWidth - nextPos - clientWidth <= 0; | |
// Disable previous & next buttons immediately | |
setPrevDisabled(actAsRTL ? RightEnd : LeftEnd); | |
setNextDisabled(actAsRTL ? LeftEnd : RightEnd); | |
// Wait for scroll animation to finish before the buttons *might* show up again | |
navigationUpdateDelay.current = 500; | |
} | |
}, [actAsRTL, getSlideInDirection]); | |
const handlePrevClick = useCallback(() => { | |
const prevSlide = getSlideInDirection(-1); | |
if (prevSlide) { | |
const { scrollLeft, scrollWidth, clientWidth } = slideListRef.current!; | |
const itemWidth = prevSlide.clientWidth; | |
const itemsToScroll = | |
itemWidth * 2.5 < document.documentElement.offsetWidth ? 2 : 1; | |
let nextPos = | |
Math.ceil(scrollLeft / itemWidth) * itemWidth - | |
itemWidth * itemsToScroll; | |
slideListRef.current!.scrollTo({ left: nextPos, behavior: 'smooth' }); | |
if (actAsRTL) nextPos = -nextPos; | |
const LeftEnd = nextPos <= 0; | |
const RightEnd = scrollWidth - nextPos - clientWidth <= 0; | |
// Disable previous & next buttons immediately | |
setPrevDisabled(actAsRTL ? RightEnd : LeftEnd); | |
setNextDisabled(actAsRTL ? LeftEnd : RightEnd); | |
// Wait for scroll animation to finish before the buttons *might* show up again | |
navigationUpdateDelay.current = 500; | |
} | |
}, [actAsRTL, getSlideInDirection]); | |
useEffect(() => { | |
// Keep checking for whether we need to disable the navigation buttons, debounced | |
if (timeoutRef.current) { | |
clearTimeout(timeoutRef.current); | |
} | |
timeoutRef.current = setTimeout(() => { | |
requestAnimationFrame(() => { | |
if (slideListRef.current) { | |
const { scrollLeft, scrollWidth, clientWidth } = slideListRef.current; | |
let scrollDistance = scrollLeft; | |
if (actAsRTL) scrollDistance = -scrollLeft; | |
const fromLeft = scrollDistance <= 0; | |
const fromRight = scrollWidth - scrollDistance - clientWidth <= 0; | |
setPrevDisabled(actAsRTL ? fromRight : fromLeft); | |
setNextDisabled(actAsRTL ? fromLeft : fromRight); | |
navigationUpdateDelay.current = 100; | |
} | |
}); | |
}, navigationUpdateDelay.current); | |
}); | |
useEffect(() => { | |
const slidesList = slideListRef.current; | |
if (slidesList) { | |
const handleScrollStartAndEnd = debounce(() => force({}), 100, { | |
leading: true, | |
trailing: true, | |
}); | |
slidesList.addEventListener('scroll', handleScrollStartAndEnd); | |
window.addEventListener('resize', handleScrollStartAndEnd); | |
force({}); | |
return () => { | |
slidesList.removeEventListener('scroll', handleScrollStartAndEnd); | |
window.removeEventListener('resize', handleScrollStartAndEnd); | |
}; | |
} | |
}, [slideListRef]); | |
return ( | |
<CarouselProvider | |
_={_} | |
nextDisabled={nextDisabled} | |
prevDisabled={prevDisabled} | |
slideListRef={slideListRef} | |
onNextClick={handleNextClick} | |
onPrevClick={handlePrevClick} | |
> | |
<div {...carouselProps} ref={ref}> | |
{children} | |
</div> | |
</CarouselProvider> | |
); | |
}; | |
export const CarouselSlideList = (props: any) => { | |
const Comp = props.asChild ? Slot : 'div'; | |
const context = useCarouselContext('CarouselSlideList'); | |
const ref = useRef<React.ElementRef<'div'>>(null); | |
const composedRefs = useComposedRefs(ref, context.slideListRef); | |
const [dragStart, setDragStart] = useState<any>(null); | |
const handleMouseMove = useCallbackRef((event) => { | |
if (ref.current) { | |
const distanceX = event.clientX - dragStart!.pointerX; | |
ref.current.scrollLeft = dragStart!.scrollX - distanceX; | |
} | |
}); | |
const handleMouseUp = useCallbackRef(() => { | |
document.removeEventListener('mousemove', handleMouseMove); | |
document.removeEventListener('mouseup', handleMouseUp); | |
setDragStart(null); | |
}); | |
return ( | |
<Comp | |
{...props} | |
className={cx( | |
props.className, | |
'HiddenScroll', | |
css({ | |
WebkitOverflowScrolling: 'touch', | |
position: 'relative', | |
}) | |
)} | |
ref={composedRefs} | |
data-state={dragStart ? 'dragging' : undefined} | |
onMouseDownCapture={composeEventHandlers( | |
props.onMouseDownCapture, | |
(event: MouseEvent) => { | |
if (event.target instanceof HTMLInputElement) { | |
return; | |
} | |
// Drag only if main mouse button was clicked | |
if (event.button === 0) { | |
document.addEventListener('mousemove', handleMouseMove); | |
document.addEventListener('mouseup', handleMouseUp); | |
setDragStart({ | |
scrollX: (event.currentTarget as HTMLElement).scrollLeft, | |
pointerX: event.clientX, | |
}); | |
} | |
} | |
)} | |
onPointerDown={composeEventHandlers( | |
props.onPointerDown, | |
(event: PointerEvent) => { | |
if (event.target instanceof HTMLInputElement) { | |
return; | |
} | |
const element = event.target as HTMLElement; | |
element.style.userSelect = 'none'; | |
element.setPointerCapture(event.pointerId); | |
} | |
)} | |
onPointerUp={composeEventHandlers( | |
props.onPointerUp, | |
(event: PointerEvent) => { | |
if (event.target instanceof HTMLInputElement) { | |
return; | |
} | |
const element = event.target as HTMLElement; | |
element.style.userSelect = ''; | |
element.releasePointerCapture(event.pointerId); | |
} | |
)} | |
/> | |
); | |
}; | |
interface CarouselSlideProps extends React.HTMLAttributes<any> { | |
as?: React.ComponentType; | |
} | |
export const CarouselSlide = (props: CarouselSlideProps) => { | |
const { as: Comp = 'div', ...slideProps } = props; | |
const context = useCarouselContext('CarouselSlide'); | |
const ref = useRef<HTMLDivElement>(null); | |
const isDraggingRef = useRef(false); | |
const [intersectionRatio, setIntersectionRatio] = useState(0); | |
useEffect(() => { | |
const observer = new IntersectionObserver( | |
([entry]) => setIntersectionRatio(entry.intersectionRatio), | |
{ root: context.slideListRef.current, threshold: [0, 0.5, 1] } | |
); | |
observer.observe(ref.current!); | |
return () => observer.disconnect(); | |
}, [context.slideListRef]); | |
return ( | |
<Comp | |
{...slideProps} | |
ref={ref} | |
data-slide-intersection-ratio={intersectionRatio} | |
draggable='true' | |
onDragStart={(event: React.DragEvent) => { | |
event.preventDefault(); | |
isDraggingRef.current = true; | |
}} | |
onMouseUp={(event: React.MouseEvent) => { | |
event.preventDefault(); | |
setTimeout(() => { | |
isDraggingRef.current = false; | |
}, 0); | |
}} | |
onClickCapture={(event: React.MouseEvent) => { | |
if (isDraggingRef.current) { | |
event.preventDefault(); | |
event.stopPropagation(); | |
} | |
}} | |
/> | |
); | |
}; | |
export const CarouselNext = (props: any) => { | |
const { as: Comp = 'button', ...nextProps } = props; | |
const context = useCarouselContext('CarouselNext'); | |
return ( | |
<Comp | |
{...nextProps} | |
onClick={() => context.onNextClick()} | |
disabled={context.nextDisabled} | |
/> | |
); | |
}; | |
export const CarouselPrevious = (props: any) => { | |
const { as: Comp = 'button', ...prevProps } = props; | |
const context = useCarouselContext('CarouselPrevious'); | |
return ( | |
<Comp | |
{...prevProps} | |
onClick={() => context.onPrevClick()} | |
disabled={context.prevDisabled} | |
/> | |
); | |
}; | |
export const CarouselArrowButton = styled('button', { | |
base: { | |
// unset: 'all', | |
outline: 0, | |
margin: 0, | |
border: 0, | |
padding: 0, | |
display: 'flex', | |
position: 'relative', | |
zIndex: 1, | |
alignItems: 'center', | |
justifyContent: 'center', | |
backgroundColor: 'Background', | |
borderRadius: '100%', | |
width: 8, | |
height: 8, | |
color: 'gray.400', | |
boxShadow: `${token('colors.ebony.600')} 0px 2px 12px -5px, ${token( | |
'colors.ebony.200' | |
)} 0px 1px 3px`, | |
willChange: 'transform, box-shadow, opacity', | |
transition: 'all 100ms', | |
'@media (hover: hover)': { | |
'&:hover': { | |
boxShadow: `${token('colors.ebony.600')} 0px 3px 16px -5px, ${token( | |
'colors.ebony.200' | |
)} 0px 1px 3px`, | |
// Fix a bug when hovering at button edges would cause the button to jitter because of transform | |
'&::before': { | |
content: '', | |
inset: -2, | |
borderRadius: '100%', | |
position: 'absolute', | |
}, | |
}, | |
}, | |
'&:focus-visible:not(:active)': { | |
boxShadow: `${token('colors.ebony.600')} 0px 2px 12px -5px, ${token( | |
'colors.ebony.200' | |
)} 0px 1px 3px`, | |
outline: '2px solid var(--accent-8)', | |
}, | |
'&:active': { | |
transform: 'translateY(1px)', | |
boxShadow: `${token('colors.ebony.600')} 0px 2px 10px -5px, ${token( | |
'colors.ebony.200' | |
)} 0px 1px 3px`, | |
transition: 'opacity 100ms', | |
}, | |
'&:disabled': { | |
opacity: 0, | |
}, | |
'@media (hover: none) and (pointer: coarse)': { | |
display: 'none', | |
}, | |
}, | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
usage example: