Last active
January 3, 2020 19:19
-
-
Save SebastianHGonzalez/6dfeaa2973891361d7d019c0a07e3d6b to your computer and use it in GitHub Desktop.
Carousel component with seamless infinite scroll
This file contains hidden or 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 React, { | |
Children, | |
useCallback, | |
useRef, | |
useEffect, | |
useState, | |
} from 'react'; | |
import { | |
node, elementType, number, bool, string, func, | |
} from 'prop-types'; | |
import styled from 'styled-components'; | |
import Chevron from './Chevron'; | |
import UnstyledStepper from './Stepper'; | |
import { debounce } from '~/helpers'; | |
export const CarouselWrapper = styled.div` | |
display: block; | |
position: relative; | |
`; | |
export const CarouselListItem = styled.li` | |
position: relative; | |
flex: 1 0 100%; | |
/* is touch screen */ | |
@media (pointer: coarse) { | |
scroll-snap-stop: always; | |
} | |
`; | |
export const CarouselList = styled.ul` | |
padding: 0; | |
margin: 0; | |
overflow-x: auto; | |
overflow-y: hidden; | |
list-style: none; | |
display: flex; | |
::-webkit-scrollbar, ::-webkit-scrollbar-thumb, ::-webkit-scrollbar-track, ::-webkit-scrollbar-track-piece { | |
display: none; | |
} | |
overflow-scrolling: touch; | |
-webkit-overflow-scrolling: touch; | |
scroll-snap-type: x mandatory; | |
${CarouselListItem} { | |
scroll-snap-align: center; | |
} | |
${CarouselListItem}:first-child { | |
scroll-snap-align: start; | |
} | |
${CarouselListItem}:last-child { | |
scroll-snap-align: end; | |
} | |
`; | |
export const CarouselButton = styled.button.attrs(({ hidden }) => ({ | |
style: { display: hidden ? 'none' : undefined }, | |
type: 'button', | |
children: <Chevron />, | |
}))` | |
cursor: pointer; | |
background-color: rgba(247, 247, 247, 0.65); | |
border: 0; | |
padding: 1em; | |
margin: 0; | |
position: absolute; | |
top: 50%; | |
height: 6em; | |
width: 4em; | |
z-index: 6; | |
/* is touch screen */ | |
@media (pointer: coarse) { | |
display: none; | |
} | |
`; | |
export const CarouselPreviousButton = styled(CarouselButton)` | |
left: 0; | |
transform: rotateY(180deg) translateY(-50%); | |
`; | |
export const CarouselNextButton = styled(CarouselButton)` | |
right: 0; | |
transform: translateY(-50%); | |
`; | |
export const Stepper = styled(UnstyledStepper)` | |
position: absolute; | |
bottom: 4px; | |
margin-left: auto; | |
margin-right: auto; | |
left: 0; | |
right: 0; | |
${({ total }) => (total <= 1 ? 'display: none;' : '')} | |
`; | |
function getItemSize ({ scrollWidth }, totalSteps, circular) { | |
return scrollWidth / (totalSteps + (circular ? 2 : 0)); | |
} | |
function scrollLength ({ scrollWidth, offsetWidth }) { | |
return scrollWidth - offsetWidth; | |
} | |
export default function Carousel ({ | |
className, | |
carouselWrapperComponent: Wrapper, | |
listComponent: List, | |
listItemComponent: ListItem, | |
previousButtonComponent: PreviousButton, | |
nextButtonComponent: NextButton, | |
stepperComponent: StepperComponent, | |
getStepSize, | |
autoplay, | |
showStepper, | |
circular, | |
children, | |
}) { | |
const listRef = useRef(); | |
const totalSteps = Children.count(children); | |
const [currentStep, setCurrentStep] = useState(0); | |
const [isAtTheStart, setIsAtTheStart] = useState(true); | |
const [isAtTheEnd, setIsAtTheEnd] = useState(false); | |
const scrollToFirst = useCallback(() => { | |
if (listRef.current) { | |
listRef.current.scrollTo({ | |
left: getItemSize(listRef.current, totalSteps, circular), | |
}); | |
} | |
}, [totalSteps, circular]); | |
const scrollToLast = useCallback(() => { | |
if (listRef.current) { | |
listRef.current.scrollTo({ | |
left: | |
scrollLength(listRef.current) | |
- getItemSize(listRef.current, totalSteps, circular), | |
}); | |
} | |
}, [totalSteps, circular]); | |
const onScroll = useCallback( | |
debounce(({ target: { offsetWidth, scrollLeft, scrollWidth } }) => { | |
const atStart = scrollLeft <= 0; | |
const atEnd = scrollLeft >= scrollWidth - offsetWidth; | |
setIsAtTheStart(atStart); | |
setIsAtTheEnd(atEnd); | |
setCurrentStep( | |
(Math.floor( | |
(scrollLeft / (scrollWidth - offsetWidth + 1)) * totalSteps, | |
)) | |
% totalSteps, | |
); | |
if (circular && atEnd) scrollToFirst(); | |
if (circular && atStart) scrollToLast(); | |
}, 100), | |
[totalSteps, scrollToFirst, scrollToLast, circular], | |
); | |
useEffect(() => { | |
if (listRef.current) { | |
listRef.current.addEventListener('scroll', onScroll); | |
return () => { | |
listRef.current.removeEventListener('scroll', onScroll); | |
}; | |
} | |
return () => {}; | |
}, []); | |
const scrollToStep = useCallback( | |
(newStep) => { | |
if (listRef.current) { | |
const targetPosition = getItemSize(listRef.current, totalSteps, circular) | |
* (newStep + (circular ? 1 : 0)); | |
listRef.current.scrollTo({ | |
left: targetPosition, | |
behavior: 'smooth', | |
}); | |
} | |
}, | |
[totalSteps, circular], | |
); | |
const scrollToPreviousStep = useCallback(() => { | |
if (listRef.current) { | |
listRef.current.scrollBy({ | |
left: -getStepSize(listRef.current), | |
behavior: 'smooth', | |
}); | |
} | |
}, [getStepSize]); | |
const scrollToNextStep = useCallback(() => { | |
if (listRef.current) { | |
listRef.current.scrollBy({ | |
left: getStepSize(listRef.current), | |
behavior: 'smooth', | |
}); | |
} | |
}, [getStepSize]); | |
useEffect(() => { | |
if (autoplay) { | |
const timeout = setTimeout(scrollToNextStep, autoplay); | |
return () => { | |
clearTimeout(timeout); | |
}; | |
} | |
return () => {}; | |
}, [currentStep, scrollToNextStep, autoplay]); | |
useEffect(() => { | |
if (circular) { | |
scrollToFirst(); | |
} | |
}, [listRef.current, scrollToFirst, circular, totalSteps]); | |
return ( | |
<Wrapper className={className}> | |
<List ref={listRef}> | |
{circular | |
&& totalSteps > 1 | |
&& Children.toArray(children) | |
.slice(-1) | |
.map(child => ( | |
<ListItem key={`${child.key}-roundback`}>{child}</ListItem> | |
))} | |
{Children.map(children, child => ( | |
<ListItem key={child.key}>{child}</ListItem> | |
))} | |
{circular | |
&& totalSteps > 1 | |
&& Children.toArray(children) | |
.slice(0, 1) | |
.map(child => ( | |
<ListItem key={`${child.key}-roundback`}>{child}</ListItem> | |
))} | |
</List> | |
<PreviousButton | |
hidden={totalSteps <= 1 || (circular ? false : isAtTheStart)} | |
onClick={scrollToPreviousStep} | |
/> | |
<NextButton | |
hidden={totalSteps <= 1 || (circular ? false : isAtTheEnd)} | |
onClick={scrollToNextStep} | |
/> | |
{showStepper && ( | |
<StepperComponent | |
total={totalSteps} | |
current={currentStep} | |
onChange={scrollToStep} | |
/> | |
)} | |
</Wrapper> | |
); | |
} | |
Carousel.propTypes = { | |
className: string, | |
carouselWrapperComponent: elementType, | |
listComponent: elementType, | |
listItemComponent: elementType, | |
previousButtonComponent: elementType, | |
nextButtonComponent: elementType, | |
stepperComponent: elementType, | |
getStepSize: func, | |
autoplay: number, | |
showStepper: bool, | |
circular: bool, | |
children: node, | |
}; | |
Carousel.defaultProps = { | |
className: undefined, | |
carouselWrapperComponent: CarouselWrapper, | |
listComponent: CarouselList, | |
listItemComponent: CarouselListItem, | |
previousButtonComponent: CarouselPreviousButton, | |
nextButtonComponent: CarouselNextButton, | |
stepperComponent: Stepper, | |
getStepSize: ({ offsetWidth }) => offsetWidth, | |
autoplay: 0, | |
showStepper: false, | |
circular: false, | |
children: undefined, | |
}; |
This file contains hidden or 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 React from 'react'; | |
import styled from 'styled-components'; | |
export const ChevronShadowPath = styled.path.attrs({ d: 'M4,70l-3.8,0L34,36L0,1.8h3.8L38,36L4,70z' })` | |
fill: white; | |
`; | |
export const ChevronPath = styled.path.attrs({ d: 'M6.7,72l-4.7,0l35.8-36L2.1,0h4.7l35.9,36L6.7,72z' })``; | |
export default styled.svg.attrs({ | |
height: '100%', | |
width: '100%', | |
viewBox: '0 0 42.7 72', | |
xmlns: 'http://www.w3.org/2000/svg', | |
children: [ | |
<ChevronPath />, | |
<ChevronShadowPath />, | |
], | |
})``; |
This file contains hidden or 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 React from 'react'; | |
import { number, func, elementType, string } from 'prop-types'; | |
import styled from 'styled-components'; | |
export const DefaultStepperWrapper = styled.ul` | |
display: flex; | |
justify-content: center; | |
font-size: 18px; | |
line-height: 1; | |
text-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.19); | |
list-style: none; | |
padding: 0; | |
margin: 0; | |
`; | |
const Button = styled.button.attrs({ type: 'button', tabIndex: -1 })` | |
margin: 0; | |
padding: 0; | |
border: 0; | |
cursor: pointer; | |
height: 6px; | |
width: 6px; | |
border-radius: 6px; | |
margin: 4px; | |
background-color: ${({ selected }) => (selected ? '#807F80' : '#C9C7C7')}; | |
/* is touch screen */ | |
@media (pointer:coarse) { | |
pointer-events: none; | |
} | |
`; | |
export const DefaultStep = styled.li.attrs(({ selected, onClick }) => ({ | |
children: <Button selected={selected} onClick={onClick} />, | |
}))``; | |
export default function Stepper ({ | |
className, | |
total, | |
current, | |
onChange, | |
wrapperComponent: Wrapper, | |
stepComponent: Step, | |
}) { | |
return ( | |
<Wrapper className={className}> | |
{Array(total) | |
.fill() | |
.map((_, index) => index) | |
.map(step => ( | |
<Step selected={current === step} onClick={() => onChange(step)} /> | |
))} | |
</Wrapper> | |
); | |
} | |
function noop () {} | |
Stepper.propTypes = { | |
className: string, | |
total: number.isRequired, | |
current: number.isRequired, | |
onChange: func, | |
wrapperComponent: elementType, | |
stepComponent: elementType, | |
}; | |
Stepper.defaultProps = { | |
className: undefined, | |
onChange: noop, | |
wrapperComponent: DefaultStepperWrapper, | |
stepComponent: DefaultStep, | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment