---
name: easeful
description: Add enter and exit animations to any component that already has open and closed state, using easeful's data-motion attribute. Use when animating a dialog, modal, popover, dropdown, menu, tooltip, select, accordion, collapsible, drawer, toast or any element that opens and closes, in React with Radix UI or Base UI, or with the native dialog and popover elements. Also use when an exit animation does not play, when a dialog closes instantly with no animation, when a panel snaps shut instead of collapsing, or when nothing animates after adding data-motion.
---
<!-- GENERATED by scripts/generate.mjs from motion.json. Do not edit by hand. -->

# easeful

A dependency-free CSS layer that adds enter and exit animations to any component that already signals open/closed state. Zero JavaScript ships to the browser. You add one attribute: `data-motion`.

## Install

```bash
# does everything below, prints the diff, asks before writing
npx easeful init
# checks it and exits non-zero, for CI
npx easeful doctor
```

By hand instead:

```css
/* Import the layer once, globally. If Tailwind v4 is present, declare the
   layer order BEFORE any import. This is the single most common failure. */
@layer easeful, theme, base, components, utilities;
@import "easeful";
@import "tailwindcss";
```

Activate the TypeScript types with one line in any declaration file the project already includes:

```ts
/// <reference types="easeful/types" />
```

Adding `"easeful/types"` to the `types` array in `tsconfig.json` also works, and switches off automatic inclusion of every other `@types` package, so `node` and anything else ambient must be listed beside it. React only either way: the augmentation targets React's `HTMLAttributes`, so a project without React should skip it. The CSS does not care.

## Hosts

| Host | State signal | Mechanism |
| --- | --- | --- |
| Radix UI | `data-state="open" \| "closed"` | Keyframes |
| Base UI | `data-starting-style` / `data-ending-style` | Transitions (preferred) |
| Native `<dialog>` / `[popover]` | `[open]` / `:popover-open` | Transitions |
| Plain | `[hidden]` | Transitions |

Support for all three mechanisms sits behind the same attribute. You never choose the mechanism; the host's own state attributes do.

## Attributes

| Attribute | Accepts | Required | What it is for |
| --- | --- | --- | --- |
| `data-motion` | One preset id, or two composed ids separated by a space. | yes | The whole API. Names the preset that animates the element. The other half of the match is the state attribute the host already writes, so this is the only thing you add. |
| `data-motion-state` | "open" or "closed". | no | The escape hatch for a host that publishes no open and closed pair of its own. Write it from the app and the transition path matches on it exactly as it matches Base UI ending styles. Base UI scroll area needs it, and so does any element you keep mounted and hide yourself. |

This table is the whole attribute surface, and the same list the TypeScript augmentation is generated from, so an attribute the types offer is an attribute a rule matches.

The types are autocomplete, not enforcement. TypeScript does not check JSX attributes whose names are not valid identifiers, and every `data-*` attribute is one, so `data-motion="nonsense"` compiles. Annotating a value gives a real error: `const v: MotionValue = "nonsense"`. Treat the preset list above as the authority rather than the compiler.

The lint rule is the enforcement. It reads this same manifest, so it cannot disagree with the stylesheet, and it fails on an unknown preset, a repeat, more than two, a pair that does not compose, or a `data-motion-state` that is not `open` or `closed`. It reads literal values and conditional branches, so `data-motion={value}` is left alone.

```js
// eslint.config.mjs
import easeful from "easeful/eslint"

export default [...easeful.configs.recommended]
```

## Presets

| Preset | What it does | Composes with |
| --- | --- | --- |
| `fade` | Fades opacity only. Overlays, backdrops, and anything where movement would distract. | `slide-up` |
| `scale-fade` | Fades in while scaling up slightly. Default choice for dialogs, dropdowns, and popovers. | `slide-up` |
| `slide-up` | Fades in while sliding up from slightly below. Menus and content anchored above their trigger. | `fade`, `scale-fade` |
| `collapse` | Animates a panel between zero and its natural height. The preset for accordions and collapsibles, where fading alone leaves the layout jumping. Takes a single element child. | `fade` |

Compose two presets with a space: `data-motion="fade slide-up"`.

## Tokens

| Token | Default | What it controls |
| --- | --- | --- |
| `--motion-duration` | `200ms` | Enter duration. |
| `--motion-duration-exit` | `150ms` | Exit duration. Faster than enter feels better. |
| `--motion-ease` | `cubic-bezier(0.32, 0.72, 0, 1)` | Enter easing, decelerating. |
| `--motion-ease-exit` | `cubic-bezier(0.4, 0, 1, 1)` | Exit easing, accelerating. |
| `--motion-distance` | `8px` | Small travel distance for slide presets. |
| `--motion-scale` | `0.96` | Scale factor for scale-in presets. |
| `--motion-origin` | `center` | Default transform-origin. |

Every token is declared on `:root` inside `@layer easeful`, so overriding one in unlayered CSS wins without `!important`. Override a token to restyle every preset at once.

## Rules

- Never write custom `@keyframes` for enter/exit. Use a preset.
- Never add Framer Motion / Motion to an element that has `data-motion`. They fight over `translate` and `scale`.
- Never guess a preset name. The vocabulary is exhaustive, and `easeful/eslint` fails the lint if you do: `fade`, `scale-fade`, `slide-up`, `collapse`.
- Never guess an attribute name either. The Attributes table above is exhaustive.
- Always declare the layer order before importing when Tailwind v4 is present.
- Compose with a space: `data-motion="fade slide-up"`.
- This library does motion only. For behavior, focus trapping, positioning, or ARIA, use the host component.

## If you are asked to add an animation

1. Check the setup first. `npx easeful doctor` reports the four things that have to be true, and the layer order is the one that fails silently.
2. Pick a preset from the table above rather than writing keyframes. The vocabulary is closed: fade, scale-fade, slide-up, collapse.
3. Put the attribute on the element the host component gives its state attribute to, which is the content or panel, not the trigger and not a wrapper.
4. Leave the host's behaviour alone. This library moves pixels and nothing else.
