Skip to content

Instantly share code, notes, and snippets.

@mattgperry
Created October 27, 2020 19:09
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mattgperry/6281538936f963296211ff9a84f659b4 to your computer and use it in GitHub Desktop.
Save mattgperry/6281538936f963296211ff9a84f659b4 to your computer and use it in GitHub Desktop.
import {
AnimatePresence,
AnimateSharedLayout,
motion,
useMotionValue,
useIsPresent,
} from "framer-motion";
import * as React from "react";
import { useEffect, useRef, useState } from "react";
import { shuffle } from "lodash";
import styled from "styled-components";
import { animate, distance, clamp, linear, PlaybackControls } from "popmotion";
import move from "array-move";
const MobileContainer = styled.div`
height: 568px;
width: 320px;
background: var(--color-background);
border-radius: 40px;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
`;
export const Example = styled.div`
grid-column: lo-start / ro-end !important;
background: var(--color-grey);
border-radius: 3px;
margin-bottom: 75px !important;
margin-top: 25px;
display: flex;
padding: 20px 0px;
justify-content: center;
`;
const CardContainerSpacing = styled.div`
height: 250px;
margin-bottom: 20px;
`;
const Image = styled(motion.div)`
background: ${({ color }) => `var(${color})`};
height: 180px;
padding-top: 10px;
padding-left: 5px;
[data-isopen="true"] & {
height: 300px;
padding-top: 20px;
padding-left: 20px;
}
`;
const CardContainer = styled(motion.div)`
height: 100%;
cursor: pointer;
overflow: hidden;
position: relative;
background: var(--color-text);
will-change: transform;
[data-isopen="true"] & {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1;
}
`;
const Info = styled(motion.div)`
height: 70px;
position: absolute;
bottom: 0;
right: 0;
left: 0;
padding: 10px;
display: flex;
align-items: flex-start;
[data-isopen="true"] & {
top: 300px;
height: auto;
}
`;
const Icon = styled(motion.div)`
width: 50px;
height: 50px;
background: var(${({ color }) => color});
[data-isopen="true"] & {
width: 120px;
height: 120px;
}
`;
const TextLine = styled.div`
height: 20px;
width: 180px;
border-radius: 10px;
background: white;
`;
const TextContainer = styled(motion.div)`
position: relative;
padding: 5px 0 0 10px;
`;
const Button = styled(motion.div)`
background: var(${({ color }) => color});
width: 120px;
height: 30px;
border-radius: 25px;
`;
function Card({ color, layout = true, onClick }: any) {
const [isOpen, setIsOpen] = useState(false);
const zIndex = useMotionValue(0);
React.useEffect(() => {
if (isOpen) zIndex.set(1);
});
return (
<CardContainerSpacing data-isopen={isOpen}>
<CardContainer
layout={layout}
onClick={() => (onClick ? onClick() : setIsOpen(!isOpen))}
initial={false}
animate={{ borderRadius: isOpen ? 0 : 20 }}
style={{ zIndex }}
onAnimationComplete={() => {
if (!isOpen) zIndex.set(0);
}}
>
<Image layout={layout} color={color}>
<TextContainer layout={layout} style={{ width: 180, opacity: 0.4 }}>
<TextLine
style={{
height: 18,
width: "100%",
marginBottom: 10,
}}
/>
<TextLine
style={{
height: 18,
width: "80%",
}}
/>
</TextContainer>
</Image>
<Info layout={layout}>
<Icon
initial={false}
animate={{ borderRadius: isOpen ? 10 : 5 }}
color={color}
layout={layout}
/>
<TextContainer layout={layout} style={{ width: 180 }}>
<TextLine
style={{
height: 14,
width: "100%",
marginBottom: 10,
opacity: 0.4,
}}
/>
<TextLine
style={{
height: 14,
width: "80%",
opacity: 0.4,
}}
/>
<Button
layout={layout}
color={color}
animate={{ opacity: isOpen ? 1 : 0 }}
style={{ marginTop: 20 }}
/>
</TextContainer>
</Info>
</CardContainer>
</CardContainerSpacing>
);
}
function CardList({ layout, onClick, fromDock = false }: any) {
const isPresent = useIsPresent();
const opacity = useMotionValue(fromDock ? 0 : 1);
useEffect(() => {
if (!fromDock) return;
let current: PlaybackControls;
opacity.attach((v, set) => {
if (isPresent) {
set(v);
current?.stop();
} else if (!current && v < 0.1) {
current = animate({
from: 1,
to: 0,
duration: 200,
ease: linear,
onUpdate: (v) => {
set(v);
},
});
}
});
}, [isPresent]);
return (
<motion.div
transition={{ duration: 0.35, ease: [0.2, 0.05, 0.48, 1] }}
layoutId="container"
style={{
opacity,
padding: 20,
background: "var(--color-background)",
zIndex: 1,
}}
>
<Card color={"--color-a"} layout={layout} onClick={onClick} />
<Card color={"--color-b"} layout={layout} onClick={onClick} />
</motion.div>
);
}
export function ComplexPrototypeExample() {
return (
<Example>
<MobileContainer>
<CardList />
</MobileContainer>
</Example>
);
}
const AppListContainer = styled.div`
display: flex;
flex-wrap: wrap;
padding: 30px;
width: 100%;
position: absolute;
top: 0;
`;
const DummyIcon = styled.div`
border-radius: 15px;
background: var(--color-grey);
width: 57px;
height: 57px;
margin-bottom: 10px;
margin-right: 10px;
&:nth-child(4n) {
margin-right: 0;
}
`;
const ActualIcon = styled(DummyIcon)`
background: var(--color-a);
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
`;
function AppList({ onClick }) {
return (
<AppListContainer>
<DummyIcon />
<DummyIcon />
<DummyIcon />
<DummyIcon />
<DummyIcon />
<DummyIcon />
<ActualIcon
as={motion.div}
layoutId="container"
transition={{ duration: 0.35, ease: [0.2, 0.05, 0.48, 1] }}
onClick={onClick}
>
<div
style={{
borderRadius: "50%",
border: "3px solid white",
width: 35,
height: 35,
}}
/>
</ActualIcon>
</AppListContainer>
);
}
export function AppIconExample({ layout = false }: any) {
const [isAppOpen, setIsOpen] = useState(false);
return (
<Example>
<MobileContainer>
<AnimateSharedLayout type="crossfade">
<AnimatePresence>
{!isAppOpen ? (
<AppList key="list" onClick={() => setIsOpen(true)} />
) : (
<CardList
key="card"
layout={layout}
onClick={() => setIsOpen(false)}
fromDock
/>
)}
</AnimatePresence>
</AnimateSharedLayout>
</MobileContainer>
</Example>
);
}
export function AppIconExampleIncorrect() {
return <AppIconExample layout />;
}
const ShuffleButton = styled(motion.button)`
background: var(--color-a);
padding: 12px 18px;
display: inline-block;
border-radius: 10px;
color: white;
text-decoration: none;
width: auto;
box-shadow: 2px 2px 5px 0px rgba(0, 0, 0, 0.4);
&:focus {
outline: none;
box-shadow: 0 0 0px 2px var(--color-grey), 0 0 0px 4px var(--color-a);
}
`;
const OrderList = styled.ul`
padding: 0 !important;
margin: 0 !important;
list-style: none !important;
`;
const OrderListItem = styled(motion.li)`
margin-bottom: 10px !important;
padding: 0;
background: var(--color-b);
border-radius: 10px;
width: 250px;
list-style: none !important;
`;
export function ListReorder() {
const [order, setOrder] = useState([0, 1, 2, 3]);
return (
<Example>
<div
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
}}
>
<OrderList>
{order.map((item) => (
<OrderListItem
layout
transition={{ type: "spring", duration: 0.9, bounce: 0.4 }}
key={item}
style={{ height: 30 + item * 15 }}
/>
))}
</OrderList>
<ShuffleButton
whileTap={{ scale: 0.95 }}
transition={{ type: false }}
onClick={() => setOrder(shuffle(order))}
>
Shuffle items
</ShuffleButton>
</div>
</Example>
);
}
export function usePositionReorder(initialState) {
const [order, setOrder] = useState(initialState);
// We need to collect an array of height and position data for all of this component's
// `Item` children, so we can later us that in calculations to decide when a dragging
// `Item` should swap places with its siblings.
const positions = useRef([]).current;
const updatePosition = (i, offset) => (positions[i] = offset);
// Find the ideal index for a dragging item based on its position in the array, and its
// current drag offset. If it's different to its current index, we swap this item with that
// sibling.
const updateOrder = (i, dragOffset) => {
const targetIndex = findIndex(i, dragOffset, positions);
if (targetIndex !== i) setOrder(move(order, i, targetIndex));
};
return [order, updatePosition, updateOrder];
}
const buffer = 30;
export const findIndex = (i, yOffset, positions) => {
let target = i;
const { top, height } = positions[i];
const bottom = top + height;
// If moving down
if (yOffset > 0) {
const nextItem = positions[i + 1];
if (nextItem === undefined) return i;
const swapOffset =
distance(bottom, nextItem.top + nextItem.height / 2) + buffer;
if (yOffset > swapOffset) target = i + 1;
// If moving up
} else if (yOffset < 0) {
const prevItem = positions[i - 1];
if (prevItem === undefined) return i;
const prevBottom = prevItem.top + prevItem.height;
const swapOffset = distance(top, prevBottom - prevItem.height / 2) + buffer;
if (yOffset < -swapOffset) target = i - 1;
}
return clamp(0, positions.length, target);
};
export function useMeasurePosition(update) {
// We'll use a `ref` to access the DOM element that the `motion.li` produces.
// This will allow us to measure its height and position, which will be useful to
// decide when a dragging element should switch places with its siblings.
const ref = useRef(null);
// Update the measured position of the item so we can calculate when we should rearrange.
useEffect(() => {
update({
height: ref.current.offsetHeight,
top: ref.current.offsetTop,
});
});
return ref;
}
function DraggableItem({ i, height, updatePosition, updateOrder }) {
const [isDragging, setDragging] = useState(false);
const ref = useMeasurePosition((pos) => updatePosition(i, pos));
return (
<OrderListItem
ref={ref}
layout
initial={false}
transition={{ type: "spring", duration: 0.9, bounce: 0.4 }}
style={{ height, cursor: "pointer" }}
whileHover={{
scale: 1.03,
boxShadow: "0px 3px 3px rgba(0,0,0,0.15)",
}}
whileTap={{
scale: 1.12,
boxShadow: "0px 5px 5px rgba(0,0,0,0.1)",
}}
drag="y"
onDragStart={() => setDragging(true)}
onDragEnd={() => setDragging(false)}
onViewportBoxUpdate={(_, delta) => {
isDragging && updateOrder(i, delta.y.translate);
}}
/>
);
}
const items = [50, 80, 70, 100];
export function ListDragToReorder() {
const [order, updatePosition, updateOrder] = usePositionReorder(items);
return (
<Example>
<div
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
}}
>
<OrderList>
{order.map((height, i) => (
<DraggableItem
key={height}
height={height}
i={i}
updatePosition={updatePosition}
updateOrder={updateOrder}
/>
))}
</OrderList>
</div>
</Example>
);
}
const ContentRow = styled.div`
width: 100%;
height: 8px;
background-color: #999;
border-radius: 10px;
margin-top: 12px;
`;
function Content() {
return (
<motion.div
layout
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<ContentRow />
<ContentRow />
<ContentRow />
</motion.div>
);
}
const AutoItem = styled(motion.li)`
background-color: rgba(214, 214, 214, 0.5);
border-radius: 10px;
display: block;
padding: 20px !important;
margin-bottom: 20px !important;
overflow: hidden;
cursor: pointer;
&:last-child {
margin-bottom: 0px !important;
}
`;
const Avatar = styled(motion.div)`
width: 40px;
height: 40px;
background-color: #666;
border-radius: 20px;
`;
function AutoHeightItem() {
const [isOpen, setIsOpen] = useState(false);
const toggleOpen = () => setIsOpen(!isOpen);
return (
<AutoItem layout onClick={toggleOpen} initial={{ borderRadius: 10 }}>
<Avatar layout />
<AnimatePresence>{isOpen && <Content />}</AnimatePresence>
</AutoItem>
);
}
const AutoContainer = styled(motion.ul)`
width: 300px;
display: flex;
flex-direction: column;
background: white;
padding: 20px;
border-radius: 25px;
margin: 0 !important;
`;
export function AnimateHeightAuto() {
return (
<Example
style={{
height: 400,
alignItems: "center",
background: "var(--color-b)",
}}
>
<AnimateSharedLayout>
<AutoContainer layout initial={{ borderRadius: 25 }}>
<AutoHeightItem />
<AutoHeightItem />
</AutoContainer>
</AnimateSharedLayout>
</Example>
);
}
const UnderlineContainer = styled.div`
display: flex;
justify-content: center;
align-items: center;
transform: translateZ(0);
margin: 20px 0;
`;
const Underline = styled(motion.div)`
width: 100%;
height: 4px;
position: absolute;
bottom: -6px;
background: var(--color-a);
`;
const UnderlineItem = styled(motion.div)`
font-size: 32px;
margin-left: 20px;
position: relative;
cursor: pointer;
`;
export const screens = ["Home", "Calendar", "Mail"];
export function Menu() {
const [selected, setSelected] = useState(0);
return (
<Example>
<AnimateSharedLayout>
<UnderlineContainer>
{screens.map((title, i) => (
<UnderlineItem
key={i}
isSelected={i === selected}
initial={false}
animate={{
color: i === selected ? "var(--color-a)" : "var(--color-text)",
}}
className="hl"
onClick={() => setSelected(i)}
>
{i === selected && (
<Underline layoutId="underline" style={{ borderRadius: 4 }} />
)}
{title}
</UnderlineItem>
))}
</UnderlineContainer>
</AnimateSharedLayout>
</Example>
);
}
export function TextWidthExample() {
const [isOpen, setIsOpen] = useState(false);
return (
<Example>
<div
style={{
display: "flex",
flexDirection: "column",
height: 450,
justifyContent: "space-between",
alignItems: "center",
}}
>
<p
style={{
width: isOpen ? 150 : 350,
background: "var(--color-text)",
color: "var(--color-background)",
padding: 20,
transition: "width 4s ease-out",
borderRadius: 5,
}}
>
I must not animate layout. I must not animate layout. I must not
animate layout. I must not animate layout. I must not animate layout.
</p>
<ShuffleButton
whileTap={{ scale: 0.95 }}
transition={{ type: false }}
onClick={() => setIsOpen(!isOpen)}
>
Toggle width
</ShuffleButton>
</div>
</Example>
);
}
export function TextWidthCrossfadeExample() {
const [isOpen, setIsOpen] = useState(false);
return (
<Example>
<div
style={{
display: "flex",
flexDirection: "column",
height: 450,
justifyContent: "space-between",
alignItems: "center",
position: "relative",
}}
>
<AnimateSharedLayout type="crossfade">
<AnimatePresence>
<motion.p
key={isOpen ? 150 : 350}
style={{
width: isOpen ? 150 : 350,
background: "var(--color-text)",
color: "var(--color-background)",
padding: 20,
transition: "width 4s ease-out",
borderRadius: 5,
position: "absolute",
top: 0,
}}
layoutId="text"
transition={{ duration: 0.6 }}
>
I must not animate layout. I must not animate layout. I must not
animate layout. I must not animate layout.
</motion.p>
</AnimatePresence>
</AnimateSharedLayout>
<ShuffleButton
whileTap={{ scale: 0.95 }}
transition={{ type: false }}
onClick={() => setIsOpen(!isOpen)}
style={{ position: "absolute", bottom: 0 }}
>
Toggle width
</ShuffleButton>
</div>
</Example>
);
}
const ScaleIndicator = styled(motion.div)`
background-position: 5px 5px;
border-radius: 50px;
`;
export function PositionExample() {
const [isOpen, setIsOpen] = useState(false);
return (
<Example style={{ height: 300 }}>
<div
style={{
display: "flex",
flexDirection: "column",
alignContent: "center",
}}
>
<ScaleIndicator
animate={{ x: isOpen ? 100 : 0 }}
style={{
borderRadius: 50,
width: 200,
height: 200,
backgroundColor: "var(--color-b)",
marginBottom: 10,
padding: 20,
}}
>
<div
style={{
width: 50,
height: 50,
borderRadius: "50%",
background: "var(--color-background)",
}}
/>
</ScaleIndicator>
<ShuffleButton
whileTap={{ scale: 0.95 }}
transition={{ type: false }}
onClick={() => setIsOpen(!isOpen)}
>
Toggle position
</ShuffleButton>
</div>
</Example>
);
}
export function ScaleExample() {
const [isOpen, setIsOpen] = useState(false);
const size = isOpen ? 200 : 100;
return (
<Example style={{ height: 300 }}>
<div
style={{
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
width: 200,
}}
>
<ScaleIndicator
layout
transition={{ duration: 1 }}
style={{
width: size,
height: size,
padding: 20,
backgroundColor: "var(--color-b)",
}}
onClick={() => setIsOpen(!isOpen)}
>
<div
style={{
width: 50,
height: 50,
borderRadius: "50%",
background: "var(--color-background)",
}}
/>
</ScaleIndicator>
<ShuffleButton
whileTap={{ scale: 0.95 }}
transition={{ type: false }}
onClick={() => setIsOpen(!isOpen)}
>
Toggle size
</ShuffleButton>
</div>
</Example>
);
}
export function CorrectedScaleExample() {
const [isOpen, setIsOpen] = useState(false);
const size = isOpen ? 200 : 100;
return (
<Example style={{ height: 300 }}>
<div
style={{
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
width: 200,
}}
>
<ScaleIndicator
layout
transition={{ duration: 1 }}
style={{
width: size,
height: size,
padding: 20,
backgroundColor: "var(--color-b)",
borderRadius: 50,
}}
onClick={() => setIsOpen(!isOpen)}
>
<motion.div
layout
style={{
width: 50,
height: 50,
borderRadius: "50%",
background: "var(--color-background)",
}}
/>
</ScaleIndicator>
<ShuffleButton
whileTap={{ scale: 0.95 }}
transition={{ type: false }}
onClick={() => setIsOpen(!isOpen)}
>
Toggle size
</ShuffleButton>
</div>
</Example>
);
}
export function CorrectedScaleExampleNoStyles() {
const [isOpen, setIsOpen] = useState(false);
const size = isOpen ? 200 : 100;
return (
<Example style={{ height: 300 }}>
<div
style={{
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
width: 200,
}}
>
<ScaleIndicator
layout
transition={{ duration: 1 }}
style={{
width: size,
height: size,
padding: 20,
backgroundColor: "var(--color-b)",
}}
onClick={() => setIsOpen(!isOpen)}
>
<motion.div
layout
style={{
width: 50,
height: 50,
background: "var(--color-background)",
borderRadius: "50%",
}}
/>
</ScaleIndicator>
<ShuffleButton
whileTap={{ scale: 0.95 }}
transition={{ type: false }}
onClick={() => setIsOpen(!isOpen)}
>
Toggle size
</ShuffleButton>
</div>
</Example>
);
}
const DistortionParent = styled.div`
height: 100px;
width: 300px;
background: var(--color-b);
border-radius: 20px;
padding: 10px;
`;
const DistortionChild = styled.div`
width: 80px;
height: 80px;
background: var(--color-background);
transform: translateX(100px);
border-radius: 50%;
`;
export function CoordinateDistortion() {
return (
<Example>
<div>
<code
style={{ marginBottom: 10, display: "block" }}
>{`scaleX: 1`}</code>
<DistortionParent
style={{
marginBottom: 40,
}}
>
<DistortionChild />
</DistortionParent>
<code
style={{ marginBottom: 10, display: "block" }}
>{`scaleX: 0.5`}</code>
<DistortionParent
style={{
transform: "scaleX(0.5)",
transformOrigin: "0% 0%",
}}
>
<DistortionChild />
</DistortionParent>
</div>
</Example>
);
}
export function DragExample() {
return (
<Example>
<motion.div
drag
dragMomentum={false}
style={{
borderRadius: 20,
background: "var(--color-b)",
cursor: "pointer",
width: 100,
height: 100,
userSelect: "none",
WebkitTouchCallout: "none",
WebkitUserSelect: "none",
}}
/>
</Example>
);
}
export function DragExampleIncorrect() {
const [state, setState] = useState(false);
useEffect(() => {
setState(true);
}, []);
return (
<Example>
<motion.div
drag
layout
dragMomentum={false}
style={{
borderRadius: 20,
background: "var(--color-b)",
cursor: "pointer",
width: 100,
height: 100,
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<motion.div
layout
style={{
borderRadius: 10,
background: "var(--color-background)",
cursor: "pointer",
width: state === false ? 49 : 50,
height: 50,
}}
></motion.div>
</motion.div>
</Example>
);
}
export function useViewportWidth() {
const viewportWidth = useRef(0);
useEffect(() => {
const updateViewportWidth = () => {
viewportWidth.current = window.innerWidth;
};
updateViewportWidth();
window.addEventListener("resize", updateViewportWidth);
}, []);
return viewportWidth;
}
export function SharedDragExample() {
const viewportWidth = useViewportWidth();
const [activeHalf, setActiveHalf] = useState("a");
const onViewportBoxUpdate = ({ x }) => {
const halfViewport = viewportWidth.current / 2;
if (activeHalf === "a" && x.min > halfViewport) {
setActiveHalf("b");
} else if (activeHalf === "b" && x.max < halfViewport) {
setActiveHalf("a");
}
};
return (
<AnimateSharedLayout>
<Example style={{ height: 400 }}>
<Zone
color="var(--color-b)"
isSelected={activeHalf === "a"}
onViewportBoxUpdate={onViewportBoxUpdate}
/>
<Zone
color="var(--color-a)"
isSelected={activeHalf === "b"}
onViewportBoxUpdate={onViewportBoxUpdate}
/>
</Example>
</AnimateSharedLayout>
);
}
const HalfContainer = styled.div`
display: flex;
align-items: center;
justify-content: center;
width: 50%;
height: 100%;
position: relative;
`;
const Overlay = styled(motion.div)`
position: absolute;
top: 30px;
left: 30px;
bottom: 30px;
right: 30px;
background: var(--color-background);
border-radius: 10px;
`;
const Box = styled(motion.div)`
width: 100px;
height: 100px;
border-radius: 20px;
position: relative;
z-index: 1;
`;
function Zone({ color, isSelected, onViewportBoxUpdate }) {
return (
<HalfContainer>
<Overlay animate={{ scale: isSelected ? 1.05 : 1 }} />
{isSelected && (
<Box
layoutId="box"
initial={false}
animate={{ backgroundColor: color }}
drag
// Snap the box back to its center when we let go
dragConstraints={{ top: 0, left: 0, right: 0, bottom: 0 }}
// Allow full movememnt outside constraints
dragElastic={1}
onViewportBoxUpdate={onViewportBoxUpdate}
/>
)}
</HalfContainer>
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment