0Pricing
Tailwind CSS Academy · Lesson

Transitions With Headless UI

Use the Transition component to animate enter and leave states of dialogs, dropdowns, and drawers using Tailwind's transition utilities.

Transitions With Headless UI is a free Tailwind CSS Academy lesson on CoddyKit — lesson 4 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the Tailwind CSS Academy learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.

Why Animate With Headless UI's Transition?

Tailwind's transition-* utilities handle smooth state changes on persistent elements, but they cannot animate elements that mount and unmount from the DOM. When a dialog or dropdown appears, it goes from non-existent to visible in one frame — there is no transition. Headless UI's Transition component solves this by keeping the element mounted briefly during the exit animation, allowing Tailwind transition utilities to complete before the element is removed.

The Transition Component API

The Transition component from Headless UI accepts a show boolean and a set of class props: enter, enterFrom, enterTo for the entering animation, and leave, leaveFrom, leaveTo for the exit. Classes in enter and leave are active during the entire transition. Classes in enterFrom/leaveFrom define the start state, and enterTo/leaveTo define the end state.

import { Transition } from '@headlessui/react';

<Transition
  show={isVisible}
  enter='transition-opacity duration-200 ease-out'
  enterFrom='opacity-0'
  enterTo='opacity-100'
  leave='transition-opacity duration-150 ease-in'
  leaveFrom='opacity-100'
  leaveTo='opacity-0'
>
  <div className='bg-white rounded-xl p-6 shadow-lg'>
    Content that fades in and out
  </div>
</Transition>

Animating a Dialog Backdrop and Panel

A polished dialog entrance uses two separate animations: the backdrop fades in while the panel scales and fades in from a slightly smaller size. Wrap each element in its own Transition.Child and set different durations so the backdrop appears slightly before the panel. Use Transition as the outer wrapper with the show prop, and Transition.Child for coordinated child animations.

import { Dialog, Transition } from '@headlessui/react';
import { Fragment } from 'react';

function AnimatedDialog({ open, onClose, children }) {
  return (
    <Transition show={open} as={Fragment}>
      <Dialog onClose={onClose} className='relative z-50'>

        {/* Backdrop fade */}
        <Transition.Child
          as={Fragment}
          enter='ease-out duration-300'
          enterFrom='opacity-0'
          enterTo='opacity-100'
          leave='ease-in duration-200'
          leaveFrom='opacity-100'
          leaveTo='opacity-0'
        >
          <div className='fixed inset-0 bg-black/50' aria-hidden='true' />
        </Transition.Child>

        {/* Panel scale-in */}
        <div className='fixed inset-0 flex items-center justify-center p-4'>
          <Transition.Child
            as={Fragment}
            enter='ease-out duration-300'
            enterFrom='opacity-0 scale-95'
            enterTo='opacity-100 scale-100'
            leave='ease-in duration-200'
            leaveFrom='opacity-100 scale-100'
            leaveTo='opacity-0 scale-95'
          >
            <Dialog.Panel className='bg-white rounded-2xl shadow-2xl max-w-md w-full p-6'>
              {children}
            </Dialog.Panel>
          </Transition.Child>
        </div>

      </Dialog>
    </Transition>
  );
}

Animating Dropdowns and Popovers

Dropdown menus benefit from a subtle slide-and-fade entrance. Wrap the Menu.Items in a Transition component that slides down from the trigger and fades in. The exit animation should be faster than the entrance — a 100-150ms leave feels crisp and responsive. Use transform origin-top so the scale animation originates from the top where the button is.

import { Menu, Transition } from '@headlessui/react';
import { Fragment } from 'react';

<Menu as='div' className='relative'>
  <Menu.Button>Options</Menu.Button>

  <Transition
    as={Fragment}
    enter='transition ease-out duration-150'
    enterFrom='transform opacity-0 scale-95 -translate-y-2'
    enterTo='transform opacity-100 scale-100 translate-y-0'
    leave='transition ease-in duration-100'
    leaveFrom='transform opacity-100 scale-100 translate-y-0'
    leaveTo='transform opacity-0 scale-95 -translate-y-2'
  >
    <Menu.Items className='absolute right-0 mt-1 w-48 rounded-xl bg-white shadow-lg ring-1 ring-black/5 p-1 focus:outline-none'>
      {/* items */}
    </Menu.Items>
  </Transition>
</Menu>

Slide-In Sidebar Drawer

