Command Palette

Search for a command to run...

Hectal
Shots in this scene
SHOT 15.7Advanced ~23 min · shot 7 of 7

Build: The React Bike Configurator

In one line

The launch page gets a React island: colour swatches with a ring that glides, accessories that enter and leave, a price that counts, and one MotionConfig that follows the site's motion setting.

0/7 · 0%

Before you start

You will learn

  • Mount a React component into the plain launch page
  • Wire the motion setting into MotionConfig

You write code in

In your lumen-motion project (npm run dev) and in the editor on this page, now with React (src/react/)

Files you touch

  • src/react/Configurator.jsx
  • src/react/main.jsx
  • index.html
  • src/styles/components.css
  • package.json
  • vite.config.js

Think of it like this

A shop adds a tailor's corner to an existing store. The store keeps its shelves and till; the corner has its own tools and rules. The configurator is that corner: React lives only in #configurator-root, and the rest of the launch page stays plain HTML, CSS and JavaScript.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

island
A small part of a page that React controls, while the rest stays plain HTML.
mount
To attach a React component to an element: createRoot(el).render(...).
useSyncExternalStore
A React hook that re-renders when something outside React (here, the motion setting) changes.
live region
An element with role="status": screen readers announce changes to its text.
StrictMode
A React development mode that mounts every component twice, to reveal missing cleanup.

Step by step

01Add the packages

In lumen-motion run the install below. @vitejs/plugin-react is a dev dependency that compiles JSX.

vite.config.js imports the plugin and adds plugins: [react()].

terminal
$ npm install react@19.2.8 react-dom@19.2.8 motion@14.3.0
npm install -D @vitejs/plugin-react@6.1.2
── expected output ──
added 4 packages (the counts depend on your npm cache)
Pinned versions; the plugin and Vite versions were taken from npm on the day of writing.

02Add the component and the mount

Create src/react/Configurator.jsx and src/react/main.jsx from the files below.

03Add the section and the script

In index.html add the section#configurator before </main> and the <script type="module" src="/src/react/main.jsx"> after the existing script. Append the Phase 15 block to components.css.

04Check reduced motion

Run npm run dev. Pick Ink and Lock. Then switch the footer toggle to Reduced: the ring and the price now jump, and the accessory only fades.

Try it yourself

  1. 1

    Pick a colour and read the price

    In the example press the Ink swatch and then Lock. Measured: after Ink and Lock: €2,569, which is 2,490 plus 40 for Ink plus 39 for Lock.

  2. 2

    Run it as a reduced-motion user

    Switch the timeline's Reduced motion on. The ring no longer glides and the price shows its total at once.

  3. 3

    Delete the cleanup

    Delete return () => controls.stop(); and click Helmet, Rack and Bell quickly. Measured: the price counts exactly as before (€2,682 after about 550 ms, rising all the way). Starting a new animate() on a motion value stops the old one, so the cleanup only matters when the component unmounts mid-count. It is still good practice.

Code & diagrams

The configurator as it stands

The project's `Configurator.jsx` with Phase 7's setting replaced by small stand-ins (the preview cannot import project files). The page picks Ink and Lock for you; the price counts.

Press Run to see the page.
00:00.000
animations
0.00s0.10s0.20s0.30s0.40s0.50s0.60s0.70s0.80s0.90s

Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.

frames

Console

Nothing logged yet.

Expected console output

at load: €2,490, fitted: 0
after Ink and Lock: €2,569, fitted: 1, Total €2,569

Your project files

Your lumen-motion project after this topic. Click a file to see it in full.

src/react/Configurator.jsxjsx

The whole file after this topic. If yours differs, you can copy this one and carry on.

// Configurator.jsx: the Lumen Volt configurator (Phase 15). One colour, any accessories, a price that counts to the new total.
import { useEffect, useState, useSyncExternalStore } from "react";
import { AnimatePresence, MotionConfig, animate, motion, useMotionValue, useTransform } from "motion/react";
import { motionAllowed, motionChoice, onMotionChange } from "../js/motion/prefers-reduced.js";

