opsinjs
HandbookWorking with components

Motion in practice

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.

The short version

NOT IMPLEMENTED. This component does not exist in any released version of opsinjs. There is no package to install, no module to import and no props interface to generate code against. Everything on this page is a specification of intended behaviour and may change without notice. Do not write code against it.

This component is not built yet

There is nothing to render because there is nothing to install. What you can read on this page is the specification the implementation will have to satisfy.

PlannedRoadmapWhat “planned” means

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.

.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

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.

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.

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.

A sheet arriving
148

--opsin-ease-spring-sheet · --opsin-duration-spring-sheet. Under prefers-reduced-motion: reduce this becomes 1ms with a flat curve. The element still moves, and it simply arrives at once. The checkbox simulates the preference; if you have actually set it, app/globals.css has already applied it and the two states will look the same.

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

  • 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.
  • 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

  • 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.
  • Reduced motion is not the same as reduced transparency. Two separate queries, two separate contracts; see Reduced transparency.

On this page