<div id="root"></div>
@import url('https://fonts.googleapis.com/css?family=Mali:400,500');

/** Animation classes */
.fromRight {
	animation: fromRight 0.5s ease-out;
}

@keyframes fromRight {
	from {
		transform: translate3d(100%, 0, 0);
	}
}
.toRight {
	animation: toRight 0.5s ease-out;
}

@keyframes toRight {
	to {
		transform: translate3d(100%, 0, 0);
	}
}
.fromLeft {
	animation: fromLeft 0.5s ease-out;
}

@keyframes fromLeft {
	from {
		transform: translate3d(-100%, 0, 0);
	}
}
.toLeft {
	animation: toLeft 0.5s ease-out;
}

@keyframes toLeft {
	to {
		transform: translate3d(-100%, 0, 0);
	}
}
.fromTop {
	animation: fromTop 0.5s ease-out;
}

@keyframes fromTop {
	from {
		transform: translate3d(0, -100%, 0);
	}
}
.toTop {
	animation: toTop 0.5s ease-out;
}

@keyframes toTop {
	to {
		transform: translate3d(0, -100%, 0);
	}
}
.fromBottom {
	animation: fromBottom 0.5s ease-out;
}

@keyframes fromBottom {
	from {
		transform: translate3d(0, 100%, 0);
	}
}
.toBottom {
	animation: toBottom 0.5s ease-out;
}

@keyframes toBottom {
	to {
		transform: translate3d(0, 100%, 0);
	}
}
.fromRightFade {
	animation: fromRightFade 0.5s ease-out;
}

@keyframes fromRightFade {
	from {
		opacity: 0.2;
		transform: translate3d(100%, 0, 0);
	}
}
.toRightFade {
	animation: toRightFade 0.5s ease-out;
}

@keyframes toRightFade {
	to {
		opacity: 0.2;
		transform: translate3d(100%, 0, 0);
	}
}
.fromLeftFade {
	animation: fromLeftFade 0.5s ease-out;
}

@keyframes fromLeftFade {
	from {
		opacity: 0.2;
		transform: translate3d(-100%, 0, 0);
	}
}
.toLeftFade {
	animation: toLeftFade 0.5s ease-out;
}

@keyframes toLeftFade {
	to {
		opacity: 0.2;
		transform: translate3d(-100%, 0, 0);
	}
}
.fromTopFade {
	animation: fromTopFade 0.5s ease-out;
}

@keyframes fromTopFade {
	from {
		opacity: 0.2;
		transform: translate3d(0, -100%, 0);
	}
}
.toTopFade {
	animation: toTopFade 0.5s ease-out;
}

@keyframes toTopFade {
	to {
		opacity: 0.2;
		transform: translate3d(0, -100%, 0);
	}
}
.fromBottomFade {
	animation: fromBottomFade 0.5s ease-out;
}

@keyframes fromBottomFade {
	from {
		opacity: 0.2;
		transform: translate3d(0, 100%, 0);
	}
}
.toBottomFade {
	animation: toBottomFade 0.5s ease-out;
}

@keyframes toBottomFade {
	to {
		opacity: 0.2;
		transform: translate3d(0, 100%, 0);
	}
}

.toLeftEasing {
	animation: toLeftFade 0.5s ease-in-out both;
}
.toRightEasing {
	animation: toRightFade 0.5s ease-in-out both;
}
.toTopEasing {
	animation: toTopFade 0.5s ease-in-out both;
}
.toBottomEasing {
	animation: toBottomFade 0.5s ease-in-out both;
}

/* Scales */
.scaleDown {
	animation: scaleDown 0.5s ease both;
}
@keyframes scaleDown {
	from {
	}
	to {
		opacity: 0;
		transform: scale(0.8);
	}
}
.scaleUp {
	animation: scaleUp 0.5s ease both;
}
@keyframes scaleUp {
	from {
		opacity: 0;
		transform: scale(0.8);
	}
}

