Motion

Transitions

Animate the screens of a stack with a built-in preset or your own createTransition, pick one per navigation, add a swipe gesture, and add a decorator such as a dim over the previous screen.

3 min read

Places

Saved for later

Idlecupertino
Duration700ms
LiveOpen a row, then drag the detail screen to the right and let go early to watch it return.

Presets

Four presets are built in and need no registration. cupertino is the Router default when you omit defaultTransitionName.

PresetMotionGesture
cupertinoSlides in from the right over 0.7s on [0.32, 0.72, 0, 1]. The previous screen moves back 30% under the overlay dimDrag right (x). Goes back past the default distance
materialRises from the bottom over 0.35s while the previous screen lifts 56px and fades. Pop runs 0.25sDrag down (y). Goes back at 56px and resists past it
layoutA 0.4s fade that does most of its change early. Only the new screen or the closing screen moves, leaving room for a Drag down (y) pulls the screen away. Goes back at 56px
noneThe screen changes at once. Every variant has zero durationNone

Use layout under shared elements: its fade is nearly over a third of the way through the transition. Use none when a should be the only thing that moves.

Preset detailsDetails
  • cupertino follows the measured iOS push: Ionic replicates UIKit at 540ms on the same curve. flemo runs it over 0.7s as an authored choice for a calmer glide. The native shadow on the new screen's leading edge is deliberately not replicated
  • material pushes on [0, 0, 0.2, 1] and [0.4, 0, 1, 1]. Its push and pop lengths differ, so every Part and decorator inherits the same asymmetry
  • layout fades the new screen in over the previous one, which stays still, on push. On pop, the closing screen fades out while the previous screen stays still. It sets no decorator, because a dim adds change where the eye should follow the shared element
  • layout's drag is not its pop. It declares current: { y: "100%", opacity: 0.96 } and prev: {}, so the sheet slides down while the previous screen stays still
  • none is also what an unregistered name resolves to. A misspelled name reads as a screen that simply appears, and development warns once per name

Choosing a transition per navigation

Set the default on the Router with defaultTransitionName. Override a single navigation with transitionName. There is no per-Route transition: motion belongs to the navigation, not the destination.

tsx
const navigate = useNavigate();​// this push onlynavigate.push("/posts/:slug", { slug: "hello" }, { transitionName: "material" });​// overrides the transition the closing screen was opened withnavigate.pop({ transitionName: "none" });

A pop without transitionName runs the transition the closing screen was opened with. See for the other navigation options.

Authoring a transition

createTransition takes six variants. Each slot except initial is { value, options }: a CSS target and its timing.

SlotWhich screenAnimates from
initialThe style a new screen has before it animatesNot animated
idleEither screen at restNot animated
enterThe new top screen on push or replace. It keeps this style after the transition endsinitial
exitThe previous screen moving behind on push or replace. It keeps this style after the transition endsidle
enterBackThe top screen closing on popidle
exitBackThe previous screen coming back on popexit
transitions/slide.ts
import { createTransition } from "@flemo/react";​const DURATION = 0.5;const EASE = [0.32, 0.72, 0, 1] as const;​export const slide = createTransition({  name: "slide",  initial: { x: "100%" },  idle: { value: { x: 0 }, options: { duration: 0 } },  enter: { value: { x: 0 }, options: { duration: DURATION, ease: EASE } },  exit: { value: { x: "-30%", opacity: 0.6 }, options: { duration: DURATION, ease: EASE } },  enterBack: { value: { x: "100%" }, options: { duration: DURATION, ease: EASE } },  exitBack: { value: { x: 0, opacity: 1 }, options: { duration: DURATION, ease: EASE } },  options: { swipe: { direction: "x" } }});​declare module "@flemo/react" {  interface RegisterTransition {    slide: "slide";  }}

Register it on the Router. The RegisterTransition augmentation makes transitionName and defaultTransitionName autocomplete.

App.tsx
import { Route, Router } from "@flemo/react";​import Home from "./Home";import { slide } from "./transitions/slide";​export default function App() {  return (    <Router transitions={[slide]} defaultTransitionName="slide">      <Route path="/" element={<Home />} />    </Router>  );}
OptionMeaning
durationSeconds
delaySeconds
easeA named ease (linear, ease, easeIn, easeOut, easeInOut, circIn, circOut, backIn, backOut, anticipate), a CSS keyword, cubic-bezier(), steps(), linear(), or a four-number tuple. Defaults to ease

active follows stack position, not travel direction. On pop, the closing screen is still the active top, so it plays enterBack.

Status and slot tableDetails

Every screen carries a status and an active flag. This table maps each pair to the slot every factory reads.

StatusactiveScreen role`createTransition``createRawTransition`Decorator
PUSHINGtrueNew screen, new topenterpushOnEnteridle
PUSHINGfalsePrevious screen, moving behindexitpushOnExitenter
REPLACINGtrueNew screenenterreplaceOnEnteridle
REPLACINGfalseReplaced screenexitreplaceOnExitenter
POPPINGtrueClosing, still on topenterBackpopOnEnteridle
POPPINGfalsePrevious screen, coming backexitBackpopOnExitexit
COMPLETEDtrueActive, transition endedentercompletedOnEnteridle
COMPLETEDfalseBehind, transition endedexitcompletedOnExitenter
IDLEeitherAt restidleidleidle
  • Where each animation starts is fixed. PUSHING-true and REPLACING-true start from initial
  • PUSHING-false, REPLACING-false and POPPING-true start from the idle style
  • POPPING-false starts where PUSHING-false ended. With raw slots, popOnExit starts from pushOnExit
  • IDLE and COMPLETED are rest styles and never animate. A variant with zero duration and zero delay does not animate either
  • enterBack is the active screen leaving on pop. In cupertino it is x: "100%"
  • Reading active === "true" as the new screen pairs morphs backwards on every pop. and slots differ again; check their pages

What you can animate

A target accepts any animatable CSS property, with autocomplete. Transform shortcuts x, y, z, scale, scaleX, scaleY, rotate, rotateX, rotateY and rotateZ compile into one transform.

Values and interpolationDetails
  • Targets cover the CSS surface: clipPath, filter, borderRadius, boxShadow, color and --custom properties, camelCased like React's style
  • Bare numbers get units: px for lengths, deg for rotations, unitless where CSS is unitless. Strings pass through verbatim, such as "100%" or "1rem"
  • Endpoints may differ in shape. A clip-path can go from inset(0 0 0 100%) to inset(0), values can be calc(), and units can mix (50% to 200px)
  • You may leave a property off one end. transform channels and opacity fall back to neutral (identity, fully opaque). Any other property starts from its current on-screen value
  • Option fields written on initial are ignored; only its target is read
  • An ease string unknown to flemo and CSS compiles to ease and warns once in development

Values use the browser's own CSS interpolation. A pair CSS can only change discretely jumps at the midpoint, as native CSS would. Two inset() values tween; inset() to circle() jumps.

This wipe reveals the new screen with a clip-path that opens left to right. The previous screen recedes with a little scale and opacity.

transitions/wipe.ts
import { createTransition } from "@flemo/react";​const EASE = [0.65, 0, 0.35, 1] as const;​export const wipe = createTransition({  name: "wipe",  initial: { clipPath: "inset(0 0 0 100%)" },  idle: { value: { clipPath: "inset(0)", scale: 1, opacity: 1 }, options: { duration: 0 } },  enter: { value: { clipPath: "inset(0)" }, options: { duration: 0.45, ease: EASE } },  enterBack: { value: { clipPath: "inset(0 0 0 100%)" }, options: { duration: 0.38, ease: EASE } },  exit: { value: { scale: 0.96, opacity: 0.8 }, options: { duration: 0.45, ease: EASE } },  exitBack: { value: { scale: 1, opacity: 1 }, options: { duration: 0.38, ease: EASE } }});​declare module "@flemo/react" {  interface RegisterTransition {    wipe: "wipe";  }}

