| 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. |
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
npx easeful init
npx easeful doctor
By hand instead:
@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:
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.
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
- 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.
- Pick a preset from the table above rather than writing keyframes. The vocabulary is closed: fade, scale-fade, slide-up, collapse.
- 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.
- Leave the host's behaviour alone. This library moves pixels and nothing else.