I cannot find how to do a 3D carousel (aka slideshow) with React being able to show at least three elements.
There seems not to be up-to-date libraries or components for that in npm :(
Here is what it should look like:
After experimenting for a while, here is how I manage to do it using framer motion:
import './styles.css';
import { AnimatePresence, motion } from 'framer-motion';
import { useState } from 'react';
export default function App() {
const [[activeIndex, direction], setActiveIndex] = useState([0, 0]);
const items = ['🍔', '🍕', '🌭', '🍗'];
// we want the scope to be always to be in the scope of the array so that the carousel is endless
const indexInArrayScope =
((activeIndex % items.length) + items.length) % items.length;
// so that the carousel is endless, we need to repeat the items twice
// then, we slice the the array so that we only have 3 items visible at the same time
const visibleItems = [...items, ...items].slice(
indexInArrayScope,
indexInArrayScope + 3
);
const handleClick = newDirection => {
setActiveIndex(prevIndex => [prevIndex[0] + newDirection, newDirection]);
};
return (
<div className="main-wrapper">
<div className="wrapper">
{/*AnimatePresence is necessary to show the items after they are deleted because only max. 3 are shown*/}
<AnimatePresence mode="popLayout" initial={false}>
{visibleItems.map((item) => {
// The layout prop makes the elements change its position as soon as a new one is added
// The key tells framer-motion that the elements changed its position
return (
<motion.div
className="card"
key={item}
layout
custom={{
direction,
position: () => {
if (item === visibleItems[0]) {
return 'left';
} else if (item === visibleItems[1]) {
return 'center';
} else {
return 'right';
}
},
}}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: 1 }}
>
{item}
</motion.div>
);
})}
</AnimatePresence>
</div>
<div className="buttons">
<motion.button
whileTap={{ scale: 0.8 }}
onClick={() => handleClick(-1)}
>
◀︎
</motion.button>
<motion.button whileTap={{ scale: 0.8 }} onClick={() => handleClick(1)}>
▶︎
</motion.button>
</div>
</div>
);
}
const variants = {
enter: ({ direction }) => {
return { scale: 0.2, x: direction < 1 ? 50 : -50, opacity: 0 };
},
center: ({ position }) => {
return {
scale: position() === 'center' ? 1 : 0.7,
x: 0,
zIndex: zIndex[position()],
opacity: 1,
};
},
exit: ({ direction }) => {
return { scale: 0.2, x: direction < 1 ? -50 : 50, opacity: 0 };
},
};
const zIndex = {
left: 1,
center: 2,
right: 1,
};
Here is a code sandbox with the solution:
https://codesandbox.io/s/react-3d-carousel-wth-framer-motion-rtn6vx?file=/src/App.js
Related
I have a list of two elements scenes rendered as <Rnd /> components from "react-rnd". What I'd like to happen is when I drag one element close to another one they should swap positions, in the list and in the UI. so [first, second] becomes [second, first]. The <Rnd /> position is controlled, this is the code I'm using:
import { useState } from "react";
import "./styles.css";
import { Rnd, Position, DraggableData } from "react-rnd";
interface IScene {
id: string;
name: string;
position: Position;
}
function Scene({
scene,
activeScene,
setActiveScene,
onDrag
}: {
scene: IScene;
activeScene: string;
setActiveScene: (id: string) => void;
onDrag: (d: DraggableData) => void;
}) {
const [dragged, setDragged] = useState(false);
return (
<Rnd
default={{
x: 0,
y: 0,
width: "200px",
height: "100px"
}}
position={scene.position}
onDragStart={() => setDragged(true)}
onDragStop={() => setDragged(false)}
onDrag={(_, d) => onDrag(d)}
onMouseDown={() => setActiveScene(scene.id)}
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
backgroundColor: "gray",
transition: dragged ? "" : "transform 0.5s",
border: "1px solid",
borderColor: activeScene === scene.id ? "white" : "gray",
zIndex: activeScene === scene.id ? "1" : "0"
}}
>
{scene.name}
</Rnd>
);
}
const initialScenesState = [
{
id: "1",
name: "first",
position: {
x: 0,
y: 0
}
},
{
id: "2",
name: "second",
position: {
x: 200,
y: 0
}
}
];
export default function App() {
const [scenes, setScenes] = useState<IScene[]>(initialScenesState);
const [activeScene, setActiveScene] = useState<string>("");
const handleStackScenes = () => {
setScenes((scenes) => {
let currentPosition = 0;
return scenes.map((scene) => {
const result = {
...scene,
position: {
x: currentPosition,
y: 0
}
};
currentPosition += 200;
return result;
});
});
};
const swapScenes = (first: IScene, second: IScene) => {
setScenes((scenes) => {
return scenes.map((scene) => {
if (scene.id === first.id) {
return second;
} else if (scene.id === second.id) {
return first;
} else return scene;
});
});
handleStackScenes();
};
const handleDrag = (scene: IScene) => (d: DraggableData) => {
console.log(d.x);
for (let i = 0; i < scenes.length; i++) {
if (
Math.abs(scenes[i].position.x - d.x) < 30 &&
scenes[i].id !== scene.id
) {
swapScenes(scene, scenes[i]);
}
}
};
console.log(scenes);
return (
<div className="App">
{scenes.map((scene) => (
<Scene
key={scene.id}
scene={scene}
activeScene={activeScene}
setActiveScene={setActiveScene}
onDrag={handleDrag(scene)}
/>
))}
</div>
);
}
The problem that I'm facing with my code, is that when I drag the left element to the right one, the swap happens exactly how I wanted, in the other way around the swap happens but I don't see a transition effect, when I checked what happens on the console, it seems that on the second case, the dom elements don't swap, but just the content and it the first case the actual dom elements move. What am I doing wrong?
EDIT: CodeSandBox
I have two issues.
Onload, the bottom links should render only once, but they are rendered twice.
When clicking the box links at the bottom, the current links should smoothly fade out and the new ones fade in.
Instead, the new ones initially fade in stacked with the previous ones.
what am I doing wrong here?
Sandbox Link
Relevant animation code:
export default function DynamicScreen({}: IProps) {
const { screenKey } = useParams()
const [isLeavingScreen, setIsLeavingScreen] = useState(false)
const [screen, setScreen] = useState<IDynamicScreen>()
const [screenLinks, setScreenLinks] = useState<IDynamicScreenLink[]>([])
const navigate = useNavigate()
const isFirstRenderRef = useRef(true)
useEffect(() => {
if (isFirstRenderRef.current) {
isFirstRenderRef.current = false
return
}
}, [])
useEffect(() => {
const loadScreen = async () => {
console.log("Screen - Loading")
const { data: _screen } = await api.getScreen(screenKey!)
setScreen(_screen)
setScreenLinks(
[_screen.link1?.[0], _screen.link2?.[0], _screen.link3?.[0]].filter(
Boolean,
),
)
console.log("Screen - Loaded")
}
loadScreen()
}, [screenKey])
const springApiLory = useSpringRef()
const springLoryStyle = useSpring({
from: { opacity: 0, scale: 0 },
to: { opacity: 1, scale: 1 },
ref: springApiLory,
})
const springApiTitle = useSpringRef()
const springTitleStyle = useSpring({
from: { opacity: 0, transform: "translateY(-25px)" },
to: !isLeavingScreen
? { opacity: 1, transform: "translateY(0)" }
: {
opacity: 0,
transform: "translateY(-25px)",
},
ref: springApiTitle,
})
const springApiScriptHtml = useSpringRef()
const springScriptHtmlStyle = useSpring({
from: { opacity: 0, transform: "translateX(-25px)" },
to: !isLeavingScreen
? { opacity: 1, transform: "translateY(0)" }
: {
opacity: 0,
transform: "translateX(-25px)",
},
ref: springApiScriptHtml,
})
const springApiLinks = useSpringRef()
const linkTransition = useTransition(isLeavingScreen ? [] : screenLinks, {
ref: springApiLinks,
from: { opacity: 0 },
enter: { opacity: 1 },
leave: isLeavingScreen ? { opacity: 0 } : {},
trail: 100,
})
const chain = [
springApiLory,
springApiTitle,
springApiScriptHtml,
springApiLinks,
]
useChain(
!isLeavingScreen ? chain : chain.reverse(),
[...Array(chain.length).keys()].map(calcStaggerDelay),
)
const goToLink = (link: string) => {
setIsLeavingScreen(true)
setTimeout(() => {
setScreenLinks([])
navigate(`/${link}`)
setIsLeavingScreen(false)
}, 1000)
}
if (!screen) return null
return (
<>
<div className="dynamic-screen-top">
<div className="left">
<animated.div style={springTitleStyle}>
<h1>{screen.title}</h1>
</animated.div>
<animated.div style={springScriptHtmlStyle}>
<div
dangerouslySetInnerHTML={{
__html: screen.scriptHtml,
}}
/>
</animated.div>
{/* TODO screen.actions */}
</div>
<animated.div style={springLoryStyle} className="right">
💁♀️
</animated.div>
</div>
<div className="dynamic-screen-bottom">
{linkTransition((style, screenLink) => (
<animated.div
style={style}
className="dynamic-screen-bottom-link-wrap"
>
<DynamicScreenLink
linkKey={screenLink!.key}
onClick={goToLink}
text={screenLink!.text}
imageUrl={screenLink!.imageUrl}
size={screenLink!.size}
/>
</animated.div>
))}
</div>
</>
)
}
interface IProps {}
const calcStaggerDelay = (i: number) => i * 0.1 + 0.1
I don't know if this helps you continue, but what i did first of all is to set screenLinks the to an empty array when navigating to new page
const goToLink = (link: string) => {
setScreenLinks([]);
setIsLeavingScreen(true);
setTimeout(() => {
navigate(`/${link}`);
setIsLeavingScreen(false);
}, 300);
};
and also, if isLeavingScreen, then set the width to zero, in order for the next screenLinks to take whole space
const linkTransition = useTransition(isLeavingScreen ? [] : screenLinks, {
ref: springApiLinks,
from: { opacity: 0 },
enter: { opacity: 1 },
leave: isLeavingScreen ? { display: "none", width: 0 } : {},
trail: 100
});
demo
I'm using react.js, Typescript, chakra and animation framer.
There is a MotionSwipe component that allows you to swipe right and left. It can be dragged to swipe the Box of a child element.
We want the Box to move to the right or left not only by dragging, but also by pressing a button.
In the function onClickSwipe, which is called when the button is pressed, I executed the animateCardSwipe function to make it swipe to the right. However, the animation is too fast for me to see. I would like to slow down the animation of the Box moving to the right when the button is pressed.
import { Button,Box } from '#chakra-ui/react';
import { PanInfo, useMotionValue, useTransform } from 'framer-motion';
import { NextPage } from 'next';
import { MotionSwipe } from 'components/MotionSwipe';
import React, { useState } from 'react';
const Component: React.VoidFunctionComponent = () => {
const [cards, setCards] = useState([
{ text: 'Up or down', background: 'red' },
{ text: 'Left or right', background: 'green' },
{ text: 'Swipe me!', background: 'gray' },
]);
const [dragStart, setDragStart] = useState<{ axis: string | null; animation: { x: number; y: number } }>({
axis: null,
animation: { x: 0, y: 0 },
});
const x = useMotionValue(0);
const y = useMotionValue(0);
const onDirectionLock = (axis: string | null) => setDragStart({ ...dragStart, axis: axis });
const animateCardSwipe = (animation: { x: number; y: number }) => {
setDragStart({ ...dragStart, animation });
setTimeout(() => {
setDragStart({ axis: null, animation: { x: 0, y: 0 } });
x.set(0);
y.set(0);
setCards([...cards.slice(0, cards.length - 1)]);
}, 200);
};
const onDragEnd = (info: PanInfo) => {
if (dragStart.axis === 'x') {
if (info.offset.x >= 400) animateCardSwipe({ x: 300, y: 0 });
else if (info.offset.x <= -400) animateCardSwipe({ x: -300, y: 0 });
} else {
if (info.offset.y >= 100) animateCardSwipe({ x: 0, y: 100 });
else if (info.offset.y <= -100) animateCardSwipe({ x: 0, y: -100 });
}
};
const rotate = useTransform(x, [-700, 700], [-90, 90]);
const onClickSwipe = () => {
animateCardSwipe({ x: 500, y: 0 });
};
return (
<>
<Box display="flex" justifyContent="center" width="300px">
{cards.map((card, index) =>
index === cards.length - 1 ? (
<MotionSwipe
animate={dragStart.animation}
card={card}
key={index}
onDirectionLock={(axis: string) => onDirectionLock(axis)}
onDragEnd={(e: MouseEvent, info: PanInfo) => onDragEnd(info)}
style={{ x, y, zIndex: index, rotate: rotate }}
>
<Box background={card.background} height="300px" width="300px"></Box>
</MotionSwipe>
) : (
<MotionSwipe
card={card}
key={index}
style={{
zIndex: index,
}}
>
<Box background={card.background} height="300px" width="300px"></Box>
</MotionSwipe>
),
)}
</Box>
<Button onClick={onClickSwipe}>
○
</Button>
<Button>✖︎</Button>
</>
);
};
const SwipeBox: NextPage = () => {
return <Component />;
};
export default SwipeBox;
interface Props {
animate?: { x: number; y: number };
onDirectionLock?: (axis: 'x' | 'y') => void;
onDragEnd?: (e: MouseEvent, info: PanInfo) => void;
style: { x?: MotionValue; y?: MotionValue; zIndex: number; rotate?: MotionValue };
card: { text: string; background: string };
}
export const MotionSwipe: React.FunctionComponent<Props> = (props) => {
return (
<MotionBox
animate={props.animate}
background="white"
borderTopRadius="8px"
className="card"
display="grid"
drag
dragConstraints={{ left: 0, right: 0, top: 0, bottom: 0 }}
dragDirectionLock
left={0}
onDirectionLock={props.onDirectionLock}
onDragEnd={props.onDragEnd}
placeItems="center center"
position="absolute"
style={{ ...props.style }}
top={0}
transition={{ ease: [0.6, 0.05, -0.01, 0.9], duration: 3 }}
>
{props.children}
</MotionBox>
);
};
Yes, it's ok when it moves fast. How to change:
set useState variable to check if the button was pressed.
For example: const [pressed, setPressed] = useState(false);
If the button was pressed, you set setPressed(true)
If pressed(true) you add some transition or animation props with duration.
If you don't have duration, your element will triggers instantly after button click.
While using Framer Motion API to create interaction and animations on my site, I can not find how to use it in order to trigger an animation when something is on the screen.
For example, this SVG draws correctly, but Framer does not wait for the element to be on the viewport and triggers it right after loading site:
import React, { Component } from 'react'
import { motion } from "framer-motion";
class IsometricScreen extends Component {
constructor() {
super()
this.icon = {
hidden: { pathLength: 0 },
visible: { pathLength: 1 }
}
}
render() {
return (
<motion.svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 1000" className="svg-mobile">
<motion.path
d="M418,988.93H82c-39.76,0-72-32.24-72-72V83.07c0-39.76,32.24-72,72-72h336c39.76,0,72,32.24,72,72v833.86
C490,956.69,457.76,988.93,418,988.93z"
variants={this.icon}
initial="hidden"
animate="visible"
transition={{
default: { duration: 2, ease: "easeInOut" }
}}
/>
</motion.svg>
)
}
}
export default IsometricScreen
Does Framer have a viewport detection triggerer to be implemented here?
Alternatively, you can use Intersection Observer, blends pretty well with React and framer motion.
import { useInView } from "react-intersection-observer"; // 1.9K gzipped
import { motion, useAnimation } from "framer-motion";
const Component = () => {
const animation = useAnimation();
const [ref, inView, entry] = useInView({ threshold: 0.1 });
useEffect(() => {
if (inView) {
animation.start("visible");
} else {
animation.start("hidden");
}
}, [animation, inView]);
const variants = {
visible: {
y: 0,
opacity: 1,
transition: { duration: 0.5, delayChilden: 0.2, staggerChildren: 0.1 },
},
hidden: {
y: enter,
opacity: 0,
},
}
return (
<motion.div
ref={ref}
animate={animation}
initial="hidden"
variants={{variants}}
/>
);
}
You can also refine your animation by looking at entry object (entering from top or bottom, etc)
framer-motion has built-in support for this use case since version 5.3.
Here's a CodeSandbox demonstrating the pattern: https://codesandbox.io/s/framer-motion-animate-in-view-5-3-94j13
Relevant code:
function FadeInWhenVisible({ children }) {
return (
<motion.div
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
transition={{ duration: 0.3 }}
variants={{
visible: { opacity: 1, scale: 1 },
hidden: { opacity: 0, scale: 0 }
}}
>
{children}
</motion.div>
);
}
Usage:
<FadeInWhenVisible>
<Box />
</FadeInWhenVisible>
I have finally solved this with a tiny functional component:
function inViewport() {
const isInViewport = el => {
const rect = el.getBoundingClientRect()
const vertInView = (rect.top <= window.innerHeight) && ((rect.top + rect.height) >= 0)
const horInView = (rect.left <= window.innerWidth) && ((rect.left + rect.width) >= 0)
return (vertInView && horInView)
}
this.elms = document.querySelectorAll('.showOnScreen')
window.addEventListener("scroll", () => {
this.elms.forEach(elm => isInViewport(elm) ? elm.classList.add('visible') : elm.classList.remove('visible'))
})
}
export default inViewport
I'm attempting to animate a card in and out. If there is a selected value, the card appears. If the selected item is undefined, the card disappears. I got this to work.
The next thing I tried to do is make it that if the selection changed (A new item) - animate out a card and animate in a new one. I'm confused on how to make this work... here is what I've attempted that kind of works.
Clearly I'm not understanding how this should be done. I'm wondering if I need to break this up into two cards and run useChain.
const App: React.FC = () => {
//...
const [selectedItem, setSelectedItem] = useState<TimelineItem | undefined>(undefined);
const [lastSelectedItem, setLastSelectedItem] = useState<TimelineItem>({
content: '',
start: new Date(),
id: 0,
});
//...
const transitions = useTransition(
[selectedItem, lastSelectedItem],
item => (item ? item.id : 0),
{
from: { opacity: 0 },
enter: { opacity: 1 },
leave: { opacity: 0 },
}
);
return (
<Timeline
onItemSelect={item => {
if (selectedItem) setLastSelectedItem(selectedItem);
setSelectedItem(item);
}}
/>
{transitions.map(({ item, key, props }) => {
return (
item && (
<animated.div style={props}>
{item === selectedItem ? (
<ItemDetails
item={selectedItem} // If the selected item is undefined, this will not be running (happens when unselecting something)
groups={groups}
key={key || undefined} // key becomes undefined since item is
></ItemDetails>
) : (
false && ( // The last item never shows, it still has the data for the lastSelectedItem (For the fade out while the new Item is being shown or there is no new item).
<ItemDetails
item={lastSelectedItem}
groups={groups}
key={key || undefined}
></ItemDetails>
)
)}
</animated.div>
)
);
})}
);
};
If I understand you well, you want to display the state of an array. New elements fade in and old one fades out. This is the functionality the Transition created for. I think it can be done a lot simpler. I would change the state managment and handle the array in the state. And the render should be a lot simpler.
UPDATE:
I created an example when the animation of the entering element wait for the animation of the leaving element to finish.
I made it with interpolation. The o value changes from 0 to 1 for enter, and 1 to 2 for leave. So the opacity will change:
leave: 1 -> 0 -> 0
enter: 0 -> 0 -> 1
Here is the code:
import React, { useState, useEffect } from "react";
import { useTransition, animated } from "react-spring";
import ReactDOM from "react-dom";
import "./styles.css";
function App() {
const [cards, set] = useState(["A"]);
useEffect(() => {
setInterval(() => {
set(cards => (cards[0] === "A" ? "B" : "A"));
}, 4000);
}, []);
const transitions = useTransition(cards, null, {
from: { o: 0 },
enter: { o: 1 },
leave: { o: 2 },
config: { duration: 2000 }
});
return transitions.map(({ item, key, props }) => (
<div style={{ fontSize: "300px" }}>
<animated.div
style={{
position: "absolute",
opacity: props.o.interpolate([0, 0.5, 1, 1.5, 2], [0, 0, 1, 0, 0])
}}
>
{item}
</animated.div>
</div>
));
}
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
working example: https://codesandbox.io/s/react-spring-staggered-transition-xs9wy