Anatomy of the GodUI Dialog
Two keyframes on Radix state, a spring written as CSS linear(), and why the scale never fights the centering translate.
↓Scroll to step through it
The entrance
Radix sets data-state="open" on the content when the dialog opens and
"closed" when it starts closing. GodUI hangs one keyframe on each state.
godui-fade-scale-in only defines a from frame: opacity: 0 and
scale: 0.96. The browser animates from there to the element's own values,
so the dialog lands exactly where its classes put it.
The dialog is centered with translate-x-[-50%] translate-y-[-50%], which
Tailwind v4 compiles to the individual translate property. The keyframe
animates scale, a different property, so the two compose instead of the
animation overwriting the centering offset.
The spring
The entrance runs for 260ms on ease-spring-snappy, a critically damped
spring sampled into a CSS linear() curve. It accelerates hard and eases into
place with a barely visible settle.
The exit is shorter: 150ms on ease-out-expo, because nobody waits for a
dialog to finish closing.
Waiting for the exit
A closing dialog has to stay in the DOM long enough to animate out. Radix's
Presence handles that: when open becomes false it checks whether the
content has a CSS animation running, and if so it waits for animationend
before unmounting.
That's why the exit is a keyframe on data-[state=closed] rather than a
transition: Presence watches animations, not transitions.
The result
Open the dialog, then close it with Escape, the Cancel button or a click on the overlay. All three paths run the same exit keyframe.
- opacity
- 0 → 1
- scale
- 0.96 → 1
<DialogPrimitive.Contentdata-slot="dialog-content"className={cn( "fixed top-[50%] left-[50%] z-50 … translate-x-[-50%] translate-y-[-50%] … data-[state=open]:animate-godui-fade-scale-in data-[state=closed]:animate-godui-fade-scale-out …", className,)}/>Anatomy of the GodUI Dialog
Two keyframes on Radix state, a spring written as CSS linear(), and why the scale never fights the centering translate.
- opacity
- 0 → 1
- scale
- 0.96 → 1
<DialogPrimitive.Contentdata-slot="dialog-content"className={cn( "fixed top-[50%] left-[50%] z-50 … translate-x-[-50%] translate-y-[-50%] … data-[state=open]:animate-godui-fade-scale-in data-[state=closed]:animate-godui-fade-scale-out …", className,)}/>The entrance
Radix sets data-state="open" on the content when the dialog opens and
"closed" when it starts closing. GodUI hangs one keyframe on each state.
godui-fade-scale-in only defines a from frame: opacity: 0 and
scale: 0.96. The browser animates from there to the element's own values,
so the dialog lands exactly where its classes put it.
The dialog is centered with translate-x-[-50%] translate-y-[-50%], which
Tailwind v4 compiles to the individual translate property. The keyframe
animates scale, a different property, so the two compose instead of the
animation overwriting the centering offset.
ease-spring-snappy
--animate-godui-fade-scale-in: godui-fade-scale-invar(--godui-duration-base) var(--ease-spring-snappy) both;@keyframes godui-fade-scale-in {from { opacity: 0; scale: calc(1 - (1 - var(--godui-enter-scale)) * var(--godui-motion));}}The spring
The entrance runs for 260ms on ease-spring-snappy, a critically damped
spring sampled into a CSS linear() curve. It accelerates hard and eases into
place with a barely visible settle.
The exit is shorter: 150ms on ease-out-expo, because nobody waits for a
dialog to finish closing.
- opacity
- 1 → 0
- scale
- 1 → 0.96
// Radix Presence, simplifiedif (!present && getAnimationName(node) !== "none") {// keep the node mounted until 'animationend'}Waiting for the exit
A closing dialog has to stay in the DOM long enough to animate out. Radix's
Presence handles that: when open becomes false it checks whether the
content has a CSS animation running, and if so it waits for animationend
before unmounting.
That's why the exit is a keyframe on data-[state=closed] rather than a
transition: Presence watches animations, not transitions.
The result
Open the dialog, then close it with Escape, the Cancel button or a click on the overlay. All three paths run the same exit keyframe.
What's animated
| Interaction | Keyframe / mechanism | Properties | Easing | Duration |
|---|---|---|---|---|
| Open | godui-fade-scale-in | opacity, scale 0.96 → 1 | ease-spring-snappy | 260ms |
| Close | godui-fade-scale-out | opacity, scale 1 → 0.96 | ease-out-expo | 150ms |
| Overlay | godui-fade-in / godui-fade-out | opacity | snappy / expo | 260 / 150ms |
The content's centering translate is untouched: the keyframes animate the
separate scale property, so they can't fight the -50% offset.
Why GPU-only
The dialog animates scale and opacity; the overlay animates opacity.
shadcn's version does the same through tw-animate-css, so GodUI changes only
the curve: a spring in, a fast exit out.
Reduced motion
Under prefers-reduced-motion the root sets --godui-motion: 0. The keyframe
multiplies its scale offset by it, so the dialog fades without scaling, and
the duration drops to 150ms.
Replacing shadcn
npx shadcn add @godui/dialog overwrites components/ui/dialog.tsx. Exports,
props and data-slot attributes match shadcn/ui new-york-v4, so existing
imports keep working. It also installs godui-motion (easings, keyframes, the
FLIP hook) and the GodUI button it uses for the footer close button.