Transition
Transition base prototype
Imperative Control
Section titled “Imperative Control”This demo shows imperative state-machine control.
- Enter: Moves into
enteringfrom any valid state (closed → enteringorleaving → entering). Ignored if already entering or entered. - Leave: Moves into
leavingfrom any valid state (entered → leavingorentering → leaving). Ignored if already leaving or closed. - Complete: Advances
enteringtoentered, orleavingtoclosed.
Typical full flow:
Enter → Complete → Leave → CompleteControlled Mode
Section titled “Controlled Mode”This demo shows the state machine driven by the open prop. The host platform calls complete() after the CSS transition ends to advance the state automatically.
- Toggle Open: Flips the
openvalue.- If currently
closed/leaving, settingopen=truetriggersentering → entered. - If currently
entered/entering, settingopen=falsetriggersleaving → closed.
- If currently
- Click Me: Clicking the box itself also toggles
open, behaving the same as the button.
Typical flow:
Toggle Open (show) → Toggle Open (hide)In this demo the CSS transition is set to 0.3s, so entering and leaving will auto-complete after that duration.
Transition is a low-level foundational component for managing the presence lifecycle of elements. It does not render visible content itself; instead, it provides state machine governance, allowing the host platform (CSS, React, Vue, etc.) to drive actual animations based on the state.
Architecture
Section titled “Architecture”asTransition is implemented as a privileged hook (not a regular asHook). It uses the system-level module @proto.ui/module-presence to control structural mount and unmount timing across all adapters (Web Component, React, Vue).
Why privileged? Because delaying structural mount before “entering” starts, and delaying unmount until “leaving → closed” finishes, requires host-level capabilities that go beyond what ordinary prototype authors can access.
Lifecycle under module-presence:
absent → (setIntent enter) → mounting → present → (setIntent leave) → unmounting → absent- Mount blocking: When the transition starts at
closedand hasn’t received an enter intent, the runtimemountedphase is deferred. The host adapter only renders/mounts the component once the presence handle resolves the mount. - Deferred unmount: When leaving starts (
entered → leaving), the host adapter keeps the component in the DOM. Only aftercomplete()moves the state toclosedand the presence handle confirms unmount does the adapter actually remove the component.
This makes the state machine work correctly with CSS transitions, because the DOM element is guaranteed to exist during entering and leaving even when the adapter would otherwise remove it immediately.
Relationship with module-presence
Section titled “Relationship with module-presence”transition is a specialization of presence. Think of @proto.ui/module-presence as the lower-level structural governance layer, while asTransition is the user-facing state machine built on top of it.
module-presencedecides when the host element may exist in the DOM. It tracks phases:absent → mounting → present → unmounting → absent.asTransitiondecides why the state changes. It exposes the 4-state FSM (closed → entering → entered → leaving → closed) and translates user actions (props, controls) into presence intents (setIntent('enter')orsetIntent('leave')).
This separation means:
- Adapters only deal with presence. A React or Vue adapter does not need to know about
enteringvsentered; it only needs to respect themount()/unmount()signals frommodule-presence. - Transition authors only deal with the state machine. They do not need to worry about host-level structural timing because
asTransitiondelegates that tomodule-presenceautomatically. - Physical removal semantics differ by host. In Web Components, the browser controls structural presence via
connectedCallback/disconnectedCallback. In React and Vue, the adapters rendernullwhen the presence handle signals unmount, so the element is truly removed from the DOM onceleaving → closedcompletes. To ensure CSS transitions still animate correctly on re-enter (closed → entering), React/Vue adapters insert a one-frame baseline: on the first commit after a fresh mount, they temporarily forcedata-transition-state="closed", force a reflow, then restore the real state. This gives the browser the style anchor it needs before switching toentering.
Best Practices
Section titled “Best Practices”1. Always pair state changes with CSS transitions
Section titled “1. Always pair state changes with CSS transitions”Transition only manages logical state. If you do not provide CSS for data-transition-state="entering" and data-transition-state="leaving", the element will appear/disappear instantly when complete() is called.
2. In controlled mode, the host must call complete()
Section titled “2. In controlled mode, the host must call complete()”When using the open prop, the adapter (or your wrapper code) is responsible for observing the CSS transition end and calling controls.complete(). Without this, the state will hang in entering or leaving indefinitely.
Typical controlled-mode wiring:
// After setting open=true and applying CSS transitionel.addEventListener('transitionend', () => { exposes.controls.complete();});3. Use appear for mount-time animation
Section titled “3. Use appear for mount-time animation”If you want an element to animate in when it first renders, set appear: true. Otherwise the element will jump straight to entered on mount without playing the enter animation.
4. Choose an interrupt strategy that matches your UX
Section titled “4. Choose an interrupt strategy that matches your UX”'reverse'(default): Best for most UI (menus, tooltips). If the user toggles quickly, the animation reverses direction smoothly.'wait': Best when you must guarantee every transition finishes (e.g. a multi-step wizard whose background panel should not skip animations).'immediate': Best for snappy stateless toggles where you do not care about partial animations. The current animation is discarded and the new one starts from a clean state.
5. Do not modify transition state directly
Section titled “5. Do not modify transition state directly”Always use the exposed controls (controls.enter(), controls.leave(), controls.complete()) or the open prop. Mutating transitionState directly will bypass the presence intent layer and can cause adapters to get out of sync.
6. Keep DOM children stable during leaving
Section titled “6. Keep DOM children stable during leaving”Because module-presence may keep the element in the DOM during leaving, avoid destroying child state (like tearing down <video> elements or canceling network requests) until the adapter actually unmounts or closed is reached.
State Machine
Section titled “State Machine”closed → entering → entered → leaving → closed- closed: Invisible, typically
display: noneor removed from DOM - entering: Entering animation in progress, DOM exists, entering styles applied
- entered: Fully visible, stable state
- leaving: Leaving animation in progress, DOM still exists, leaving styles applied
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | - | Controlled mode: target presence state |
defaultOpen | boolean | false | Uncontrolled mode: initial state |
appear | boolean | false | Whether to play enter animation on mount |
enterDuration | number | 300 | Expected enter animation duration (ms) |
leaveDuration | number | 200 | Expected leave animation duration (ms) |
interrupt | 'reverse' | 'wait' | 'immediate' | 'reverse' | Interruption strategy |
Imperative API
Section titled “Imperative API”Accessed via the controls expose:
enter(): Trigger enterleave(): Trigger leavecomplete(): Mark current transition complete, advance to next state
v0 Scope
Section titled “v0 Scope”base-transition v0 only manages the presence lifecycle of a single element (closed → entering → entered → leaving → closed).
Nested exit coordination (e.g., an outer Dialog waiting for an inner child to finish leaving before closing) belongs to a separate transition-group / boundary abstraction and is intentionally out of scope for v0.