Transition
Class-driven enter and leave transitions. Transition adds and removes CSS
classes around a visibility change, waiting for the element's own animations to
finish between steps, and keeps it mounted until the leave transition is over.
An element can therefore animate out instead of vanishing.
It pairs well with every disclosure-based component. Give the panel
unmount={false} and let the transition own the mounting.
import { Transition, TransitionChild } from 'terracotta/transition';Anatomy
<Transition show> {/* provides `show` and transitions itself */}
<TransitionChild/>{/* optional: extra elements sharing the same show flag */}
</Transition>Transition is a TransitionChild with a show prop. The simple case needs
only one component.
How the classes are applied
On enter:
beforeEnter()runs, then theenterandenterFromclasses are added.- Once the element has nothing left animating,
enterFromis removed andenterToadded. - Once that animation finishes,
enterandenterToare removed,enteredis added, andafterEnter()runs.
On leave:
beforeLeave()runs and every nestedTransitionChildleaves first.enteredis removed, andleaveandleaveFromare added.- Once the element has nothing left animating,
leaveFromis removed andleaveToadded. - Once that animation finishes,
leaveandleaveToare removed, the element is hidden (or unmounted), andafterLeave()runs.
Every wait goes through Element.getAnimations() rather than a transitionend
listener, so an element with nothing to animate advances on the next microtask
instead of waiting for an event that would never arrive.
Each prop takes a space-separated class string, so one phase can carry several class names.
Interrupting
A show change part-way through a transition takes effect immediately. The
running transition stops at its next step, its afterEnter or afterLeave
never fires, and the opposite one starts, clearing the classes the interrupted
one left behind. So a panel shown again while it is fading out enters from
enterFrom rather than easing back from wherever the fade had got to: the phase
lives entirely in the classes, and there is no partial value to resume from.
Examples
Fade
Reusing fade-to as leaveFrom and fade-from as leaveTo makes the leave a
mirror of the enter. That is usually what you want.
Slide and fade
<Transition
show={open()}
class="drawer"
enter="slide-enter" enterFrom="slide-from" enterTo="slide-to"
leave="slide-leave" leaveFrom="slide-to" leaveTo="slide-from"
>
…
</Transition>.slide-enter { transition: opacity 220ms ease-out, translate 220ms ease-out; }
.slide-leave { transition: opacity 160ms ease-in, translate 160ms ease-in; }
.slide-from { opacity: 0; translate: 1rem 0; }
.slide-to { opacity: 1; translate: none; }Keyframe animations
Keyframe animations are waited on exactly like transitions, so they work without changes:
<Transition
show={visible()}
class="toast"
enter="pop-in"
leave="pop-out"
>
Saved
</Transition>.pop-in { animation: pop-in 180ms ease-out; }
.pop-out { animation: pop-out 140ms ease-in; }
@keyframes pop-in {
from { opacity: 0; scale: 0.9; }
to { opacity: 1; scale: 1; }
}
@keyframes pop-out {
from { opacity: 1; scale: 1; }
to { opacity: 0; scale: 0.9; }
}A resting state with entered
entered is applied once the enter transition finishes, and removed when
leaving starts. Use it for a state that should not be part of the animation:
<Transition
show={open()}
class="panel"
enter="fade-enter" enterFrom="fade-from" enterTo="fade-to"
entered="panel-open"
leave="fade-leave" leaveFrom="fade-to" leaveTo="fade-from"
>
…
</Transition>.panel-open { box-shadow: 0 8px 24px rgb(0 0 0 / 0.12); }Around a disclosure panel
<Disclosure class="disclosure" defaultOpen={false}>
{({ isOpen }) => (
<>
<DisclosureButton class="disclosure-button">Toggle</DisclosureButton>
<Transition
show={isOpen()}
enter="fade-enter" enterFrom="fade-from" enterTo="fade-to"
leave="fade-leave" leaveFrom="fade-to" leaveTo="fade-from"
>
<DisclosurePanel class="disclosure-panel" unmount={false}>…</DisclosurePanel>
</Transition>
</>
)}
</Disclosure>unmount={false} on the panel is the important part. Without it, the panel
removes itself the moment the disclosure closes, and the leave transition has
nothing to animate.
Coordinating several elements
TransitionChild reads show from the nearest Transition. A group of
elements can therefore animate on different timings while still leaving
together, because the parent waits for its children's leave transitions before
finishing its own.
Running work around a transition
<Transition
show={open()}
enter="fade-enter" enterFrom="fade-from" enterTo="fade-to"
leave="fade-leave" leaveFrom="fade-to" leaveTo="fade-from"
beforeEnter={() => void preloadImages()}
afterEnter={() => inputRef?.focus()}
afterLeave={() => resetForm()}
>
…
</Transition>afterLeave is the right place to discard state. It runs once the element is
gone.
Respecting reduced motion
@media (prefers-reduced-motion: reduce) {
.fade-enter, .fade-leave, .pop-enter, .pop-leave {
transition-duration: 1ms;
}
}Shortening the duration is enough, and dropping the transition altogether is safe too: Terracotta waits on the element's running animations, so an element with nothing to animate simply moves straight to the next step.
State attributes
| Element | Attribute | Present when |
|---|---|---|
Transition / TransitionChild |
tc-transition |
Always, carrying the current phase |
Transition / TransitionChild |
inert |
While leaving, and for as long as the element stays mounted afterwards |
The phase values, in order:
| Value | Meaning |
|---|---|
enter-from |
The enter transition has just started; the element is at its starting state. |
enter-to |
The enter transition is running towards its end state. |
entered |
The enter transition has finished; the element is at rest and visible. |
leave-from |
The leave transition has just started. |
leave-to |
The leave transition is running towards its end state. |
The attribute is absent until the first transition runs.
Styling
You can drive the whole transition from the attribute and skip the class props entirely:
<Transition show={open()} class="sheet">…</Transition>.sheet {
transition: opacity 200ms ease, translate 200ms ease;
}
.sheet[tc-transition="enter-from"],
.sheet[tc-transition="leave-to"] {
opacity: 0;
translate: 0 0.5rem;
}
.sheet[tc-transition="enter-to"],
.sheet[tc-transition="entered"],
.sheet[tc-transition="leave-from"] {
opacity: 1;
translate: none;
}This works because the attribute changes at exactly the points the classes do.
The attribute is also handy for debugging and for assertions:
/* Temporarily surface the phase while developing */
[tc-transition]::after {
content: attr(tc-transition);
position: absolute;
font-size: 0.625rem;
color: #dc2626;
}Reading the state in code
Transition exposes no state object and no render prop. show is a prop you
already own. To react in JavaScript, use the lifecycle callbacks: beforeEnter,
afterEnter, beforeLeave and afterLeave.
Keyboard
Neither component handles keys. Whatever it wraps keeps its own keyboard behaviour.
API
<Transition>
The root. It provides show to its descendants and renders a TransitionChild
with the rest of its props, so it takes every TransitionChild prop too.
Renders a <div> by default.
| Prop | Type | Default | Description |
|---|---|---|---|
show |
boolean |
required | Whether the content should be visible. Drives every TransitionChild beneath it. |
…plus all TransitionChild props |
<TransitionChild>
One transitioning element. Renders a <div> by default.
| Prop | Type | Default | Description |
|---|---|---|---|
as |
ValidConstructor |
'div' |
Element or component to render as. |
enter |
string |
none | Classes applied for the whole enter transition. Put the transition or animation declaration here. |
enterFrom |
string |
none | Starting state of the enter transition. |
enterTo |
string |
none | Ending state of the enter transition. |
entered |
string |
none | Classes applied once entering has finished, and removed when leaving starts. |
leave |
string |
none | Classes applied for the whole leave transition. |
leaveFrom |
string |
none | Starting state of the leave transition. |
leaveTo |
string |
none | Ending state of the leave transition. |
beforeEnter |
() => void |
none | Called just before entering starts. |
afterEnter |
() => void |
none | Called once entering has finished. |
beforeLeave |
() => void |
none | Called just before leaving starts. |
afterLeave |
() => void |
none | Called once leaving has finished and the element is hidden. |
unmount |
boolean | 'offscreen' |
true |
How the element behaves while hidden. See unmount. |
appear |
boolean |
false |
Mount and start entering on the very first render. It matters for a nested TransitionChild, which otherwise waits for its parent to finish entering before it mounts and runs its own enter. |
ref |
DynamicNode<T> | (el) => void |
none | Handle to the rendered element. |
children |
JSX.Element |
none | The content. Not a render prop. |
| …rest | props of as |
none | Forwarded to the rendered element. |
TransitionChild throws if rendered outside a <Transition>.
Notes
- Each step ends when the element has no animation left running, read through
Element.getAnimations(). An element hidden with no transition or animation at all advances immediately rather than stalling, so a missing duration no longer leaves it stranded on screen. - Nested
TransitionChilds hold their parent back in both directions. A child enters only once its parent has entered, and a parent starts its own leave transition only once every child beneath it has finished leaving. - The element is marked
inertthe moment a leave starts, and stays inert for as long asunmount={false}keeps it around afterwards. A panel that is fading out is therefore unclickable and out of the tab order rather than lingering as a focus trap behind its own animation. The enter phases stay interactive, so a panel nested inside can still take focus while it animates in. unmount={true}andunmount="offscreen"need no such treatment: both take the element out of the document once the leave transition has finished, so the content is already unreachable. The difference between them is only what happens to the subtree behind the scenes. Seeunmount.- Panels that move focus into themselves, namely
DialogPanel,PopoverPanel,ContextMenuPanel,CommandBarPanel,ListboxOptionsandComboboxOptions, wait for the same animations to finish before focusing, so focus lands once the panel has settled rather than while it is still animating in.