A mobile sidebar drawer slides in from the left or right edge. Use a Transition with translate-x utilities for the slide direction. Start off-screen (-translate-x-full for left, translate-x-full for right) and translate to translate-x-0 to bring it into view. Add a backdrop transition simultaneously using Transition.Child.

function MobileSidebar({ open, onClose }) {
  return (
    <Transition show={open} as={Fragment}>
      <Dialog onClose={onClose}>
        {/* Backdrop */}
        <Transition.Child
          as={Fragment}
          enter='ease-out duration-300'
          enterFrom='opacity-0'
          enterTo='opacity-100'
          leave='ease-in duration-200'
          leaveFrom='opacity-100'
          leaveTo='opacity-0'
        >
          <div className='fixed inset-0 bg-black/40 z-40' aria-hidden='true' />
        </Transition.Child>

        {/* Sidebar panel */}
        <Transition.Child
          as={Fragment}
          enter='transition ease-out duration-300'
          enterFrom='-translate-x-full'
          enterTo='translate-x-0'
          leave='transition ease-in duration-200'
          leaveFrom='translate-x-0'
          leaveTo='-translate-x-full'
        >
          <Dialog.Panel className='fixed left-0 top-0 h-full w-72 bg-white shadow-xl z-50 overflow-y-auto'>
            {/* sidebar content */}
          </Dialog.Panel>
        </Transition.Child>
      </Dialog>
    </Transition>
  );
}

Notification Toast Animation

Notification toasts typically slide in from the bottom or top corner and fade out after a timeout. Use a Transition with translate-y for the slide direction. Combine show with a boolean that automatically toggles to false after a timeout using setTimeout in a useEffect. The leave animation completes before the component unmounts.

function Toast({ message, show, onHide }) {
  return (
    <Transition
      show={show}
      as={Fragment}
      enter='transition ease-out duration-300'
      enterFrom='translate-y-4 opacity-0'
      enterTo='translate-y-0 opacity-100'
      leave='transition ease-in duration-200'
      leaveFrom='translate-y-0 opacity-100'
      leaveTo='translate-y-4 opacity-0'
    >
      <div className='
        fixed bottom-4 right-4 z-50
        bg-gray-900 text-white
        px-4 py-3 rounded-xl shadow-lg
        flex items-center gap-3
        max-w-sm
      '>
        <CheckCircleIcon className='h-5 w-5 text-green-400 flex-shrink-0' />
        <span className='text-sm'>{message}</span>
        <button onClick={onHide} className='ml-auto text-gray-400 hover:text-white'>
          <XMarkIcon className='h-4 w-4' />
        </button>
      </div>
    </Transition>
  );
}

Coordinating Multiple Transitions

The Transition.Child component coordinates timing with a parent Transition. Children observe the parent's show state and can add their own timing offsets. This enables staggered animations where different parts of a UI appear in sequence. Headless UI does not natively support arbitrary stagger delays, but you can simulate it with incrementally longer enter durations or CSS animation delays.

// Staggered items using animation-delay workaround
<Transition show={open}>
  <div className='bg-white rounded-xl p-6 shadow-lg'>
    {items.map((item, i) => (
      <Transition.Child
        key={item.id}
        as={Fragment}
        enter='transition ease-out duration-200'
        enterFrom='opacity-0 translate-y-2'
        enterTo='opacity-100 translate-y-0'
        style={{ transitionDelay: i * 50 + 'ms' }}
        leave='transition ease-in duration-150'
        leaveFrom='opacity-100'
        leaveTo='opacity-0'
      >
        <div className='py-2 border-b border-gray-100'>{item.label}</div>
      </Transition.Child>
    ))}
  </div>
</Transition>

Transition With appear Prop

By default, the Transition component does not animate on first render if show is already true when it mounts. The appear prop overrides this, causing the enter transition to play even on initial mount. This is useful for page-load animations or when a component is conditionally rendered with show immediately set to true.

// Without 'appear': no animation on initial mount when show=true
<Transition show={true}>
  <div>This appears without animation on first load</div>
</Transition>

// With 'appear': animates in even on first mount
<Transition
  show={true}
  appear  // enables enter transition on initial mount
  enter='transition-all ease-out duration-500'
  enterFrom='opacity-0 scale-95'
  enterTo='opacity-100 scale-100'
>
  <div className='bg-white rounded-xl p-6 shadow'>
    This animates in on first load
  </div>
</Transition>

Handling afterLeave Callbacks