Swipe

Add swipe: { direction } to options and the transition becomes draggable, as slide above is. Dragging plays the transition's own pop, following the finger. There is no per-frame code.

On release, the screen goes back if the gesture went past threshold or the finger was still moving faster than velocity. Either alone is enough. A transition without swipe has no gesture.

OptionTypeDefaultRole
direction"x" or "y"RequiredThe axis the gesture travels
thresholdnumber or (span) => number50px on a 390px screen, scaled to the spanHow far, in px, the gesture must carry the screen to go back
velocitynumber20How fast the finger must still be moving to go back however little it travelled
progress(info, span) => number or { current, prev }Distance carried over the screen's own width or heightWhere each screen is along its travel, 0 to 1. Write it for a drag that resists or clamps
current / prevTransitionTarget or SwipeStop[]The pop's own targetsWhere the drag carries each screen, when that is not where the pop takes it
onStarthookNoneAccept or refuse the gesture before anything moves
onMove / onEndhooksNoneDrive the drag yourself. See the warning below

The progress flemo computes also drives the transition's decorator and every on both screens, so they all follow the same gesture.

Writing onMove or onEnd without setting current or prev leaves both screens and the decision to go back to you. Set a destination instead whenever the drag only moves the screens to a fixed style.

Swipe recipesDetails

cupertino's whole gesture is swipe: { direction: "x" }. Its drag follows the finger one for one, which is the geometric default.

material needs progress because its two screens move at different rates. The dragged screen travels its own height and keeps moving as the rubber band stretches. The previous screen travels 56px and stops.

material's swipe
import type { SwipeOptions } from "@flemo/react";​const PULL = 56;​// One for one up to PULL, then a square-root falloff.const pull = (dragY: number) => {  const followed = Math.max(0, Math.min(PULL, dragY));  const over = Math.max(0, dragY - PULL);  return followed + Math.sqrt(Math.min(1, over / 160)) * 12;};​export const swipe: SwipeOptions = {  direction: "y",  threshold: PULL,  progress: (info, span) => {    const pulled = pull(info.offset.y);    return {      current: span > 0 ? pulled / span : 0,      prev: Math.min(PULL, pulled) / PULL    };  }};

When properties travel at different rates, list stops. at is where along the drag a style is reached, 0 to 1. The last stop is the end.

