Motion

Part

Part animates one element inside a screen or its shared bar, such as a header title, in step with the screen transition.

3 min read
JH

Places

Idlecupertino
Duration700ms
LivePush the detail and watch only the title change while the bar stays still, then swipe back slowly.

What a Part is

<Part name> wraps its children in a div and runs a registered part transition on that element alone. name is its only own prop; every other prop is a normal div prop.

UseWhen
PartOne element changes on each screen: a title, an action, a line of copy
One element is the same thing on two screens and moves between them
Fixed UI beside the UI identical on every route. It never moves
A screen transitionThe whole screen moves

Shared bars and Part

A bar that looks fixed but whose title changes belongs to each . Render it with sharedTopBar, give every screen the same sharedTopBarId, and wrap what changes in a Part.

parts/headerTitle.ts
import { createPartTransition } from "@flemo/react";​// cupertino's easing, so the title moves in step with the screen on swipes too.// No durations: each variant uses the screen's duration and delay.const EASE = [0.32, 0.72, 0, 1] as const;​export const headerTitle = createPartTransition({  name: "header-title",  initial: { opacity: 0, x: 72 },  idle: { value: { opacity: 1, x: 0 }, options: { ease: EASE } },  enter: { value: { opacity: 0, x: -72 }, options: { ease: EASE } },  exit: { value: { opacity: 1, x: 0 }, options: { ease: EASE } },  dismiss: { value: { opacity: 0, x: 72 }, options: { ease: EASE } }});​declare module "@flemo/react" {  interface RegisterPartTransition {    "header-title": "header-title";  }}
Inbox.tsx
import { Part, Screen } from "@flemo/react";​function Header({ title }: { title: string }) {  return (    <header className="app-header">      <Part name="header-title">        <h1>{title}</h1>      </Part>    </header>  );}​export default function Inbox() {  return (    <Screen sharedTopBar={<Header title="Inbox" />} sharedTopBarId="app-header">      <MailList />    </Screen>  );}

Register part transitions on the Router, beside transitions and decorators. The RegisterPartTransition augmentation gives name autocomplete.

tsx
<Router partTransitions={[headerTitle]}>  <Route path="/" element={<Inbox />} />  <Route path="/mail/:id" element={<Mail />} /></Router>
How a shared bar changes between screensDetails
  • Two bars are treated as the same bar only when their ids are equal. Two bars without an id match by position, but a bar with an id never matches one without
  • Each screen renders its own copy of the bar. While the transition runs, the Parts of a matched bar are moved into a separate part layer, so the copy on the screen underneath is not hidden under the other screen's opaque background. They move back when the transition ends
  • A Part follows the transition of the Router that renders the screen around it. A Part in a nested Router's fixed UI follows the outer Router's transition. Outside every screen, it follows the nearest Router
  • A part transition is found by name under any transition in the Router. Its timing is resolved separately for each transition
  • A Part whose name is not registered does not move. Development warns once and points at partTransitions

Part slots

createPartTransition reuses screen slot names with different meanings. Read each slot by what the Part's screen is doing.

SlotThe Part's screenAnimates from
initialThe new screen's starting style on push or replaceNot animated
idleAt rest, and the new screen on push or replaceinitial, on the new screen
enterThe previous screen on push or replace, moving behind or staying thereidle
exitThe previous screen coming back on popenter
dismissOptional. The top screen closing on pop. Omitted, it keeps idleidle

Parts animate from the previous variant's values, not from initial. Match exit to idle so the returning Part ends without a jump.

Without dismiss, the closing screen's Part stays fully opaque while the returning one fades in, so only one side of a matched pair moves. Set all five, as headerTitle does.

Status and slot tableDetails
StatusactiveScreen roleScreen slotPart slot
PUSHINGtrueNew screen, now on topenteridle
PUSHINGfalsePrevious screen, moving behindexitenter
REPLACINGtrueNew screenenteridle
REPLACINGfalseScreen being replacedexitenter
POPPINGtrueClosing screen, still on topenterBackdismiss, else idle
POPPINGfalsePrevious screen, coming backexitBackexit
COMPLETEDtrueActive, transition endedenteridle
COMPLETEDfalseBehind, transition endedexitenter
  • active follows the stack, not the direction of motion. On pop, the closing screen stays on top and true
  • Applying screen slot meanings to a Part animates the wrong side and can look like it fades only one way
  • POPPING-false starts where PUSHING-false ended, which is why exit animates from enter
  • Before dismiss existed, fading both halves of a pair on pop meant restating all ten variants through createRawPartTransition
  • On a programmatic push, replace or pop, a Part animates with its screen's transition from compiled keyframes. There is no per-frame code

Timing

Leave duration out of a Part. It takes the screen's timing variant by variant, so material Parts run 0.35s on push and 0.25s on pop.

Part
DurationAuthored, else the screen's same variant key
DelayAuthored, else the screen's. after: "transition" adds the screen's delay plus duration
EaseAuthored only, never inherited. To stay in step, use the screen's easing, such as cupertino's [0.32, 0.72, 0, 1]

Starting after the transition ends

UI that appears once the transition ends cannot know how long the transition takes. after: "transition" means "start after the screen transition ends", under any transition.

