Animated Sign-In Dialog
An animated sign-in dialog built on top of the Radix dialog primitive.
Structure
Building a dialog component can be quite complex if you're doing it from scratch. Luckily, there are pre-built primitives that we can use such as Radix or Base UI that handle accessibility and basic behavior out of the box.


For this component I chose the Radix primitive but either option works. The dialog has five different states: the default state, wallet connection, email verification, phone verification and passkey authentication.
type ModalStep = "default" | "connect-wallet" | "email" | "phone" | "passkey";
Height Animation
Animating height can often be tricky. If you try to animate from and to auto
height, the animation won't work. This is a limitation of CSS as the browser needs concrete start and end points to animate the values and it can't calculate them ahead of time.
Even if you set one value to a number and the other to auto
, it won't work. And since Motion relies on CSS properties for animations, it won't solve this either.
An example of animating height with "auto" values
Therefore, we need to calculate the value somehow. You could do this manually and it would work well, but as soon as you change the content, you'd need to change the values. This gets tedious and ideally we want to avoid magic numbers like this.
Luckily, there are hooks that we can use that will do the calculation for us such as useMeasure
from react-use-measure
.
If you are worried about bundle size (although this package is very small) or loading an external dependency, you can also create your own measure hook since it uses the ResizeObserver
API.
import { useRef, useEffect, useState } from "react";
export function useMeasure() {
const ref = useRef<HTMLElement>(null);
const [width, setWidth] = useState(0);
const [height, setHeight] = useState(0);
useEffect(() => {
const element = ref.current;
if (!element) return;
const updateDimensions = () => {
setWidth(element.offsetWidth);
setHeight(element.offsetHeight);
};
// Initial measurement
updateDimensions();
// Watch for size changes
const resizeObserver = new ResizeObserver(updateDimensions);
resizeObserver.observe(element);
return () => resizeObserver.disconnect();
}, []);
return [ref, { width, height }] as const;
}
The way to animate the height using these values is to first wrap the content in a div with the ref
on it.
<div ref={ref}>{content}</div>
Now we can use the values that the useMeasure
hook returns for the height animation. Keep in mind that you cannot place the ref
on the element whose height you are animating.
import { useMeasure } from "react-use-measure";
const [ref, bounds] = useMeasure({ offsetSize: true });
<motion.div
animate={{
height: step === "default" ? 388 : bounds.height,
}}
className="overflow-hidden will-change-transform"
>
You can see I'm using a hardcoded value for the default state. Solely using bounds.height
for the height value would work fine, but you would run into an issue where the dialog doesn't open with the proper height.
<motion.div
animate={{
height: bounds.height,
}}>
An example of animating height only with bounds.height.
With this, the height
animation is complete. Below is a full structure of the dialog.
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<button className="trigger-button">Sign In</button>
</DialogTrigger>
<DialogContent asChild className="overflow-hidden">
<motion.div
animate={{
height: step === "default" ? 388 : bounds.height,
}}
className="will-change-transform"
>
<div ref={ref}>{content}</div>
</motion.div>
</DialogContent>
</Dialog>
An example of animating height with a default hardcoded value and bounds.height
Content Transition
In addition to animating height
, the content is also being animated at the same time. First we have to define all of the content states. You could memoize these, but I'm using the React Compiler which handles it automatically.
const renderState = () => {
switch (step) {
case "default":
return <DefaultState onNext={handleNext} />;
case "connect-wallet":
return <ConnectWalletState onBack={handleBack} />;
case "email":
return <EmailState onBack={handleBack} />;
case "phone":
return <PhoneState onBack={handleBack} />;
case "passkey":
return <PasskeyState onBack={handleBack} />;
default:
return null;
}
};
For this transition we will be using AnimatePresence
from Motion. It is very powerful, because it allows you to animate elements as they exit the react tree. There are multiple modes that can be used, that provide different behavior.
The default mode is sync
, which animates children in and out as soon as they are added or removed.
The wait
mode makes sure that the second element won't animate in until the first element has fully animated out.
The popLayout
mode pops the exiting children out of the layout, which ensures that the entering children will animate in the correct position.
Below is a visual representation of the different modes side-by-side.
For the transition animation we render the state inside a motion.div
wrapper. We then wrap it in AnimatePresence
with the popLayout
mode to make sure that the different contents stay stacked on top of each other when animating.
const content = (
<AnimatePresence mode="popLayout" initial={false}>
<motion.div
key={step}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ type: "spring", bounce: 0, duration: 0.3 }}
>
{renderState()}
</motion.div>
</AnimatePresence>
);
Keep in mind that any direct children of AnimatePresence
must have a unique key
, otherwise the animation won't work.
Morphing Tab Component
The default dialog state includes a tab component that switches between multiple sign-in methods with an animated active-tab indicator.
To achieve this effect, instead of filling the background of the actual component with a color, you can add an absolutely positioned container with inset-0
and we can animate it using shared layout animations from Motion.
{
activeTab === tab && (
<motion.div
initial={false}
layoutId="tab-indicator"
className={tabIndicatorClasses}
transition={{
type: "spring",
duration: 0.4,
bounce: 0,
}}
/>
);
}
Passkey Animation
The passkey state also has a progress animation.
It's a rotating, absolutely positioned element that gets clipped with overflow-hidden
to create a border effect. It uses a conic-gradient
that goes from transparent
to a blue color and then back to transparent
.
<div className="relative flex items-center justify-center overflow-hidden rounded-[22px] p-0.5">
<motion.div
className="absolute left-[-50%] top-[-50%] h-[200%] w-[200%] bg-[conic-gradient(from_0deg,transparent_0%,#4DAFFE_10%,#4DAFFE_25%,transparent_35%)]"
animate={{ rotate: 360 }}
transition={{
duration: 1.25,
repeat: Infinity,
ease: "linear",
repeatType: "loop",
}}
/>
<div className="bg-preview-bg z-1 flex items-center justify-center rounded-[20px] p-1">
<div className="flex items-center justify-center rounded-2xl bg-gray-300 p-1">
<div className="flex size-16 items-center justify-center rounded-xl bg-gray-100">
<PasskeyIcon className="size-8 shrink-0 stroke-gray-800" />
</div>
</div>
</div>
</div>
More
In case you have any questions reach me at jakub@kbo.sk, see more of my work on Twitter or subscribe to my newsletter.
Stay In The Loop
Get updates when I share new posts, resources and tips.