Motion
Putting it together
Build an inbox step by step: a header whose title changes, a panel with its own back stack, a card that grows into the next screen, and a menu drawn above everything.
This page builds the inbox above one piece at a time. Each step adds one component and changes one thing you can see.
1. One Router for the app
Start with two screens, the inbox and a message. One Router holds both, and pushing a message slides it in with cupertino.
<Router name="app" defaultTransitionName="cupertino"> <Route path="/workspace" element={<Workspace />} /> <Route path="/message/:id" element={<Message />} /></Router>2. A header that stays while its title changes
Both screens render the same header. Give them the same sharedTopBarId and the header stays in place during the transition instead of sliding with the screen.
Wrap what differs between the two screens, the title and the left button, in Part. Only those parts animate, with the motion you register in the Router's partTransitions. They use the screen's duration and follow a swipe without extra code. See .
function Header({ title, action }: { title: string; action: ReactNode }) { return ( <header> <Part name="header-action">{action}</Part> <Part name="header-title"> <h1>{title}</h1> </Part> </header> );}// Workspace<Screen sharedTopBar={<Header title="Inbox" action={<MenuButton />} />} sharedTopBarId="app-header">// Message<Screen sharedTopBar={<Header title="Message" action={<BackButton />} />} sharedTopBarId="app-header">3. A panel with its own back stack
The lower panel of the inbox has its own pages, a list and filters. Put a second Router inside the inbox screen. history="memory" keeps its pages out of the URL.
Moving between those pages animates only the panel. The header and the browser history stay as they are.
<Screen sharedTopBar={<Header title="Inbox" action={<MenuButton />} />} sharedTopBarId="app-header"> <Router name="pane" history="memory" initPath="/list"> <nav>Local tools</nav> <Slot> <Route path="/list" element={<MessageList />} /> <Route path="/filters" element={<Filters />} /> </Slot> </Router></Screen>Without Slot, every child of a Router is treated as a route, so the nav above would be ignored. Wrap the routes in Slot whenever a Router has other children.
4. Opening a full screen from the panel
useNavigate() targets the closest Router, which inside the panel is pane. To open the message over the whole app, name the outer Router.
function MessageList() { const pane = useNavigate(); // the panel's Router const app = useNavigate({ router: "app" }); // the app Router return ( <Screen> <button onClick={() => pane.push("/filters")}>Local filters</button> <button onClick={() => app.push("/message/:id", { id: "7" })}>Open message</button> </Screen> );}A name reaches the Routers that contain the component, never a sibling. The other ways to choose a Router are in .
5. A card that grows into the next screen
Wrap the card on the inbox and the card on the message in Morph with the same layoutId. On push the card grows from its place in the inbox into the message; on pop it shrinks back.
// Workspace, outside the panel<Morph layoutId="featured-message" onClick={() => app.push("/message/:id", { id: "42" })}> <article>…</article></Morph>// Message<Morph layoutId="featured-message"> <article>…</article></Morph>Keep the card in the inbox screen itself, not inside the panel. A Morph only pairs with Morphs on screens of the same Router, and inside the panel it would belong to pane, not app. See .
When the title appears on both screensDetails
The card title is the same text at two sizes. Give it its own Morph with name="text" inside the card Morph, so the same letters resize instead of two copies fading over each other.
Set display: block, the font size and the line height on that text Morph, and put it in a wrapper that keeps the line's height. An inline element would show the words at the destination before they move.
Text that is different on each screen, like the "Today" label, is not shared. Put it in a Part outside the text Morph so it fades out and back in.
6. A menu above the header
The menu opens from the panel but should cover the whole app, header included. A screen's content is drawn under the shared header, and Layer draws it above. To keep the menu inside the panel instead, render it without Layer. See .
{menuOpen && ( <Layer> <div role="dialog" aria-modal="true">…</div> </Layer>)}Check before shipping
- Go forward and back inside the panel. The header and the browser history do not change.
- Open the message from the panel. The whole app moves, not just the panel.
- Open the menu. It covers the header, and no screen changes.
- Swipe back slowly, let go early once, then swipe all the way. The title, the button, the screen and the card move together.
- Try a long title and no left button, so the header layout is tested rather than assumed.
- Judge the motion on a real device with developer tools closed. Then add
@flemo/devtoolsand check thatwindow.flemo.report()lists no anomalies.
Full code
import { useState, type ReactNode } from "react";import { Layer, Morph, Part, Route, Router, Screen, Slot, createPartTransition, useNavigate} from "@flemo/react";const ARRIVE = [0.4, 0, 1, 1] as const;const LEAVE = [0, 0, 0.2, 1] as const;// cupertino's easing, so the header moves in step with the screen on a swipe.const TRANSITION_EASE = [0.32, 0.72, 0, 1] as const;const headerTitle = createPartTransition({ name: "header-title", initial: { opacity: 0, x: 72 }, idle: { value: { opacity: 1, x: 0 }, options: { ease: TRANSITION_EASE } }, enter: { value: { opacity: 0, x: -72 }, options: { ease: TRANSITION_EASE } }, exit: { value: { opacity: 1, x: 0 }, options: { ease: TRANSITION_EASE } }, dismiss: { value: { opacity: 0, x: 72 }, options: { ease: TRANSITION_EASE } }});const headerAction = createPartTransition({ name: "header-action", initial: { opacity: 0, x: 12 }, idle: { value: { opacity: 1, x: 0 }, options: { ease: TRANSITION_EASE } }, enter: { value: { opacity: 0, x: -12 }, options: { ease: TRANSITION_EASE } }, exit: { value: { opacity: 1, x: 0 }, options: { ease: TRANSITION_EASE } }, dismiss: { value: { opacity: 0, x: 12 }, options: { ease: TRANSITION_EASE } }});const cardCopy = createPartTransition({ name: "card-copy", initial: { opacity: 0 }, idle: { value: { opacity: 1 }, options: { ease: ARRIVE } }, enter: { value: { opacity: 0 }, options: { ease: LEAVE } }, exit: { value: { opacity: 1 }, options: { ease: ARRIVE } }, dismiss: { value: { opacity: 0 }, options: { ease: LEAVE } }});function Header({ title, action }: { title: string; action: ReactNode }) { return ( <header className="app-header"> <Part name="header-action">{action}</Part> <Part name="header-title"> <h1>{title}</h1> </Part> </header> );}export function AppRouter() { return ( <Router name="app" strictRoutes defaultTransitionName="cupertino" partTransitions={[headerTitle, headerAction, cardCopy]} > <Route path="/workspace" element={<Workspace />} /> <Route path="/message/:id" element={<Message />} /> </Router> );}function Workspace() { const app = useNavigate({ router: "app" }); return ( <Screen sharedTopBar={<Header title="Inbox" action={<button>Menu</button>} />} sharedTopBarId="app-header" > <Morph name="shared" layoutId="featured-message" onClick={() => app.push("/message/:id", { id: "42" })} > <article> <Part name="card-copy"> <small>Today</small> </Part> <span style={{ display: "block", height: 24 }}> <Morph as="span" name="text" layoutId="featured-message-title" style={{ display: "block", fontSize: 16, lineHeight: "24px" }} > Featured message </Morph> </span> <Part name="card-copy"> <p>Open message</p> </Part> </article> </Morph> <Router name="pane" history="memory" initPath="/list" className="pane"> <nav>Local tools</nav> <Slot className="pane-slot"> <Route path="/list" element={<MessageList />} /> <Route path="/filters" element={<Filters />} /> </Slot> </Router> </Screen> );}function MessageList() { const pane = useNavigate(); const app = useNavigate({ router: "app" }); const [overlayOpen, setOverlayOpen] = useState(false); return ( <Screen> <button onClick={() => pane.push("/filters")}>Local filters</button> <button onClick={() => app.push("/message/:id", { id: "7" })}>Full-screen message</button> <button onClick={() => setOverlayOpen(true)}>Open command layer</button> {overlayOpen && ( <Layer> <div className="app-overlay" role="dialog" aria-modal="true"> <button onClick={() => setOverlayOpen(false)}>Close</button> </div> </Layer> )} </Screen> );}function Filters() { return <Screen>Filters</Screen>;}function Message() { const app = useNavigate({ router: "app" }); return ( <Screen sharedTopBar={ <Header title="Message" action={<button onClick={() => app.pop()}>Back</button>} /> } sharedTopBarId="app-header" > <Morph name="shared" layoutId="featured-message"> <article> <Part name="card-copy"> <small>Message 42</small> </Part> <span style={{ display: "block", height: 32 }}> <Morph as="span" name="text" layoutId="featured-message-title" style={{ display: "block", fontSize: 24, lineHeight: "32px" }} > Featured message </Morph> </span> <Part name="card-copy"> <p>Full message</p> </Part> </article> </Morph> </Screen> );}