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.
Presets
Four presets are built in and need no registration. cupertino is the Router default when you omit defaultTransitionName.
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
cupertinofollows 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 replicatedmaterialpushes 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 asymmetrylayoutfades 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 elementlayout's drag is not its pop. It declarescurrent: { y: "100%", opacity: 0.96 }andprev: {}, so the sheet slides down while the previous screen stays stillnoneis 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.
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.
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.
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> );}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.
- Where each animation starts is fixed. PUSHING-true and REPLACING-true start from
initial - PUSHING-false, REPLACING-false and POPPING-true start from the
idlestyle - POPPING-false starts where PUSHING-false ended. With raw slots,
popOnExitstarts frompushOnExit - IDLE and COMPLETED are rest styles and never animate. A variant with zero duration and zero delay does not animate either
enterBackis the active screen leaving on pop. Incupertinoit isx: "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,colorand--customproperties, camelCased like React'sstyle - Bare numbers get units:
pxfor lengths,degfor rotations, unitless where CSS is unitless. Strings pass through verbatim, such as"100%"or"1rem" - Endpoints may differ in shape. A
clip-pathcan go frominset(0 0 0 100%)toinset(0), values can becalc(), and units can mix (50%to200px) - You may leave a property off one end.
transformchannels andopacityfall back to neutral (identity, fully opaque). Any other property starts from its current on-screen value - Option fields written on
initialare ignored; only its target is read - An ease string unknown to flemo and CSS compiles to
easeand 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.
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.
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.
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.
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 progressreturns one number for both screens or{ current, prev }. Results clamp to 0 to 1, andNaNreads as 0currentis the screen under the finger.previs 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.
infois{ point, offset, velocity, delta }, each an{ x, y }pair.eventis aPointerEventanimate(element, target, options?)writes a target to an element. Pass{ duration: 0 }to follow the finger, and a shortdurationwith aneaseto finish after release- Setting
currentorprevkeeps 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) andDEFAULT_COMMIT_VELOCITY(20) are exported for anonEndthat 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 fory) 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
yswipe 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.
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 screenreplaceOnEnter/replaceOnExit: REPLACING, the new screen and the replaced screenpopOnEnter/popOnExit: POPPING, the closing top screen and the previous screen coming back.popOnEnteris not the new screencompletedOnEnter/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
EnterorExit
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.