ts
import type { SwipeOptions } from "@flemo/react";​export const swipe: SwipeOptions = {  direction: "x",  // opacity is spent by 30% of the drag; x keeps going to the end  current: [    { at: 0.3, value: { x: "30%", opacity: 0 } },    { value: { x: "100%", opacity: 0 } }  ]};
  • You set only the destination. The drag starts where the screen already is, which is where the pop starts
  • An empty target, such as prev: {}, means that screen does not move and flemo leaves it untouched
  • progress returns one number for both screens or { current, prev }. Results clamp to 0 to 1, and NaN reads as 0
  • current is the screen under the finger. prev is the previous screen revealed behind it
Swipe internals and hooksDetails

Whoever moves the screens also handles the release. With current or prev set, flemo moves the screens, decides whether the swipe goes back and sets the timing of the animation after release. Your onEnd then receives that decision as triggered, and its return value is not read.

A hook written without a destination beside it leaves the screens and the decision to you. That is right only for a drag that moves the screens themselves to arbitrary places. Writing onStart never takes the screens away from flemo.

HookSignatureRole
onStart(event, info, { animate, currentScreen, prevScreen, onStart }) => Promise<boolean>Resolve true to begin the swipe, false to leave the drag alone
onMove(event, info, { animate, currentScreen, prevScreen, onProgress }) => numberFires every drag frame. Without a declared destination, you move both screens. onProgress(triggered) reports the decision so the decorator and Parts follow; a second argument is accepted and not read
onEnd(event, info, { animate, currentScreen, prevScreen, triggered, onStart }) => Promise<boolean | void>When you move the screens: decide from info.offset and info.velocity, report the decision through onStart, finish both screens' animation, and return it
  • info is { point, offset, velocity, delta }, each an { x, y } pair. event is a PointerEvent
  • animate(element, target, options?) writes a target to an element. Pass { duration: 0 } to follow the finger, and a short duration with an ease to finish after release
  • Setting current or prev keeps flemo moving the screens whatever hooks you write. Hook writes to the screens are refused. Writes to other elements, such as a morphing element, go through
  • The cost of hooks: the browser's compositor does not see a drag written a value at a time as an animation, so it has to start one on release. Measured on an iPhone at 41 to 49ms of dropped frames on every release. A declared drag does not pay it
  • DEFAULT_COMMIT_FRACTION (50 / 390) and DEFAULT_COMMIT_VELOCITY (20) are exported for an onEnd that wants flemo's own rule
  • The duration of the animation after release is the shorter of two lengths: the authored curve's time for the remaining distance, or the time the finger's speed needs. It never exceeds the authored duration and is never under 0.12s
  • A drag needs 8px of movement in the positive direction (right for x, down for y) with a 3:1 lead over the other axis. Otherwise it stays a scroll for that pointer
  • A drag that begins inside a scroller on the swipe axis normally scrolls. A vertical scroller already at its top lets a y swipe start
  • Navigation input that arrives while a transition is running is ignored, not queued

Decorators

A transition can add a decorator, a dim or tint over the previous screen, set with options.decoratorName. cupertino sets the built-in overlay. See for writing one, its timing and how it follows a swipe.

Raw transitions

createTransition derives push, replace and pop from one symmetric set. createRawTransition has a slot for every status and side, so each operation can move differently.

transitions/shove.ts
import { createRawTransition } from "@flemo/react";​const DURATION = 0.4;​export const shove = createRawTransition({  name: "shove",  initial: { x: "100%" },  idle: { value: { x: 0 }, options: { duration: 0 } },  pushOnEnter: { value: { x: 0 }, options: { duration: DURATION } },  pushOnExit: { value: { x: "-30%" }, options: { duration: DURATION } },  replaceOnEnter: { value: { x: 0 }, options: { duration: DURATION } },  replaceOnExit: { value: { x: "-100%" }, options: { duration: DURATION } },  popOnEnter: { value: { x: "100%" }, options: { duration: DURATION } },  popOnExit: { value: { x: 0 }, options: { duration: DURATION } },  completedOnEnter: { value: { x: 0 }, options: { duration: 0 } },  completedOnExit: { value: { x: "-30%" }, options: { duration: 0 } }});​declare module "@flemo/react" {  interface RegisterTransition {    shove: "shove";  }}

Here a replace pushes the old screen fully off, while a push moves it back only 30%. createRawDecorator and createRawPartTransition use the same slot names.

Raw slots and advanced optionsDetails
  • pushOnEnter / pushOnExit: PUSHING, the new screen and the previous screen
  • replaceOnEnter / replaceOnExit: REPLACING, the new screen and the replaced screen
  • popOnEnter / popOnExit: POPPING, the closing top screen and the previous screen coming back. popOnEnter is not the new screen
  • completedOnEnter / completedOnExit: the styles after a navigation ends. They are rest styles and do not animate
  • Verify status mappings in declaration JSDoc rather than inferring them from Enter or Exit

options.driver: "native" lets the engine adjust the timing of this transition's running animation. It may hold the first frame, re-align the animation to the start of the transition, and re-align it after a stall.

By default, flemo protects the start of a transition by scheduling when animations start, and never touches a running animation. On WebKit, any such touch loses the accelerated out-of-process animation path. Opt in only after verifying the trade on real devices.

Edit on GitHub