const BASE = 2490;
const COLOURS = [
  { id: "ember", name: "Ember", hex: "#ff6b2c", extra: 0 },
  { id: "harbour", name: "Harbour", hex: "#2a9df4", extra: 0 },
  { id: "ink", name: "Ink", hex: "#1b1a22", extra: 40 },
];
const EXTRAS = [
  { id: "lock", name: "Lock", price: 39 },
  { id: "lights", name: "Lights", price: 29 },
  { id: "rack", name: "Rack", price: 45 },
  { id: "bell", name: "Bell", price: 9 },
  { id: "basket", name: "Basket", price: 35 },
  { id: "helmet", name: "Helmet", price: 59 },
];
const euro = (n) => "€" + Math.round(n).toLocaleString("en-US");

// The visitor's setting is the authority (Phase 7): "reduced" and "full" win over the device, "system" follows it.
function useMotionMode() {
  useSyncExternalStore(onMotionChange, motionAllowed); // re-render when the setting or the device changes
  const choice = motionChoice();
  return choice === "reduced" ? "always" : choice === "full" ? "never" : "user";
}

function Price({ total }) {
  const shown = useMotionValue(total); // the number on screen: changes every frame without re-rendering React
  const text = useTransform(shown, euro);
  useEffect(() => {
    if (!motionAllowed()) { shown.set(total); return; } // calm: the new price just appears
    const controls = animate(shown, total, { duration: 0.5, ease: "easeOut" });
    return () => controls.stop(); // stops the count if the component goes away mid-count
  }, [total, shown]);
  return (
    <>
      <motion.output className="cfg-price" aria-hidden="true">{text}</motion.output>
      <span className="cfg-sr" role="status">Total {euro(total)}</span>
    </>
  );
}

export default function Configurator() {
  const mode = useMotionMode();
  const [colour, setColour] = useState(COLOURS[0]);
  const [fitted, setFitted] = useState([]);
  const total = BASE + colour.extra + fitted.reduce((sum, id) => sum + EXTRAS.find((x) => x.id === id).price, 0);
  const toggle = (id) => setFitted((list) => (list.includes(id) ? list.filter((x) => x !== id) : [...list, id]));

  return (
    <MotionConfig reducedMotion={mode}>
      <div className="cfg">
        <motion.svg className="cfg-bike" viewBox="0 0 220 110" role="img" aria-label={"Lumen Volt in " + colour.name} fill="none" strokeWidth="6" strokeLinecap="round" strokeLinejoin="round">
          <circle cx="45" cy="78" r="24" stroke="currentColor" />
          <circle cx="175" cy="78" r="24" stroke="currentColor" />
          <motion.path d="M45 78 L85 38 H140 L175 78 M85 38 L110 78 H45 M110 78 L140 38" animate={{ stroke: colour.hex }} initial={false} />
        </motion.svg>

        <div className="cfg-panel">
          <div role="group" aria-label="Colour" className="cfg-swatches">
            {COLOURS.map((c) => (
              <button key={c.id} type="button" className="cfg-swatch" style={{ background: c.hex }} aria-pressed={colour.id === c.id} aria-label={c.name + (c.extra ? ", plus " + euro(c.extra) : "")} onClick={() => setColour(c)}>
                {colour.id === c.id && <motion.span layoutId="cfg-ring" className="cfg-ring" transition={{ type: "spring", stiffness: 500, damping: 35 }} />}
              </button>
            ))}
          </div>

          <div role="group" aria-label="Accessories" className="cfg-extras">
            {EXTRAS.map((x) => (
              <button key={x.id} type="button" className="cfg-chip" aria-pressed={fitted.includes(x.id)} onClick={() => toggle(x.id)}>{x.name} <small>{euro(x.price)}</small></button>
            ))}
          </div>

          <ul className="cfg-fitted" aria-label="Fitted accessories">
            <AnimatePresence mode="popLayout" initial={false}>
              {fitted.map((id) => (
                <motion.li key={id} layout initial={{ opacity: 0, scale: 0.8 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.8 }}>
                  {EXTRAS.find((x) => x.id === id).name}
                </motion.li>
              ))}
            </AnimatePresence>
          </ul>
          {fitted.length === 0 && <p className="cfg-empty">No accessories yet. Pick any above.</p>}

          <p className="cfg-total">Your Volt <Price total={total} /></p>
        </div>
      </div>
    </MotionConfig>
  );
}

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Drop the reduced-motion guard on the price