.cubeLeftOut {
	transform-origin: 100% 50%;
	animation: cubeLeftOut 0.5s ease-in both;
}
@keyframes cubeLeftOut {
	0% {
	}
	50% {
		animation-timing-function: ease-out;
		transform: translateX(-50%) translateZ(-200px) rotateY(-45deg);
	}
	100% {
		opacity: 0.3;
		transform: translateX(-100%) rotateY(-90deg);
	}
}
.cubeLeftIn {
	transform-origin: 0% 50%;
	animation: cubeLeftIn 0.5s ease-in both;
}
@keyframes cubeLeftIn {
	0% {
		opacity: 0.3;
		transform: translateX(100%) rotateY(90deg);
	}
	50% {
		animation-timing-function: ease-out;
		transform: translateX(50%) translateZ(-200px) rotateY(45deg);
	}
}
.cubeRightOut {
	transform-origin: 0% 50%;
	animation: cubeRightOut 0.5s ease-in both;
}
@keyframes cubeRightOut {
	0% {
	}
	50% {
		animation-timing-function: ease-out;
		transform: translateX(50%) translateZ(-200px) rotateY(45deg);
	}
	100% {
		opacity: 0.3;
		transform: translateX(100%) rotateY(90deg);
	}
}

.cubeRightIn {
	transform-origin: 100% 50%;
	animation: cubeRightIn 0.5s ease-in both;
}
@keyframes cubeRightIn {
	0% {
		opacity: 0.3;
		transform: translateX(-100%) rotateY(-90deg);
	}
	50% {
		animation-timing-function: ease-out;
		transform: translateX(-50%) translateZ(-200px) rotateY(-45deg);
	}
}

/** utilities */
.isActive {
	visibility: visible !important;
}
.onTop {
	z-index: 999;
}

/* resets and defaults */
*,
*::before,
*::after {
	box-sizing: border-box;
}
html,
body {
	height: 100%;
	padding: 0;
	margin: 0;
}
body {
	text-align: center;
	background: linear-gradient(45deg, #b066fe, #63e2ff);
	color: #292929;
	font-family: 'Mali', sans-serif;
}
.app {
	display: grid;
	grid-template: auto / 1fr;
	padding: 20px;
  margin: 0 auto;
  width: 50%;
}

@media only screen and (max-width: 680px) {
  .app {
    width: 85%;
  }
}
.view-container {
	background: #f1f1f1;
	border: 0;
	padding: 16px;
	grid-gap: 16px;
	box-shadow: 0 3px 5px 1px rgba(55, 55, 55, 0.25);
}

button {
	margin: 4px;
	padding: 8px 12px;
	border: none;
	border-radius: 3px;
	text-transform: uppercase;
	background: linear-gradient(45deg, #2f80ed, #56ccf2);
	font-family: inherit;
	color: white;
	cursor: pointer;
  outline: none;
  transition: all 0.2s ease-out;
  // box-shadow: 0 1px 1px 1px rgba(55, 55, 55, 0.25);
  
  &:hover {
    transform: translateY(-1px);
    // box-shadow: 0 2px 5px 2px rgba(55, 55, 55, 0.25);
  }
}

#root {
	height: 100%;
}
h3 {
	margin: 0 0 16px;
}

.view-slide {
	padding: 8px 16px;
}
View Compiled
// --------------------------------------------------------------------
// APP - Please read for usage:

// Change these with any keys from animationTypes array below 
// or pass these pre selected ones into ViewSlides 
const demoAnimationsOne = {	nextAnimation: 'cubeToLeft', prevAnimation: 'toRightEasing' };
const demoAnimationsTwo = { nextAnimation: 'toLeftEasing', prevAnimation: 'cubeToRight' };
const demoAnimationsThree = { nextAnimation: 'scaleDownFromRight', prevAnimation: 'scaleDownFromTop' };
const demoAnimationsFour = { nextAnimation: 'toLeftEasing', prevAnimation: 'toRightEasing' };

// Pass either of these to control if the slide or controls render on top etc
const slidesOnTop = { templateArea: ['slide', 'controls'] };
const controlsOnTop = { templateArea: ['controls', 'slide']};
// Or create your own custom grid template area.
const customControls = { templateArea: ['', '']};

// You can also forego this, and nest the controls inside the slide to use in each slide.

// ViewContainer allows render={(args =>)} or child as function
const App = () => (
	<div className="app">
		<ViewContainer
      // PASS IN GRID TEMPLATE AREAS HERE:
			gridProperties={slidesOnTop}
			slideTotal={3}
			render={({ prevSlide, nextSlide, ...props }) => (
				<React.Fragment>
					{items.map((item, index) => (
						<ViewSlide
							key={item.title}
							slideIndex={index}
              // PASS IN ANIMATIONS HERE:
							animationType={demoAnimationsFour}
							slideClassname="view-slide"
							{...props}
						>
							<React.Fragment>
								<h3>{item.title}</h3>
								<div>{item.content}</div>
							</React.Fragment>
						</ViewSlide>
					))}
					<ViewControls>
						<button onClick={prevSlide}>Prev</button>
						<button onClick={nextSlide}>Next</button>
					</ViewControls>
				</React.Fragment>
			)}
		/>
	</div>
);

const items = [
	{
		title: 'Title One',
		content: (
			<div>
				Horses can sleep both lying down and standing up. Domestic horses have a
				lifespan of around 25 years. A 19th century horse named 'Old Billy' is
				said to have lived 62 years.
			</div>
		)
	},
	{
		title: 'Title Two',
		content: (
			<div>
				Rhino skin maybe thick but it can be quite sensitive to sunburns and
				insect bites which is why they like wallow so much – when the mud dries
				it acts as protection from the sunburns and insects.
			</div>
		)
	},
	{
		title: 'Title Three',
		content: (
			<div>
				If you’re looking to hunt a unicorn, but don’t know where to begin, try
				Lake Superior State University in Sault Ste. Marie, Michigan. Since
				1971, the university has issued permits to unicorn questers.
			</div>
		)
	}
];

// --------------------------------------------------------------------
// VIEW CONTAINER
const defaultProps = {
	gridProperties: { columnSizes: '1fr', rowSizes: 'auto' }
};
const initialState = {
	prevActiveIndex: 0,
	activeIndex: 0,
	isAnimating: false
};
class ViewContainer extends React.Component {
	state = initialState;
	static defaultProps = defaultProps;

	// ref for container node
	container;

	shouldComponentUpdate(nextProps, nextState) {
		return (
			nextState.activeIndex !== this.state.activeIndex ||
			nextState.isAnimating !== this.state.isAnimating ||
			nextProps.slideTotal !== this.props.slideTotal
		);
	}

	render() {
		const { children, render } = this.props;
		const { activeIndex, prevActiveIndex, isAnimating } = this.state;

    // todo: move this out of render
		const renderProps = {
			activeIndex,
			prevActiveIndex,
			isAnimating,
			handleAnimationEnd: this.handleAnimationEnd,
			prevSlide: this.onPrevSlide,
			nextSlide: this.onNextSlide
		};

		const defaultStyles = {
			display: 'grid',
			position: 'relative',
			overflow: 'hidden',
			perspective: '1200px'
		};

		const propStyles = this.assignGridValues();
		const finalStyles = {
			...defaultStyles,
			...propStyles
		};

		return (
			<div
				style={finalStyles}
				className="view-container"
				ref={el => (this.container = el)}
			>
				{render
					? render(renderProps)
					: isFunction(children)
						? children(renderProps)
						: null}
			</div>
		);
	}

	assignGridValues = () => {
		const { columnSizes, rowSizes, templateArea } = this.props.gridProperties;
		const obj = {
			gridTemplateColumns: columnSizes,
			gridTemplateRows: rowSizes
		};

		// For grid-template-areas prop, syntax needs to be "'value' 'value'" etc to be interpreted correctly.
		if (templateArea.length) {
			let templateString = '';
			templateArea.forEach(val => (templateString += `'${val}' `));

			return {
				...obj,
				gridTemplateAreas: `${templateString}`
			};
		}
		return obj;
	};

	onPrevSlide = () => {
		if (this.state.isAnimating || this.props.slideTotal < 2) {
			return;
		}

		this.setState(prevState => {
			if (prevState.activeIndex === 0) {
				return prevState;
			} else {
				return {
					...prevState,
					isAnimating: true,
					prevActiveIndex: prevState.activeIndex,
					activeIndex: prevState.activeIndex - 1
				};
			}
		});
	};

	// handle next slide
	onNextSlide = () => {
		if (this.state.isAnimating || this.props.slideTotal < 2) {
			return;
		}

		this.setState(prevState => {
			if (prevState.activeIndex === this.props.slideTotal - 1) {
				return prevState;
			} else {
				return {
					...prevState,
					isAnimating: true,
					prevActiveIndex: prevState.activeIndex,
					activeIndex: prevState.activeIndex + 1
				};
			}
		});
	};

	handleAnimationEnd = () => {
		this.setState({
			isAnimating: false
		});
	};
}

// ---------------------------------------------------------------------------
// VIEW SLIDE

const defaultSlideProps = {
	animationType: {
		nextAnimation: 'fromRightFade',
		prevAnimation: 'fromLeftFade',
	},
	gridAreaValue: 'slide'
};

class ViewSlide extends React.Component {
	static defaultProps = defaultSlideProps;
	slide;

	render() {
		const {
			activeIndex,
			slideIndex,
			slideClassname,
			gridAreaValue,
			children
		} = this.props;
		const isActive = activeIndex === slideIndex;

		const slideStyles = {
			gridArea: gridAreaValue,
			overflow: 'hidden',
			visibility: 'hidden',
			transformStyle: 'preserve-3d',
			backfaceVisibility: 'hidden',
			willChange: 'transform',
			zIndex: isActive ? 1 : 0
		};

		// Assign prop, animation and direction classnames.
		const slideClassNames = pipe(
			this.assignPropClassnames,
			this.assignAnimationClassnames
		)(slideClassname).join(' ');

		return (
			<div
				style={slideStyles}
				className={slideClassNames.length ? slideClassNames : undefined}
				onAnimationEnd={this.handleAnimationEnd}
				ref={el => (this.slide = el)}
			>
				{children}
			</div>
		);
	}

	// Assign any classnames passed as props.
	assignPropClassnames = (slideClassname) => {
		let classes = [];
		if (slideClassname) {
			// handle array of classes
			if (Array.isArray(slideClassname)) {
				classes = [...slideClassname];
			} else {
				classes.push(slideClassname);
			}
		}

		return classes;
	};

	// Assign relavant animation classes based on slide direction
	// and which animation is applied for both prev and next actions
	assignAnimationClassnames = (classes) => {
		const { activeIndex, prevActiveIndex, slideIndex } = this.props;

		const isExitingSlide = slideIndex === prevActiveIndex;
		const isEnteringSlide = slideIndex === activeIndex;
		const directionalClass = this.getDirectionalClassname(isEnteringSlide);

		let animationClasses = [];
		if (this.props.isAnimating) {
			if (isExitingSlide) {
				animationClasses = [...classes, ...directionalClass, 'isActive'];
			} else if (isEnteringSlide) {
				animationClasses = [...classes, ...directionalClass, 'isActive'];
			}
		} else if (isEnteringSlide) {
			animationClasses = [...classes, 'isActive'];
		} else {
			return classes;
		}

		return animationClasses;
	};

	getDirectionalClassname = (isEnteringSlide) => {
		const { activeIndex, prevActiveIndex } = this.props;
		const isAnimatingForward = activeIndex > prevActiveIndex ? true : false;

		const { enteringClass, exitingClass } = this.getAnimationType(
			isAnimatingForward
		);

		if (isEnteringSlide) {
			return enteringClass;
		} else {
			return exitingClass;
		}
	};

	// Retrieve the correct enter and exiting class names from animation object
	getAnimationType = (isAnimatingForward) => {
		const { nextAnimation, prevAnimation } = this.props.animationType;

		// Get Animation key based on if prev or next slide is called
		const animationKey = isAnimatingForward ? nextAnimation : prevAnimation;
		// Once we have the key -> get the containing object for that key and retrieve classnames
		const animationObj = animations.find(arr => arr[animationKey]);
		const { enteringClass, exitingClass } = animationObj[animationKey];

		return {
			enteringClass,
			exitingClass
		};
	};

	// Handle resetting of slides and any callback on animEnd
	handleAnimationEnd = (e) => {
		const { handleAnimationEnd } = this.props;
		if (e && e.target !== this.slide) return;

		this.resetSlides();
		if (handleAnimationEnd) {
			handleAnimationEnd();
		}
	};

	// Need to find better way to handle reset?? (too implicit?)
	resetSlides = () => {
		const { prevActiveIndex, slideIndex } = this.props;
		const wasPreviousActive = prevActiveIndex === slideIndex;

		if (wasPreviousActive) {
			if (this.slide.classList.contains('isActive')) {
				this.slide.classList.remove('isActive');
			}
		}
	};
}

// -------------------------------------------------------------------------------------
// VIEW CONTROLS

function ViewControls({ children,	gridAreaValue }) {
	const propStyles = {
		position: 'relative',
		gridArea: gridAreaValue
	};
	return <div style={propStyles}>{children}</div>;
}

// --------------------------------------------------------------------------------------
// HELPERS

const pipe = (...fns) => (x) =>	fns.reduce((v, fn) => fn(v), x);
const isFunction = (value) =>	typeof value === 'function';

// --------------------------------------------------------------------------------------
// ANIMATIONS

const animations = [
	{
		fromRight: {
			enteringClass: ['fromRight'],
			exitingClass: ['toLeft']
		}
	},
	{
		fromLeft: {
			enteringClass: ['fromLeft'],
			exitingClass: ['toRight']
		}
	},
	{
		fromBottom: {
			enteringClass: ['fromBottom'],
			exitingClass: ['toTop']
		}
	},
	{
		fromTop: {
			enteringClass: ['fromTop'],
			exitingClass: ['toBottom']
		}
	},
	{
		fromRightFade: {
			enteringClass: ['fromRightFade'],
			exitingClass: ['toLeftFade']
		}
	},
	{
		fromLeftFade: {
			enteringClass: ['fromLeftFade'],
			exitingClass: ['toRightFade']
		}
	},
	{
		fromBottomFade: {
			enteringClass: ['fromBottomFade'],
			exitingClass: ['toTopFade']
		}
	},
	{
		fromTopFade: {
			enteringClass: ['fromTopFade'],
			exitingClass: ['toBottomFade']
		}
	},
	{
		toLeftEasing: {
			enteringClass: ['fromRight'],
			exitingClass: ['toLeftEasing', 'onTop']
		}
	},
	{
		toRightEasing: {
			enteringClass: ['fromLeft'],
			exitingClass: ['toRightEasing', 'onTop']
		}
	},
	{
		toTopEasing: {
			enteringClass: ['fromBottom'],
			exitingClass: ['toTopEasing', 'onTop']
		}
	},
	{
		toBottomEasing: {
			enteringClass: ['fromTop'],
			exitingClass: ['toBottomEasing', 'onTop']
		}
	},
	{
		scaleDownFromRight: {
			enteringClass: ['fromRight', 'onTop'],
			exitingClass: ['scaleDown']
		}
	},
	{
		scaleDownFromLeft: {
			enteringClass: ['fromLeft', 'onTop'],
			exitingClass: ['scaleDown']
		}
	},
	{
		scaleDownFromTop: {
			exitingClass: ['scaleDown'],
			enteringClass: ['fromTop', 'onTop']
		}
	},
	{
		scaleDownFromBottom: {
			exitingClass: ['scaleDown'],
			enteringClass: ['fromBottom', 'onTop']
		}
	},
	{
		cubeToLeft: {
			enteringClass: ['cubeLeftIn'],
			exitingClass: ['cubeLeftOut', 'onTop']
		}
	},
	{
		cubeToRight: {
			enteringClass: ['cubeRightIn'],
			exitingClass: ['cubeRightOut', 'onTop']
		}
	},
	{
		cubeToTop: {
			enteringClass: ['cubeTopIn'],
			exitingClass: ['cubeTopOut', 'onTop']
		}
	},
	{
		cubeToBottom: {
			enteringClass: ['cubeBottomIn'],
			exitingClass: ['cubeBottomOut', 'onTop']
		}
	}
];

ReactDOM.render(
  <App />,
  document.querySelector('#root')
);
View Compiled

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/react/16.6.0/umd/react.development.js
  2. https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.6.0/umd/react-dom.production.min.js