parts/detailChrome.ts
import { createPartTransition } from "@flemo/react";​const SHOWN = { opacity: 1, y: 0 };const HIDDEN = { opacity: 0, y: -24 };​export const detailChrome = createPartTransition({  name: "detail-chrome",  initial: HIDDEN,  // Waits until the screen transition ends, then lowers the header.  idle: { value: SHOWN, options: { duration: 0.32, after: "transition", ease: [0, 0, 0.2, 1] } },  enter: { value: SHOWN, options: { duration: 0 } },  exit: { value: SHOWN, options: { duration: 0 } },  dismiss: { value: HIDDEN, options: { duration: 0.16, ease: [0.4, 0, 1, 1] } }});

delay adds to it: { after: "transition", delay: 0.04 } starts 40ms after the transition ends. Under none the transition takes no time, so the variant starts at once.

A Part that runs longer than its screen keeps the whole transition running until the Part finishes, and swipe-back is disabled until then.

Timing inheritanceDetails
  • Inheritance uses the same variant key. A Part's PUSHING-false runs with the timing of the screen's PUSHING-false
  • An explicit duration wins, including 0 to change instantly. Resolution uses ??, not ||, so an authored 0 survives
  • A Part outside any screen has no screen transition to take timing from and keeps what it authored. An omitted duration there resolves to zero, so the Part changes instantly
  • Do not copy screen durations into Parts to keep them in sync. Copied numbers drift apart
  • Easing is not inherited because a Part is found by name under any transition. Taking each transition's easing would move the same Part differently on every transition
  • Equal duration does not mean the Part is at the same point along its path as the screen. See Swipe below

Swipe

The pop you declare is already the swipe animation. A Part with no swipe hooks follows the same POPPING progress as its screen, with the timing resolved for that Part, while dragging and when the swipe goes back or is cancelled. No per-frame code is required.

During a swipe, the Part's position follows the finger. A programmatic pop plays the easing over time. Give the Part's variants the screen's easing, or the two will not match.

onSwipeStart, onSwipe and onSwipeEnd replace the built-in swipe tracking. You do not need them to turn tracking on. Adding any one of them stops that Part from following the swipe on its own, on both screens.

Custom swipe hooksDetails

With a hook, you control the Part's style during the drag and how it ends, both when the swipe goes back and when it is cancelled. Use hooks only when the Part should move differently from the declared pop.

Each hook receives (triggered, { animate, element, active }). onSwipe also receives progress from 0 to 100, as (triggered, progress, { animate, element, active }).

parts/panelTitle.ts
import { createPartTransition } from "@flemo/react";​const EASE = [0.32, 0.72, 0, 1] as const;​export const panelTitle = createPartTransition({  name: "panel-title",  initial: { opacity: 1, y: 0 },  idle: { value: { opacity: 1, y: 0 }, options: { duration: 0 } },  enter: { value: { opacity: 0.35, y: -10 }, options: { ease: EASE } },  exit: { value: { opacity: 1, y: 0 }, options: { ease: EASE } },  options: {    onSwipe: (_, progress, { animate, element, active }) => {      if (active) return;      const travelled = Math.min(1, Math.max(0, progress / 100));      const faded = Math.min(1, travelled / 0.55);      animate(        element,        { opacity: 0.35 + 0.65 * faded, y: -10 * (1 - travelled) },        { duration: 0 }      );    },    onSwipeEnd: (triggered, { animate, element, active }) => {      if (active) return;      animate(element, triggered ? { opacity: 1, y: 0 } : { opacity: 0.35, y: -10 }, {        duration: 0.3,        ease: EASE      });    }  }});

This finishes the opacity change in the first 55% of the gesture while the position keeps moving across the whole drag. The built-in swipe tracking cannot split them like that.

if (active) return; keeps the closing screen's title under custom control without setting any style on it. The title on the inactive, returning screen is animated and brought to its final values whether the swipe goes back or is cancelled.

Remove the whole options block when both properties should follow the declared pop.

Pitfalls

SymptomFix
A Part never movesPass its transition to <Router partTransitions>
Only one side of a pair fades on popAdd dismiss
The Part changes instantly while the screen takes 0.7sIt sits outside any screen. Give it a duration
The Part travels a different distance on swipe and on the back buttonGive its variants the screen's easing
Swipe-back stays disabled after a navigationShorten Parts that outlast their screen
Parts inside a MorphDetails
  • Body copy inside a that re-wraps and then jumps belongs in a Part. A Part lays out once at its width at rest, and while the Morph grows it is clipped instead of re-wrapped
  • If a container Morph shows old and new copy together, the copy that differs is being shown by the fading copy of the old element. Keep the shared item as a nested Morph and put the changing copy in a sibling Part
  • That Part does not wrap a nested Morph that stays visible throughout, and the shared Morph's parent does not fade
Raw part transitionsDetails

createRawPartTransition sets every status like createRawTransition: idle, pushOnEnter / pushOnExit, replaceOnEnter / replaceOnExit, popOnEnter / popOnExit, and completedOnEnter / completedOnExit.

Raw Parts still take the screen's matching timing and follow its swipe. Any onSwipe* callback replaces that built-in swipe tracking. Easing is still not inherited.

Edit on GitHub