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.

2 min read
Nested memory RouterLOCAL

Workspace

Inbox

ROOT
LiveOpen Local filters: only the panel changes. Go back, then tap the Morning brief card: it grows into the message while the header title changes. Swipe from the left edge to go back.

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.

tsx
<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 .

tsx
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.

tsx
<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.

tsx
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.

tsx
// 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 .

tsx
{menuOpen && (  <Layer>    <div role="dialog" aria-modal="true">…</div>  </Layer>)}

Check before shipping

  1. Go forward and back inside the panel. The header and the browser history do not change.
  2. Open the message from the panel. The whole app moves, not just the panel.
  3. Open the menu. It covers the header, and no screen changes.
  4. Swipe back slowly, let go early once, then swipe all the way. The title, the button, the screen and the card move together.
  5. Try a long title and no left button, so the header layout is tested rather than assumed.
  6. Judge the motion on a real device with developer tools closed. Then add @flemo/devtools and check that window.flemo.report() lists no anomalies.

Full code

AppRouter.tsx
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>  );}
Edit on GitHub