In Price, delete the line if (!motionAllowed()) { shown.set(total); return; }.

terminal
$ Switch the timeline's Reduced motion on, then click Helmet, Rack and Bell in quick succession and read the price
── what you'll see ──
Measured: the price still counts, €2,556 at 64 ms, €2,628 at 244 ms and €2,682 only at about 550 ms. With the guard it reads €2,682 at once.

Myth vs fact

Myth

Adding React means rewriting the page.

Fact

React mounts into one element. The rest of the page and its scripts are untouched.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    The price uses a motion value plus useTransform for text, so the count never re-renders the component: React renders once per choice, and Motion writes the text each frame.

  • ▸

    StrictMode mounts the component twice in development, so the animate() effect starts, is stopped by its cleanup, and starts again. Without the cleanup nothing visible changes (measured), because the second animate() stops the first on the same motion value.

Remember this

  1. 1

    The problem this solves: Tomás wants visitors to build their Volt and see the price. The state (colour, accessories, total) suits React, and the story's motion (a ring that travels, parts that leave, a counting price) is exactly what Motion does well. Ravi's rule stays: with reduced motion nothing slides, but every change is still visible.

  2. 2

    What it adds and how it plugs in. src/react/Configurator.jsx holds the component and src/react/main.jsx mounts it into section#configurator (#configurator-root, added to index.html). It imports motionAllowed, motionChoice and onMotionChange from Phase 7's prefers-reduced.js. package.json gains react, react-dom and motion, and vite.config.js gains the React plugin. The colour ring is one motion.span with layoutId, so it glides between swatches. Accessories sit in an AnimatePresence with mode="popLayout" and initial={false}. The price is a motion value that animate() counts to the new total (a new choice mid-count starts from the number on screen, because starting an animation on a motion value stops the one before; controls.stop() in the cleanup stops a count when the component unmounts).

  3. 3

    Reduced motion, both sources. Phase 7's setting can be "system", "reduced" or "full". The component maps it to MotionConfig reducedMotion (user, always or never) and re-renders through useSyncExternalStore when it changes. With motion off the ring jumps, accessories fade only, and the price changes at once (measured: after the example's own Ink and Lock, three more clicks on Helmet, Rack and Bell took the total to €2,682; 60 ms after the clicks the price already read €2,682 in a reduced-motion browser, and about €2,559 on its way in a normal one, which settled after roughly 550 ms). A visually hidden role="status" line announces the total, because the animated number is aria-hidden (it would be read out on every frame).

  4. 4

    What the launch site can do now. Visitors pick a colour and accessories and see the total. Measured in a production build: vite build gave one 342.5 KB (110.5 KB gzip) script, including React and motion. No console errors or warnings at 1000 or 390 px, no horizontal scroll, the leaving accessory still in the page at 20 ms and gone by 700 ms. Still missing: the headlight mascot (Phase 16), the launch confetti (Phase 17), motion tokens and tests (Phase 18) and the launch audit (Phase 19). Using LazyMotion here would save about 14 KB gzip, but the ring needs layout, so domMax would save nothing.

  5. 5

    Edge cases the build covers. No accessories shows a plain empty message. Choosing the same colour again changes nothing. Clicking Lock twice quickly removes it, and a third click while it leaves puts it back (Topic 15.3). The exit is popLayout, so the neighbours close up at once. The price animation never re-renders React.

Explain it without notes

01

Why does the configurator read Phase 7's setting instead of only using reducedMotion="user"?

02

Why is the animated price aria-hidden with a separate status line?

Practice

01

Add a fourth colour, "Moss" (+€40), to the configurator.

02

Make the Helmet chip add 59 and also fit a "Helmet" label only once even if clicked twice quickly.

Done when you can

  • You can mount a React component into a plain page

  • You can map the site's motion setting onto MotionConfig

  • You can say what the configurator adds and what is still missing