Skip to main content

Animation

Add enter and exit animations to duck-primitives using CSS animations or transitions.

How animation works

duck-primitives use the Presence component to delay unmounting until CSS animations finish. This means you can use standard CSS @keyframes for enter/exit animations without any JavaScript animation library.


CSS keyframes pattern

Define open and closed animations, then apply them via data-state:

@keyframes fadeIn {
  from { opacity: 0; }
  to { opacity: 1; }
}
 
@keyframes fadeOut {
  from { opacity: 1; }
  to { opacity: 0; }
}
 
@keyframes scaleIn {
  from { opacity: 0; transform: translate(-50%, -50%) scale(0.95); }
  to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
}
 
@keyframes scaleOut {
  from { opacity: 1; transform: translate(-50%, -50%) scale(1); }
  to { opacity: 0; transform: translate(-50%, -50%) scale(0.95); }
}
 
.overlay[data-state="open"] { animation: fadeIn 200ms ease; }
.overlay[data-state="closed"] { animation: fadeOut 200ms ease; }
 
.content[data-state="open"] { animation: scaleIn 200ms ease; }
.content[data-state="closed"] { animation: scaleOut 200ms ease; }
@keyframes fadeIn {
  from { opacity: 0; }
  to { opacity: 1; }
}
 
@keyframes fadeOut {
  from { opacity: 1; }
  to { opacity: 0; }
}
 
@keyframes scaleIn {
  from { opacity: 0; transform: translate(-50%, -50%) scale(0.95); }
  to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
}
 
@keyframes scaleOut {
  from { opacity: 1; transform: translate(-50%, -50%) scale(1); }
  to { opacity: 0; transform: translate(-50%, -50%) scale(0.95); }
}
 
.overlay[data-state="open"] { animation: fadeIn 200ms ease; }
.overlay[data-state="closed"] { animation: fadeOut 200ms ease; }
 
.content[data-state="open"] { animation: scaleIn 200ms ease; }
.content[data-state="closed"] { animation: scaleOut 200ms ease; }

Tailwind CSS

<Dialog.Overlay className="
  fixed inset-0 bg-black/50
  data-[state=open]:animate-in data-[state=open]:fade-in-0
  data-[state=closed]:animate-out data-[state=closed]:fade-out-0
" />
 
<Dialog.Content className="
  fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2
  data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95
  data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95
" />
<Dialog.Overlay className="
  fixed inset-0 bg-black/50
  data-[state=open]:animate-in data-[state=open]:fade-in-0
  data-[state=closed]:animate-out data-[state=closed]:fade-out-0
" />
 
<Dialog.Content className="
  fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2
  data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95
  data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95
" />

The forceMount escape hatch

If you want to control animation entirely yourself (e.g., with Framer Motion), use forceMount on Portal, Overlay, and Content to keep them always mounted:

<Dialog.Portal forceMount>
  <Dialog.Overlay forceMount asChild>
    <motion.div
      animate={open ? { opacity: 1 } : { opacity: 0 }}
      initial={{ opacity: 0 }}
    />
  </Dialog.Overlay>
  <Dialog.Content forceMount asChild>
    <motion.div
      animate={open ? { scale: 1, opacity: 1 } : { scale: 0.95, opacity: 0 }}
    />
  </Dialog.Content>
</Dialog.Portal>
<Dialog.Portal forceMount>
  <Dialog.Overlay forceMount asChild>
    <motion.div
      animate={open ? { opacity: 1 } : { opacity: 0 }}
      initial={{ opacity: 0 }}
    />
  </Dialog.Overlay>
  <Dialog.Content forceMount asChild>
    <motion.div
      animate={open ? { scale: 1, opacity: 1 } : { scale: 0.95, opacity: 0 }}
    />
  </Dialog.Content>
</Dialog.Portal>

Presence render function

For conditional class application without forceMount:

<Presence present={isOpen}>
  {({ present }) => (
    <div className={present ? 'slide-in' : 'slide-out'}>
      Sidebar content
    </div>
  )}
</Presence>
<Presence present={isOpen}>
  {({ present }) => (
    <div className={present ? 'slide-in' : 'slide-out'}>
      Sidebar content
    </div>
  )}
</Presence>

Tips