The afterLeave prop fires a callback after the leave transition completes. This is useful for cleanup actions that should happen only after the element has fully disappeared — like resetting form state, clearing error messages, or unmounting expensive child components. Running cleanup during the transition (when the element is still visible) would cause a jarring visual change.

function SearchDialog({ open, onClose }) {
  const [query, setQuery] = useState('');

  return (
    <Transition
      show={open}
      afterLeave={() => {
        // Reset query AFTER dialog has fully faded out
        // Prevents seeing the input clear while dialog is still visible
        setQuery('');
      }}
      enter='ease-out duration-300'
      enterFrom='opacity-0 scale-95'
      enterTo='opacity-100 scale-100'
      leave='ease-in duration-200'
      leaveFrom='opacity-100 scale-100'
      leaveTo='opacity-0 scale-95'
    >
      <Dialog open={open} onClose={onClose}>
        <Dialog.Panel className='bg-white rounded-2xl p-6 shadow-xl'>
          <input
            value={query}
            onChange={e => setQuery(e.target.value)}
            placeholder='Search...'
            className='w-full border rounded-lg px-3 py-2'
          />
        </Dialog.Panel>
      </Dialog>
    </Transition>
  );
}

Best Practices for UI Transitions

Keep animations short and purposeful. Enter transitions should be 150-300ms — long enough to feel smooth but short enough not to make users wait. Leave transitions should be 10-30% shorter than enters — exits should feel crisp. Use ease-out for enters (fast start, slow finish feels natural) and ease-in for exits (slow start, fast finish feels intentional). Never animate for the sake of animation — every motion should communicate state change.

/* Recommended timing guidelines */

/* Tooltip / small dropdown */
enter: 'transition ease-out duration-100'
leave: 'transition ease-in duration-75'

/* Modal dialog */
enter: 'ease-out duration-300'
leave: 'ease-in duration-200'

/* Sidebar drawer */
enter: 'transition ease-out duration-300'
leave: 'transition ease-in duration-250'

/* Page transitions */
enter: 'transition ease-out duration-500'
leave: 'transition ease-in duration-300'

/* Avoid: too slow (>500ms) feels laggy */
/* Avoid: ease-in for enters (starts slow, looks stuck) */

Respecting Reduced Motion Preferences

Some users experience motion sickness or seizures from on-screen animations. The prefers-reduced-motion media query allows the operating system to signal this preference to the browser. Always respect it in your Transition animations. Use Tailwind's motion-reduce: variant (or a custom variant) to strip transitions for users who need reduced motion, keeping the UI functional but static.

/* globals.css — respect reduced motion globally */
@media (prefers-reduced-motion: reduce) {
  .transition,
  .transition-all,
  .transition-colors,
  .transition-transform {
    transition-duration: 0.01ms !important;
  }
}

<!-- In Tailwind with motion-reduce: variant -->
<div
  class='
    transition-all ease-out duration-300
    motion-reduce:transition-none
    opacity-0 translate-y-4
    open:opacity-100 open:translate-y-0
  '
>
  Animates for standard users, instant for reduced-motion users
</div>

Quick Check

Test your understanding of Tailwind CSS Mastery concepts from this lesson.

Lesson Recap

In this lesson you learned: Transition wraps mounted elements to enable enter/leave animations, Transition.Child coordinates timing for multiple elements in a parent Transition, and afterLeave runs cleanup callbacks only after the exit animation completes. Next up we dive into writing custom Tailwind plugins to extend the utility system.

Frequently asked questions

Is the “Transitions With Headless UI” lesson free?

Yes — the full text of “Transitions With Headless UI” is free to read here on the web, and the Tailwind CSS Academy course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the Tailwind CSS Academy course, upgrade to CoddyKit PRO.

What will I learn in “Transitions With Headless UI”?

Use the Transition component to animate enter and leave states of dialogs, dropdowns, and drawers using Tailwind's transition utilities. You practise Tailwind CSS Academy with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.

Do I need any experience to start Tailwind CSS Academy?

No prior experience is required. Tailwind CSS Academy on CoddyKit is structured for beginners through advanced learners; this is — lesson 4 of 4, so you can start here or from the beginning and move at your own pace.

How long does the “Transitions With Headless UI” lesson take?

Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.

Can I write and run code in this Tailwind CSS Academy lesson?

Yes. Every Tailwind CSS Academy lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.

All lessons in this course

  1. Introduction to Headless UI
  2. Styling Headless Menu and Dropdown
  3. Building Accessible Dialogs
  4. Transitions With Headless UI
← Back to Tailwind CSS Academy