5 min read
I recently came across this beautiful interaction design from Alex Krasikov on X. I couldn't resist the urge to explore every bit of it. So, I decided to hop on it and recreate it with Motion (formerly framer-motion).
This article will briefly explain every tip and trick used to build it.
Timing and Coordination
The success of this interaction lies in the careful coordination of multiple animations. Key timing constants include:
const DEFAULT_DURATION = 0.6;
const ENTER_DURATION = 0.5;
const DEFAULT_DELAY = 2;
These settings ensure:
Card component
We will start with our card component, which contains the details of the providers to match.
type CardProps = {
data: {
title: string;
icon: React.ReactNode;
};
amount: number;
isOutputFiat?: boolean;
};
const Card = ({ data, amount, isOutputFiat }: CardProps) => {
const [y, setY] = useState(8);
const timeoutRef = useRef<NodeJS.Timeout>(undefined);
const fiatAmount = isOutputFiat ? amount : amount * -1;
useEffect(() => {
timeoutRef.current = setTimeout(() => {
setY(0);
}, DEFAULT_DELAY * 1000);
return () => {
clearTimeout(timeoutRef.current);
};
}, []);
return (
<motion.div
initial={{
opacity: 0,
y: isOutputFiat ? 50 : -50,
borderRadius: 12,
}}
animate={{
opacity: 1,
y: isOutputFiat ? y : -y,
borderTopLeftRadius: isOutputFiat ? 0 : 12,
borderTopRightRadius: isOutputFiat ? 0 : 12,
borderBottomLeftRadius: isOutputFiat ? 12 : 0,
borderBottomRightRadius: isOutputFiat ? 12 : 0,
}}
transition={{
duration: ENTER_DURATION,
borderTopLeftRadius: {
duration: DEFAULT_DURATION,
delay: DEFAULT_DELAY,
},
borderTopRightRadius: {
duration: DEFAULT_DURATION,
delay: DEFAULT_DELAY,
},
borderBottomLeftRadius: {
duration: DEFAULT_DURATION,
delay: DEFAULT_DELAY,
},
borderBottomRightRadius: {
duration: DEFAULT_DURATION,
delay: DEFAULT_DELAY,
},
}}
className="w-full flex items-center justify-between gap-4 rounded-xl p-6 shadow-sm"
>
<div className="size-16 border-2 border-gray-100 rounded-full grid place-content-center">
{data.icon}
</div>
<div className="flex-1">
<p className="text-xl font-bold">{data.title}</p>
<p className="text-gray-500 mt-2">
{new Date().toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
})}
</p>
</div>
<p
className={cn("text-xl font-bold", {
"text-teal-600": isOutputFiat,
})}
>
{fiatAmount.toLocaleString("en-US", {
currency: "USD",
style: "currency",
minimumFractionDigits: 0,
})}
</p>
</motion.div>
);
};
Explanation
We initially start with each card being at 50px on the y-axis with 0 opacity and 12px border radius.
With no delay, animate the opacity and the y-axis of the cards as they enter into view.
As we could not set a separate delay for the y-axis, I decided to use the useEffect hook with setTimeout to wait for the progress bar to complete and set the new y-axis of the cards to 0px.
Feel free to explore keyframes to achieve the same outcome with the three states we have.
To get a sequence timeline animation, we ensure that the delay to animate the y and border-radius properties matches the duration of the progress bar.
Tip: We animate the cards from 50px to 8px each on the y-axis to have a 16px gap between them. Then set it to 0px and remove border radius on specific corners get a smooth connect animation.
Switch component
The component that will indicate when the match is pending and completed.
type Direction = "up" | "down";
const arrowVariants: Variants = {
initial: (direction: Direction) => ({ y: direction === "up" ? 8 : -8 }),
animate: (direction: Direction) => ({ y: direction === "up" ? -8 : 8 }),
exit: (direction: Direction) => ({ y: direction === "up" ? -80 : 80 }),
};
const wrapperVariants: Variants = {
initial: { rotate: 0 },
animate: { rotate: 270 },
};
type SwitchButtonProps = {
completed: boolean;
onAnimationComplete?: () => void;
};
const SwitchButton = ({
completed,
onAnimationComplete,
}: SwitchButtonProps) => {
return (
<motion.div
initial={{ scale: 0.2, opacity: 0, filter: "blur(4px)" }}
animate={{
scale: 1,
opacity: 1,
filter: "blur(0px)",
backgroundColor: completed ? "#3084EB" : "#000000",
}}
transition={{ duration: ENTER_DURATION }}
className="size-20 border-6 border-white rounded-full p-1.5 shadow-sm overflow-hidden"
>
<motion.div
animate={{
backgroundColor: completed ? "#619FF5" : "#565656",
borderColor: completed ? "#63a8ff" : "#29252499",
}}
className="relative size-full overflow-hidden rounded-full border-3 flex items-center justify-center"
>
<AnimatePresence mode="popLayout">
{completed ? (
<CheckedIcon
key="checkedIcon"
transition={{ duration: ENTER_DURATION }}
/>
) : (
<motion.div
key="arrowsWrapper"
variants={wrapperVariants}
initial="initial"
animate="animate"
transition={{
duration: DEFAULT_DURATION,
delay: DEFAULT_DELAY,
}}
className="flex items-center justify-center -space-x-3"
onAnimationComplete={onAnimationComplete}
>
<motion.div
custom="up"
variants={arrowVariants}
initial="initial"
animate="animate"
exit="exit"
transition={{ duration: DEFAULT_DURATION }}
>
<ArrowUp className="text-white stroke-3 size-6" />
</motion.div>
<motion.div
custom="down"
variants={arrowVariants}
initial="initial"
animate="animate"
exit="exit"
transition={{ duration: DEFAULT_DURATION }}
>
<ArrowDown className="text-white stroke-3 size-6" />
</motion.div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</motion.div>
);
};
Explanation
In the final version of the interaction we are recreating, to hide imperfections as the component is scaling up, we need to make use of blur and opacity. I decided to go with a blur of 4px and a scale of 0.2. This will shrink the component by 20% and blur it out, which will give us a beautiful fade-out result when we animate these properties.
For the arrows, we don't need to apply any delay as we want to animate the y-axis as they enter into view. I decided to translate them on the y-axis by 8px and use the -space-x-3 Tailwind class on the parent container to bring them closer. Without that class, we would have the arrows spaced out, yet we need to get our design as close as possible to the final version of the interaction.
You will notice a prop named custom on the motion.div wrapper of the arrows, as the name implies, it allows us to pass custom values to variants. This way, we can easily determine which arrow to move up or down.
To rotate the arrows and release them after rotation, I added wrapperVariants variants to take care of rotating the wrapper to get that half-spin, which will cause the y-axis of the arrows to change position and be on the x-axis. Then I added a exit variant on the arrows variants to handle releasing the arrows when the rotation completes.
Divider component
A subtle but important detail is the divider that appears between the cards.
type DividerProps = {
completed: boolean;
};
const Divider = ({ completed }: DividerProps) => {
return (
<motion.div
animate={{
opacity: completed ? 1 : 0,
transition: { duration: DEFAULT_DURATION },
}}
className="absolute inset-x-0 bg-white h-1.5 flex items-center justify-center"
>
{completed && (
<motion.div
initial={{ width: "0%" }}
animate={{
width: "100%",
transition: { duration: DEFAULT_DURATION, delay: 0.1 },
}}
className="h-0.5 bg-gray-400/20"
/>
)}
</motion.div>
);
};
Explanation
This divider helps create a visual connection between the cards when the matching is complete.
To give an illusion that the arrows trigger the divider, I decided to conditionally show the divider when the matching is complete.
To hide the shadows of the cards when they connect, I added a height of 6px and a white background to the parent and aligned items at the center, so that the divider starts from the center and expands its width to the width of the cards.
I added the delay of 0.1 on the divider, as it feels good to me, as the shadows of the cards are fading into the white background of the parent.
With this setup, I ended up with the results I wanted to match the final version of the interaction.
Circular Progress Bar component
A circular progress bar, not like any other.
const CircularProgressBar = () => {
const size = 45;
const progress = 0.5; // 50%
const strokeWidth = 3;
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
return (
<div className="absolute -inset-[7px]">
<motion.div
initial={{ rotate: 0, opacity: 0, filter: "blur(4px)" }}
animate={{
rotate: 360,
filter: "blur(0px)",
opacity: [0, 1, 1, 0],
}}
transition={{
duration: DEFAULT_DELAY,
delay: ENTER_DURATION,
times: [0, 0.1, 0.8, 1],
filter: {
duration: 0.1,
},
}}
>
<svg viewBox={`0 0 ${size} ${size}`} className="-rotate-90">
<defs>
<linearGradient
id="progressGradient"
x1="0%"
y1="0%"
x2="100%"
y2="0%"
>
<stop offset="60%" stopColor="#619FF5" />
<stop offset="100%" stopColor="#ffffff" />
</linearGradient>
</defs>
<motion.circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke="url(#progressGradient)"
strokeWidth={strokeWidth}
fill="transparent"
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={circumference * (1 - progress)}
/>
</svg>
</motion.div>
</div>
);
};
Explanation
To display the progress bar outside the switch component, we need to position it absolutely using negative insets. I chose a value of -7px to ensure the progress bar is fully visible.
To achieve a full circular spin, we wrapped the SVG in a container element that handles a 360-degree rotation. By default, the circular progress indicator is aligned to the center-right, but for this interaction, we want it to start at the top-center and end at the bottom-center. To accomplish this, we simply rotate the SVG by -90 degrees.
For a timeline-based animation sequence, we need to slightly adjust our logic by using the default delay value as the duration and the default enter duration as the delay. This ensures the progress bar animates for the same amount of time that the arrow animation is being delayed. Once the progress bar completes its animation, we trigger the arrow spin and finish the matching sequence.
We also delay this animation until the cards are fully visible. Therefore, the delay value must match the duration of the card entrance animation.
To create a fade-in and fade-out effect for the progress bar, we use keyframes. In this interaction, the progress bar starts hidden, fades in gradually from 10% to 80% of the animation duration, and then fades out during the final 20%.
PS: I frequently use this component a lot whenever I work on an interaction that requires a spinner.
The main component
This component serves as the core of the auto-match interaction, orchestrating the animated transition between input and output cards.
const FIAT_PROVIDERS = [
{
title: "Mercury",
icon: <Mercury />,
},
{
title: "Ramp",
icon: <Ramp />,
},
];
export const AutoMatch = () => {
const [completed, setCompleted] = useState(false);
return (
<div className="w-full max-w-[460px] mx-auto">
<div className="w-full relative flex flex-col items-center justify-center">
<Card data={FIAT_PROVIDERS[0]} />
<SwitchDivider completed={completed} />
<div className="absolute size-20">
<CircularProgressBar />
<SwitchButton
completed={completed}
onAnimationComplete={() => setCompleted(true)}
/>
</div>
<Card
isOutputFiat
data={FIAT_PROVIDERS[1]}
/>
</div>
</div>
);
};
To put everything together, we need to align items in the center using flexbox and overlay other items with absolute positioning. This way, we don't need to manually position overlapping elements at the center, as they will follow the layout of the parent.
Conclusion
This AutoMatch interaction demonstrates how to create a complex, multi-step animation that feels natural and engaging. By carefully coordinating multiple animations and paying attention to timing, we can create interactions that not only look good but also provide clear feedback to users about the state of their actions. The implementation shows how Framer Motion can be used to create sophisticated animations while maintaining clean, maintainable code. The use of variants, proper timing, and coordinated animations results in a polished user experience that guides users through the matching process.