A slider/carousel built with React. The x and y coordinates of the current slide are set to CSS variables to create dynamic transition effects on mouseover.
A Pen by Kumpon Sotsukpiam on CodePen.
#app |
A slider/carousel built with React. The x and y coordinates of the current slide are set to CSS variables to create dynamic transition effects on mouseover.
A Pen by Kumpon Sotsukpiam on CodePen.
const slideData = [ | |
{ | |
index: 0, | |
headline: 'New Fashion Apparel', | |
button: 'Shop now', | |
src: 'https://s3-us-west-2.amazonaws.com/s.cdpn.io/225363/fashion.jpg' | |
}, | |
{ | |
index: 1, | |
headline: 'In The Wilderness', | |
button: 'Book travel', | |
src: 'https://s3-us-west-2.amazonaws.com/s.cdpn.io/225363/forest.jpg' | |
}, | |
{ | |
index: 2, | |
headline: 'For Your Current Mood', | |
button: 'Listen', | |
src: 'https://s3-us-west-2.amazonaws.com/s.cdpn.io/225363/guitar.jpg' | |
}, | |
{ | |
index: 3, | |
headline: 'Focus On The Writing', | |
button: 'Get Focused', | |
src: 'https://s3-us-west-2.amazonaws.com/s.cdpn.io/225363/typewriter.jpg' | |
} | |
] | |
// ========================= | |
// Slide | |
// ========================= | |
class Slide extends React.Component { | |
constructor(props) { | |
super(props) | |
this.handleMouseMove = this.handleMouseMove.bind(this) | |
this.handleMouseLeave = this.handleMouseLeave.bind(this) | |
this.handleSlideClick = this.handleSlideClick.bind(this) | |
this.imageLoaded = this.imageLoaded.bind(this) | |
this.slide = React.createRef() | |
} | |
handleMouseMove(event) { | |
const el = this.slide.current | |
const r = el.getBoundingClientRect() | |
el.style.setProperty('--x', event.clientX - (r.left + Math.floor(r.width / 2))) | |
el.style.setProperty('--y', event.clientY - (r.top + Math.floor(r.height / 2))) | |
} | |
handleMouseLeave(event) { | |
this.slide.current.style.setProperty('--x', 0) | |
this.slide.current.style.setProperty('--y', 0) | |
} | |
handleSlideClick(event) { | |
this.props.handleSlideClick(this.props.slide.index) | |
} | |
imageLoaded(event) { | |
event.target.style.opacity = 1 | |
} | |
render() { | |
const { src, button, headline, index } = this.props.slide | |
const current = this.props.current | |
let classNames = 'slide' | |
if (current === index) classNames += ' slide--current' | |
else if (current - 1 === index) classNames += ' slide--previous' | |
else if (current + 1 === index) classNames += ' slide--next' | |
return ( | |
<li | |
ref={this.slide} | |
className={classNames} | |
onClick={this.handleSlideClick} | |
onMouseMove={this.handleMouseMove} | |
onMouseLeave={this.handleMouseLeave} | |
> | |
<div className="slide__image-wrapper"> | |
<img | |
className="slide__image" | |
alt={headline} | |
src={src} | |
onLoad={this.imageLoaded} | |
/> | |
</div> | |
<article className="slide__content"> | |
<h2 className="slide__headline">{headline}</h2> | |
<button className="slide__action btn">{button}</button> | |
</article> | |
</li> | |
) | |
} | |
} | |
// ========================= | |
// Slider control | |
// ========================= | |
const SliderControl = ({ type, title, handleClick }) => { | |
return ( | |
<button className={`btn btn--${type}`} title={title} onClick={handleClick}> | |
<svg className="icon" viewBox="0 0 24 24"> | |
<path d="M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z" /> | |
</svg> | |
</button> | |
) | |
} | |
// ========================= | |
// Slider | |
// ========================= | |
class Slider extends React.Component { | |
constructor(props) { | |
super(props) | |
this.state = { current: 0 } | |
this.handlePreviousClick = this.handlePreviousClick.bind(this) | |
this.handleNextClick = this.handleNextClick.bind(this) | |
this.handleSlideClick = this.handleSlideClick.bind(this) | |
} | |
handlePreviousClick() { | |
const previous = this.state.current - 1 | |
this.setState({ | |
current: (previous < 0) | |
? this.props.slides.length - 1 | |
: previous | |
}) | |
} | |
handleNextClick() { | |
const next = this.state.current + 1; | |
this.setState({ | |
current: (next === this.props.slides.length) | |
? 0 | |
: next | |
}) | |
} | |
handleSlideClick(index) { | |
if (this.state.current !== index) { | |
this.setState({ | |
current: index | |
}) | |
} | |
} | |
render() { | |
const { current, direction } = this.state | |
const { slides, heading } = this.props | |
const headingId = `slider-heading__${heading.replace(/\s+/g, '-').toLowerCase()}` | |
const wrapperTransform = { | |
'transform': `translateX(-${current * (100 / slides.length)}%)` | |
} | |
return ( | |
<div className='slider' aria-labelledby={headingId}> | |
<ul className="slider__wrapper" style={wrapperTransform}> | |
<h3 id={headingId} class="visuallyhidden">{heading}</h3> | |
{slides.map(slide => { | |
return ( | |
<Slide | |
key={slide.index} | |
slide={slide} | |
current={current} | |
handleSlideClick={this.handleSlideClick} | |
/> | |
) | |
})} | |
</ul> | |
<div className="slider__controls"> | |
<SliderControl | |
type="previous" | |
title="Go to previous slide" | |
handleClick={this.handlePreviousClick} | |
/> | |
<SliderControl | |
type="next" | |
title="Go to next slide" | |
handleClick={this.handleNextClick} | |
/> | |
</div> | |
</div> | |
) | |
} | |
} | |
ReactDOM.render(<Slider heading="Example Slider" slides={slideData} />, document.getElementById('app')); |
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.6/umd/react.production.min.js"></script> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.6/umd/react-dom.production.min.js"></script> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-transition-group/4.2.1/react-transition-group.min.js"></script> |
@import url('https://fonts.googleapis.com/css?family=Playfair+Display:700|IBM+Plex+Sans:500&display=swap'); | |
:root { | |
--color-primary: #6B7A8F; | |
--color-secondary: #101118; | |
--color-accent: #1D1F2F; | |
--color-focus: #6D64F7; | |
--base-duration: 600ms; | |
--base-ease: cubic-bezier(0.25, 0.46, 0.45, 0.84); | |
} | |
// ========================= | |
// Global | |
// ========================= | |
*, *:before, *:after { | |
box-sizing: border-box; | |
} | |
html, body { | |
height: 100%; | |
} | |
body { | |
font-family: 'IBM Plex Sans', sans-serif; | |
background-color: var(--color-secondary); | |
} | |
#app { | |
align-items: center; | |
display: flex; | |
height: 100%; | |
justify-content: center; | |
overflow-x: hidden; | |
width: 100%; | |
} | |
h1, h2, h3 { | |
font-family: 'Playfair Display', serif; | |
} | |
.visuallyhidden { | |
clip: rect(1px, 1px, 1px, 1px); | |
height: 1px; | |
overflow: hidden; | |
position: absolute !important; | |
white-space: nowrap; | |
width: 1px; | |
} | |
// ========================= | |
// Icons | |
// ========================= | |
.icon { | |
fill: var(--color-primary); | |
width: 100%; | |
} | |
// ========================= | |
// Buttons | |
// ========================= | |
.btn { | |
background-color: var(--color-primary); | |
border: none; | |
border-radius: 0.125rem; | |
color: white; | |
cursor: pointer; | |
font-family: inherit; | |
font-size: inherit; | |
padding: 1rem 2.5rem 1.125rem; | |
&:focus { | |
outline-color: var(--color-focus); | |
outline-offset: 2px; | |
outline-style: solid; | |
outline-width: 3px; | |
} | |
&:active { | |
transform: translateY(1px); | |
} | |
} | |
// ========================= | |
// Slider controls | |
// ========================= | |
.slider__controls { | |
display: flex; | |
justify-content: center; | |
position: absolute; | |
top: calc(100% + 1rem); | |
width: 100%; | |
.btn { | |
--size: 3rem; | |
align-items: center; | |
background-color: transparent; | |
border: 3px solid transparent; | |
border-radius: 100%; | |
display: flex; | |
height: var(--size); | |
padding: 0; | |
width: var(--size); | |
&:focus { | |
border-color: var(--color-focus); | |
outline: none; | |
} | |
&--previous > * { | |
transform: rotate(180deg); | |
} | |
} | |
} | |
// ========================= | |
// Slider | |
// ========================= | |
.slider { | |
--slide-size: 70vmin; | |
--slide-margin: 4vmin; | |
height: var(--slide-size); | |
margin: 0 auto; | |
position: relative; | |
width: var(--slide-size); | |
} | |
.slider__wrapper { | |
display: flex; | |
margin: 0 calc(var(--slide-margin) * -1); | |
position: absolute; | |
transition: transform var(--base-duration) cubic-bezier(0.25, 1, 0.35, 1); | |
} | |
// ========================= | |
// Slide | |
// ========================= | |
.slide { | |
align-items: center; | |
color: white; | |
display: flex; | |
flex: 1; | |
flex-direction: column; | |
height: var(--slide-size); | |
justify-content: center; | |
margin: 0 var(--slide-margin); | |
opacity: 0.25; | |
position: relative; | |
text-align: center; | |
transition: | |
opacity calc(var(--base-duration) / 2) var(--base-ease), | |
transform calc(var(--base-duration) / 2) var(--base-ease); | |
width: var(--slide-size); | |
z-index: 1; | |
&--previous, | |
&--next { | |
&:hover { | |
opacity: 0.5; | |
} | |
} | |
&--previous { | |
cursor: w-resize; | |
&:hover { | |
transform: translateX(2%); | |
} | |
} | |
&--next { | |
cursor: e-resize; | |
&:hover { | |
transform: translateX(-2%); | |
} | |
} | |
} | |
.slide--current { | |
--x: 0; | |
--y: 0; | |
--d: 50; | |
opacity: 1; | |
pointer-events: auto; | |
user-select: auto; | |
@media (hover: hover) { | |
&:hover .slide__image-wrapper { | |
transform: | |
scale(1.025) | |
translate( | |
calc(var(--x) / var(--d) * 1px), | |
calc(var(--y) / var(--d) * 1px) | |
); | |
} | |
} | |
} | |
.slide__image-wrapper { | |
background-color: var(--color-accent); | |
border-radius: 1%; | |
height: 100%; | |
left: 0%; | |
overflow: hidden; | |
position: absolute; | |
top: 0%; | |
transition: transform calc(var(--base-duration) / 4) var(--base-ease); | |
width: 100%; | |
} | |
.slide__image { | |
--d: 20; | |
height: 110%; | |
left: -5%; | |
object-fit: cover; | |
opacity: 0; | |
pointer-events: none; | |
position: absolute; | |
top: -5%; | |
transition: | |
opacity var(--base-duration) var(--base-ease), | |
transform var(--base-duration) var(--base-ease); | |
user-select: none; | |
width: 110%; | |
@media (hover: hover) { | |
.slide--current & { | |
transform: | |
translate( | |
calc(var(--x) / var(--d) * 1px), | |
calc(var(--y) / var(--d) * 1px) | |
); | |
} | |
} | |
} | |
.slide__headline { | |
font-size: 8vmin; | |
font-weight: 600; | |
position: relative; | |
} | |
.slide__content { | |
--d: 60; | |
opacity: 0; | |
padding: 4vmin; | |
position: relative; | |
transition: transform var(--base-duration) var(--base-ease); | |
visibility: hidden; | |
.slide--current & { | |
animation: fade-in calc(var(--base-duration) / 2) var(--base-ease) forwards; | |
visibility: visible; | |
@media (hover: hover) { | |
transform: | |
translate( | |
calc(var(--x) / var(--d) * -1px), | |
calc(var(--y) / var(--d) * -1px) | |
); | |
} | |
} | |
> * + * { | |
margin-top: 2rem; | |
} | |
} | |
// ========================= | |
// Animations | |
// ========================= | |
@keyframes fade-in { | |
from { opacity: 0 } | |
to { opacity: 1 } | |
} |