Motion
Part
Part animates one element inside a screen or its shared bar, such as a header title, in step with the screen transition.
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.
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.
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"; }}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.
<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
Partwhosenameis not registered does not move. Development warns once and points atpartTransitions
Part slots
createPartTransition reuses screen slot names with different meanings. Read each slot by what the Part's screen is doing.
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
activefollows the stack, not the direction of motion. On pop, the closing screen stays on top andtrue- 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
exitanimates fromenter - Before
dismissexisted, fading both halves of a pair on pop meant restating all ten variants throughcreateRawPartTransition - 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.
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.
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
durationwins, including0to change instantly. Resolution uses??, not||, so an authored0survives - 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 }).
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
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.