---
title: "Motion in practice"
description: "Writing enter and exit transitions against data-starting-style and data-ending-style, with a reduced-motion fallback that is per token, not a kill switch."
url: "https://opsinjs.pensievelabs.org/handbook/motion-in-practice"
source: "https://opsinjs.pensievelabs.org/handbook/motion-in-practice.md"
section: "Handbook"
kind: "handbook"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["enter transition", "exit transition css", "prefers-reduced-motion css", "animating a popup", "writing a css transition"]
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> Nothing is missing from this page. The data simply does not live in
> the prose.

<PageTemplate kind="handbook" />

## The short version [#the-short-version]

<NotBuiltYet />

You do not need an animation library. Base UI keeps a leaving element mounted
and marks the lifecycle with attributes; you write ordinary CSS transitions
against them.

```css
.sheet {
  transition: opacity var(--opsin-motion-enter), translate var(--opsin-motion-enter);
  opacity: 1;
  translate: 0 0;
}
.sheet[data-starting-style],
.sheet[data-ending-style] {
  opacity: 0;
  translate: 0 1rem;
}
```

Three lines and both directions work. The from-state and the to-state are the
same declaration block because "off screen" is the same place whether you are
arriving or leaving.

## How it works [#how-it-works]

`data-starting-style` is present for one frame as the element enters, which
gives the browser a state to transition *from*. `data-ending-style` is present
while the element is leaving; Base UI keeps it mounted until the transition
finishes, then unmounts it. Between the two, the element has neither attribute
and sits at its resting style.

The timing values are tokens, not literals. opsinjs motion tokens are `linear()`
easings generated from spring parameters, so a transition reads as one custom
property rather than as a duration and a curve you tuned by eye. The reasoning
is in [Springs as tokens](../foundations/motion/springs-as-tokens.mdx).

### Reduced motion [#reduced-motion]

`prefers-reduced-motion: reduce` is not a request to remove feedback. It is a
request to remove *movement*. A dialog that appears with no transition at all is
harder to follow than one that cross-fades, and a reader who has set the
preference still needs to know that something arrived.

opsinjs therefore degrades **per token**: each motion token has a reduced-motion
counterpart that keeps a short opacity change and drops the translation, scale
and any rotation. This is handled in the token layer, so a transition written
against a token gets the correct fallback without your writing a media query.
The rule and its rationale are in
[Reduced motion](../foundations/motion/reduced-motion.mdx).

Below is a real transition on a real element, with a checkbox that simulates the
preference so the two can be compared without changing your system settings. The
token pair is the one `Sheet` uses.

<MotionDemo easing="--opsin-ease-spring-sheet" duration="--opsin-duration-spring-sheet" property="translate" label="A sheet arriving" />

## Do this [#do-this]

* **Transition properties that do not trigger layout**: `opacity`, `transform`
  or `translate`/`scale`, and `filter`. These composite on the GPU; `width`,
  `height`, `top` and `margin` do not.
* **Use the same declaration block for starting and ending style** unless the
  two directions genuinely differ. A sheet that slides up on entry and fades on
  exit is usually a mistake rather than a decision.
* **Key off the token, not a number.** `var(--opsin-motion-enter)` carries the
  duration, the curve and the reduced-motion behaviour together.
* **Let the element leave.** If you control mounting yourself, keep the element
  in the tree until the exit finishes, or use the component's own open state and
  let it handle it.
* **Test with the preference on.** It is a one-line change in system settings
  and it is the only way to find a transition that was carrying meaning.

## Not this [#not-this]

* **Never carry urgency in motion.** A pulsing or shaking alert conveys nothing
  to a reader with the preference set, nothing in a screenshot, and nothing to
  anyone using assistive technology. At the same time, it is actively harmful to
  people with vestibular disorders. This is doctrine, not preference; see
  [Motion in health UI](../health/motion-in-health-ui.mdx).
* **Do not animate a number into place.** A value that counts up from zero is
  briefly displaying figures that are not the reader's. In a health context that
  is a false statement rendered at 60fps.
* **Do not use a blanket `* { transition: none }` under reduced motion.** It
  removes the opacity fallback too, which makes state changes harder to follow.
* **Do not reach for a spring library.** The tokens already are springs,
  serialised to `linear()`, and they run on the compositor rather than in
  JavaScript.
* **Do not transition `display`.** It does not interpolate. The fix is a
  discrete transition plus `@starting-style`, and it is not needed here because
  Base UI's mount lifecycle already solves it.

## Gotchas [#gotchas]

* **You cannot see `data-starting-style` in devtools.** It lasts one frame. An
  enter transition that "does not fire" is almost always a missing from-state
  rule rather than a missing attribute.
* **Transitioning a property the element has never had produces no transition.**
  If the resting style does not declare `translate`, there is nothing to
  interpolate from.
* **`@starting-style` and `data-starting-style` are different things.** The
  first is a CSS at-rule for entry animations on newly rendered elements; the
  second is Base UI's attribute. You need only the second, and mixing them
  produces two competing sources of the same intent.
* **Exit transitions do not run if you unmount the element yourself.** No error
  is reported; the element simply vanishes.
* **A transition on a composited surface costs more than you think.** Animating
  a blurred material is one of the few things that will actually drop frames on
  a mid-range phone; see [Performance
  budget](../foundations/materials/performance-budget.mdx).
* **Reduced motion is not the same as reduced transparency.** Two separate
  queries, two separate contracts; see [Reduced
  transparency](../accessibility/reduced-transparency.mdx).

## Related [#related]

* [Springs as tokens](../foundations/motion/springs-as-tokens.mdx) is where the
  `linear()` values come from.
* [Reduced motion](../foundations/motion/reduced-motion.mdx) has the per-token
  degradation contract.
* [Data attributes](./data-attributes.mdx) has the full lifecycle vocabulary.
* [Motion in health UI](../health/motion-in-health-ui.mdx) is the doctrine that
  constrains all of the above.
