Motion
Putting it together
Build a Places app 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 Places app 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 list of places and a place. One Router holds both, and pushing a place slides it in with cupertino.
<Router name="trip" defaultTransitionName="cupertino"> <Route path="/trip" element={<Home />} /> <Route path="/trip/:id" element={<Place />} /></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 TopBar({ title, action }: { title: string; action: ReactNode }) { return ( <header> <Part name="bar-action">{action}</Part> <Part name="bar-title"> <h1>{title}</h1> </Part> </header> );}// Home<Screen sharedTopBar={<TopBar title="Places" action={<Avatar />} />} sharedTopBarId="trip-bar">// Place<Screen sharedTopBar={<TopBar title="Kyoto" action={<BackButton />} />} sharedTopBarId="trip-bar">3. A panel with its own back stack
The Saved panel on the home screen has its own pages, the list and its filters. Put a second Router inside the home 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={<TopBar title="Places" action={<Avatar />} />} sharedTopBarId="trip-bar"> <FeaturedCard /> <Router name="panel" history="memory" initPath="/panel" className="panel"> <Route path="/panel" element={<Saved />} /> <Route path="/panel/filters" element={<Filters />} /> </Router></Screen>Without Slot, every child of a Router is treated as a route. If the panel also needs a heading or tabs beside its routes, wrap the routes in Slot.
4. Opening a full screen from the panel
useNavigate() targets the closest Router, which inside the panel is panel. To open a place over the whole app, name the outer Router.
function Saved() { const panel = useNavigate(); // the panel's Router const app = useNavigate({ router: "trip" }); // the app Router return ( <Screen> <button onClick={() => panel.push("/panel/filters")}>Filter</button> <button onClick={() => app.push("/trip/:id", { id: "lisbon" })}>Lisbon</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 photo on the featured card and the photo on the place in Morph with the same layoutId. On push the card grows from its spot on the home screen into the place; on pop it shrinks back.
// Home, outside the panel<button onClick={() => push("/trip/:id", { id: "kyoto" })}> <Morph layoutId="trip-art-kyoto"> <Photo place="kyoto" /> </Morph></button>// Place<Morph layoutId={`trip-art-${id}`}> <Photo place={id} /></Morph>Keep the card in the home 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 panel, not trip. That is why a place opened from the panel arrives with its screen instead of growing. Let the photo fill the Morph's box, because the box is laid out at each size rather than scaled. See .
When the title appears on both screensDetails
If the card also shows the place name, and the place shows it bigger, that 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 a label that reads only on the card, 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 className="sheet-backdrop" onClick={() => setMenuOpen(false)}> <div role="dialog" aria-modal="true">…</div> </div> </Layer>)}The demo above runs inside this page, so its app Router sets ownsLayers to keep the menu inside the phone instead of over the site. An app whose Router is the outermost one does not need it.
Check before shipping
- Go forward and back inside the panel. The header and the browser history do not change.
- Open a place 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, createPartTransition, useNavigate, useParams} from "@flemo/react";import { Avatar, BackButton, PLACES, Photo } from "./ui";// cupertino's easing, so the header moves in step with the screen on a swipe.const EASE = [0.32, 0.72, 0, 1] as const;const barTitle = createPartTransition({ name: "bar-title", initial: { opacity: 0, x: 56 }, idle: { value: { opacity: 1, x: 0 }, options: { ease: EASE } }, enter: { value: { opacity: 0, x: -56 }, options: { ease: EASE } }, exit: { value: { opacity: 1, x: 0 }, options: { ease: EASE } }, dismiss: { value: { opacity: 0, x: 56 }, options: { ease: EASE } }});const barAction = createPartTransition({ name: "bar-action", initial: { opacity: 0, x: 10 }, idle: { value: { opacity: 1, x: 0 }, options: { ease: EASE } }, enter: { value: { opacity: 0, x: -10 }, options: { ease: EASE } }, exit: { value: { opacity: 1, x: 0 }, options: { ease: EASE } }, dismiss: { value: { opacity: 0, x: 10 }, options: { ease: EASE } }});function TopBar({ title, action }: { title: string; action: ReactNode }) { return ( <header className="top-bar"> <Part name="bar-action">{action}</Part> <Part name="bar-title"> <h1>{title}</h1> </Part> </header> );}export function App() { return ( <Router name="trip" defaultTransitionName="cupertino" partTransitions={[barTitle, barAction]}> <Route path="/trip" element={<Home />} /> <Route path="/trip/:id" element={<Place />} /> </Router> );}function Home() { const { push } = useNavigate(); return ( <Screen sharedTopBar={<TopBar title="Places" action={<Avatar />} />} sharedTopBarId="trip-bar"> <button onClick={() => push("/trip/:id", { id: "kyoto" })}> <Morph layoutId="trip-art-kyoto" className="card-photo"> <Photo place="kyoto" /> </Morph> Kyoto </button> <Router name="panel" history="memory" initPath="/panel" className="panel"> <Route path="/panel" element={<Saved />} /> <Route path="/panel/filters" element={<Filters />} /> </Router> </Screen> );}function Saved() { const panel = useNavigate(); const app = useNavigate({ router: "trip" }); const [menuOpen, setMenuOpen] = useState(false); return ( <Screen> <button onClick={() => panel.push("/panel/filters")}>Filter</button> <button onClick={() => setMenuOpen(true)}>More</button> {["lisbon", "oaxaca", "reykjavik"].map((id) => ( <button key={id} onClick={() => app.push("/trip/:id", { id })}> {PLACES[id].name} </button> ))} {menuOpen && ( <Layer> <div className="sheet-backdrop" onClick={() => setMenuOpen(false)}> <div role="dialog" aria-modal="true" className="sheet"> Saved places </div> </div> </Layer> )} </Screen> );}function Filters() { const { pop } = useNavigate(); return ( <Screen> <button onClick={() => pop()}>Back</button> Filters </Screen> );}function Place() { const { id } = useParams<"/trip/:id">(); const { pop } = useNavigate(); return ( <Screen sharedTopBar={ <TopBar title={PLACES[id].name} action={<BackButton onClick={() => pop()} />} /> } sharedTopBarId="trip-bar" > <Morph layoutId={`trip-art-${id}`} className="hero-photo"> <Photo place={id} /> </Morph> <p>{PLACES[id].description}</p> </Screen> );}