introducing duck motion

Enter/exit animations, mount/unmount transitions, and staggered sequences — built to pair with duck-variants and duck-primitives.

Why Another Animation Library?

Most animation libraries are general-purpose. They handle springs, keyframes, and timeline orchestration — powerful, but often more than you need when all you want is a smooth enter/exit transition on a dialog or dropdown.

@gentleduck/motion is purpose-built for UI component animations. It handles the hard part — coordinating mount/unmount with CSS or JS transitions — while staying small and composable.


What It Solves

The classic problem: you want a dialog to fade in when it opens and fade out when it closes. But React unmounts the component immediately when you set open={false}, so the exit animation never plays.

// The problem: component unmounts before animation finishes
{open && <Dialog>...</Dialog>}
// The problem: component unmounts before animation finishes
{open && <Dialog>...</Dialog>}

@gentleduck/motion solves this with a Presence primitive that delays unmounting until the exit animation completes.


How It Works

Loading diagram...

Motion primitives coordinate three phases:

  1. Mount — Component enters the DOM, enter animation triggers
  2. Present — Component is visible and interactive
  3. Exit — Exit animation plays, component unmounts after completion

Used Across the Ecosystem

Every animated component in @gentleduck/ui uses motion primitives:

ComponentAnimation
DialogFade + scale on open/close
SheetSlide from edge with backdrop fade
DrawerSlide up with drag-to-dismiss
Dropdown MenuScale + fade from trigger
TooltipFade with slight offset
PopoverScale from anchor point
CollapsibleHeight animation
AccordionHeight animation per section

Pairing with Variants

Motion works naturally with @gentleduck/variants for conditional animation classes:

import { cva } from '@gentleduck/variants'
 
const overlay = cva('fixed inset-0 bg-black/50', {
  variants: {
    state: {
      open: 'animate-in fade-in',
      closed: 'animate-out fade-out',
    },
  },
})
import { cva } from '@gentleduck/variants'
 
const overlay = cva('fixed inset-0 bg-black/50', {
  variants: {
    state: {
      open: 'animate-in fade-in',
      closed: 'animate-out fade-out',
    },
  },
})

The variant system handles which classes to apply. Motion handles when to unmount.


Getting Started

bun add @gentleduck/motion
bun add @gentleduck/motion

Motion is a peer dependency of @gentleduck/primitives and is automatically included when you install components via the CLI. If you are building custom components on top of primitives, install it directly.