Build a Motion Command Palette
Build a typed React command palette with Motion and accessible transitions.
Introduction
30 Second Summary
A command palette feels effortless when each result stays connected to your next choice. Abrupt jumps make the same interface harder to follow.
In this project, you will direct Claude to help turn a six-state Figma map into a command palette built with React and TypeScript. Motion for React will connect the palette's opening, filtering, selection, feedback, and reduced-motion states.
What You'll Build
Your finished interface opens into a polished palette where searches reflow smoothly while a selection indicator travels between results.
By the end of this project, you'll have:
- A six-state design map in Figma that makes closed, open, filtered, empty, selected, and reduced-motion behavior easy to explain.
- A typed command palette you can open, search, navigate with a keyboard, control with a pointer, and use to run simulated commands.
- A purposeful motion system that animates panel presence, result reflow, selection continuity, gesture feedback, and an opacity-based reduced-motion treatment.
- Secret Mission: Create a professional motion critique board that compares full-motion and reduced-motion treatments while defending three accepted, revised, or rejected Claude recommendations.
Are there any prerequisites?
You do not need prior React or animation experience. Step 1 checks whether Node.js and npm are ready before you build anything.
Before We Start
This is your moment to define who the keyboard-friendly command palette serves and what its motion must communicate before any hands-on work begins.
Set Up the Pinned React and Motion Workspace
Your command palette needs a reproducible workspace before its interaction states can become code. Hidden version differences can make valid suggestions from Claude fail on your machine.
This step sets up Node.js, npm, Vite, React, TypeScript, and Motion for React with pinned package versions. You will finish with a local browser feedback loop for every design change that follows.
In this step, get ready to:
- Verify a Node.js release that meets Vite's requirements.
- Create the delightful-command-palette workspace with pinned dependencies.
- Run the initial React interface in your browser.
Verify Node.js and npm
Vite requires Node.js 20.19+ or 22.12+. The Node.js Windows installer includes npm, which manages the packages in your project.
- Press the Windows key to open Windows search.
- Type PowerShell into the search field.
- Press Enter to open Windows PowerShell.
- Check the installed Node.js version and npm version by running:
node --version
npm --version
What do these commands check?
- The first command prints the Node.js release available to Vite.
- The second command confirms that npm is available for scaffolding and dependency installation.
✔️ I see a compatible version
Your runtime is ready when Node.js reports 20.19 or higher within the 20 release line. Node.js 22.12 or any newer supported release also meets Vite's requirement.
- Keep this PowerShell window open for the project scaffold.
ⓧ I see an older version
Your installed release cannot run this version of Vite reliably. Updating Node.js also refreshes the npm installation bundled with it.
- Visit the official Node.js download page.
- Download the Windows installer for the current LTS release.
- Run the downloaded Windows installer.
- Accept the installer's default components.
- Close your existing PowerShell window after installation.
- Open a fresh PowerShell window through Windows search.
- Confirm the updated versions by running:
node --version
npm --version
What should this confirm?
The fresh PowerShell session reloads your system path. You should now see a compatible Node.js release followed by an npm version.
Still seeing the older version?
- Restart Windows so PowerShell reloads the updated Node.js path.
- Confirm that the installer completed before opening PowerShell again.
- Ask for help with the remaining path conflict: Help me find why PowerShell still reports my older Node.js version after I ran the official Windows installer.
ⓧ Command not found
Node.js is missing from your Windows path. The official Windows installer adds Node.js plus npm in one setup.
- Visit the official Node.js download page.
- Download the Windows installer for the current LTS release.
- Run the downloaded Windows installer.
- Accept the installer's default components.
- Close PowerShell after the installer finishes.
- Open a fresh PowerShell window through Windows search.
- Verify both tools by running:
node --version
npm --version
What should this confirm?
The installer places Node.js plus npm on your Windows path. Both commands should now print version numbers.
Commands still unavailable?
- Confirm that the Windows installer finished successfully.
- Restart Windows before testing the commands again.
- Ask for help with the installation path: Help me diagnose why Node.js and npm are unavailable in PowerShell after installation.
Scaffold and pin the workspace
The scaffold supplies the browser server and TypeScript configuration. Replacing its manifest with pinned versions keeps your local code aligned with the APIs used throughout this project.
- Return to the PowerShell window from the version check.
- Move to your Desktop by running this command:
cd ~/Desktop
What does this command do?
This places the new workspace on your Desktop. You will be able to find the project without searching through your user folders.
- Start the Vite project creator by running:
npm create vite@latest
What does this command do?
npm downloads the current Vite project creator and starts its interactive scaffold. It may ask for permission to install the creator package before showing the project choices.
- Accept the temporary package installation if npm asks for confirmation.
- Enter delightful-command-palette as the project name.
- Select React as the framework.
- Select the react-ts preset for React with TypeScript.
The scaffold creates the delightful-command-palette folder on your Desktop. Its generated files give you a runnable baseline before any guide code is added.
Scaffold did not finish?
- Confirm that both version commands worked in this PowerShell window.
- Check your internet connection because npm must download the project creator.
- Ask for help with the scaffold output: Help me diagnose why npm create vite@latest did not create my React TypeScript project.
- Move into the new workspace by running:
cd delightful-command-palette
What does this command do?
PowerShell now points at the folder containing package.json and the generated application files. Package commands run against this workspace.
- Press the Windows key to open Windows search.
- Type Visual Studio Code into the search field.
- Press Enter to open Visual Studio Code.
- Click File in the top menu.
- Click Open Folder.
- Select the delightful-command-palette folder on your Desktop.
- Click Select Folder to open the workspace.
- Open package.json from the Explorer sidebar.
- Replace the generated manifest with the pinned project manifest below:
{
"name": "delightful-command-palette",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"motion": "14.6.0",
"react": "19.3.1",
"react-dom": "19.3.1"
},
"devDependencies": {
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@vitejs/plugin-react": "6.1.2",
"typescript": "7.0.2",
"vite": "8.3.5"
}
}
What does this manifest control?
- The scripts define the development server and production build commands.
- The dependencies pin React 19.3.1, React DOM 19.3.1, and Motion 14.6.0.
- The development dependencies pin Vite 8.3.5, TypeScript 7.0.2, and the React plugin 6.1.2.
- Save package.json.
- Confirm the pinned values remain visible after saving the file.
- Open a VS Code terminal from the Terminal menu.
- Install the pinned dependencies by running:
npm install
What does this command do?
npm reads package.json and installs the exact package versions into the workspace. It also records the resolved dependency tree for repeatable installs.
The first install can take a few minutes while npm downloads the packages. A quiet terminal during part of that time does not mean the install has stopped.
- Wait for PowerShell to return to its prompt.
- Confirm that the node_modules folder appears in the VS Code Explorer.
Dependency installation failed?
- Confirm that the terminal path ends with delightful-command-palette.
- Check that package.json contains valid JSON with matching braces.
- Ask for help with the npm output: Help me diagnose my npm install failure in this pinned Vite React TypeScript workspace.
✔️ The dependencies installed
The pinned workspace is ready. Keep package.json saved before moving to the guide files.
ⓧ I'd like to double check the manifest
Compare your complete package.json with this reference:
{
"name": "delightful-command-palette",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"motion": "14.6.0",
"react": "19.3.1",
"react-dom": "19.3.1"
},
"devDependencies": {
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@vitejs/plugin-react": "6.1.2",
"typescript": "7.0.2",
"vite": "8.3.5"
}
}
Before you start the server, what do you expect the untouched React scaffold to show in the browser?
- Start the development server by running:
npm run dev
What does this command do?
The dev script starts Vite's local development server. Vite prints a local URL and watches your files for saved changes.
- Hold Ctrl while clicking the local URL printed in PowerShell.
- Confirm that the Vite React starter renders in your browser.
Your browser-server feedback loop is working. Every saved interface change can now appear without rebuilding the workspace from scratch.
Starter page did not load?
- Keep the terminal running while the browser uses the local URL.
- Use the exact local URL printed by your own Vite server.
- Ask for help with the server output: Help me diagnose why my Vite development server prints a local URL but the React starter does not load.
Replace the starter application and verify it
The generated starter proves the toolchain works. The guide files now replace its demo branding with the first visual shell for your creative-workspace command palette.
- Open index.html from the VS Code Explorer.
- Replace its contents with the project entry document below:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="A motion-driven command palette built with React and TypeScript."
/>
<title>Delightful Command Palette</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
What does this document do?
- The root element gives React one browser element to control.
- The module script loads /src/main.tsx as the application entry point.
- The metadata gives the browser a responsive viewport and a project-specific description.
- Save index.html.
- Check the browser tab for the title Delightful Command Palette.
Browser title did not update?
- Confirm that you edited index.html inside delightful-command-palette.
- Refresh the page opened from Vite's local URL.
- Ask for help with the HTML entry file: Help me find why my Vite browser tab does not use the title from index.html.
- Open src/main.tsx from the VS Code Explorer.
- Replace its contents with the React entry point below:
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.tsx'
import './index.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
What does this entry point do?
- The imports load React's strict checks and the browser root renderer.
- The App import identifies the top-level interface component.
- The stylesheet import applies src/index.css to the rendered application.
- Save src/main.tsx.
- Confirm that the running starter still renders without a TypeScript error overlay.
Seeing an import error?
- Check that App.tsx and index.css remain inside the src folder.
- Match the capitalization in ./App.tsx exactly.
- Ask for help with the entry point: Help me diagnose the import error in src/main.tsx.
- Open src/App.tsx from the VS Code Explorer.
- Replace its contents with the initial command-palette shell below:
function App() {
return (
<main className="workspace">
<section className="workspace-copy" aria-labelledby="page-title">
<p className="eyebrow">Design engineering playground</p>
<h1 id="page-title">Make state changes feel connected.</h1>
<header className="palette-header">
<div>
<p className="palette-kicker">Quick actions</p>
<h2>Where do you want to go?</h2>
</div>
</header>
<div className="search-wrap">
<span aria-hidden="true">/</span>
<input
placeholder="Search commands"
aria-label="Search commands"
aria-controls="command-results"
aria-expanded="true"
/>
</div>
</section>
</main>
)
}
export default App
What does this component establish?
- The workspace element creates the full-page creative environment.
- The palette header gives the interface a clear purpose before command data is introduced.
- The search input establishes the central control that later filtering behavior uses.
- Save src/App.tsx.
- Confirm that the browser now shows the command-palette heading and search field.
Command-palette shell did not render?
- Check the browser for a Vite error overlay naming the affected line.
- Confirm that every JSX element has a matching closing tag.
- Ask for help with the component structure: Help me debug the initial src/App.tsx command-palette shell.
- Delete src/App.css from the VS Code Explorer.
- Confirm the deletion when VS Code asks.
The src/App.css file disappears from the Explorer. The new component remains visible because it imports no styles from that file.
- Delete src/assets from the VS Code Explorer.
- Confirm the deletion when VS Code asks.
The generated image assets disappear from the Explorer. Your initial component uses no starter logos.
- Delete public from the VS Code Explorer.
- Confirm the deletion when VS Code asks.
The generated public assets disappear from the Explorer. The workspace now contains only the files needed for this project.
- Open src/index.css from the VS Code Explorer.
- Clear the generated stylesheet.
- Add the global visual foundation by pasting this first CSS section:
:root {
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
color: #f7f3ff;
background: #0d0914;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* {
box-sizing: border-box;
}
body {
min-width: 320px;
min-height: 100vh;
margin: 0;
}
button,
input {
font: inherit;
}
button {
color: inherit;
}
What does this CSS establish?
- The root rules establish the dark palette and the interface font stack.
- Universal border sizing keeps future component dimensions predictable.
- The body rules remove the browser margin and reserve the full viewport.
- Save src/index.css.
- Confirm that the browser background becomes dark and the interface text becomes light.
Global styles did not appear?
- Confirm that src/main.tsx imports ./index.css.
- Check that the CSS braces close after each rule.
- Ask for help with the stylesheet import: Help me find why the global rules in src/index.css are not applied.
- Add the workspace layout below the existing CSS:
.workspace {
position: relative;
display: grid;
min-height: 100vh;
place-items: center;
overflow: hidden;
padding: 32px;
background:
radial-gradient(circle at 20% 15%, rgba(136, 83, 255, 0.16), transparent 34%),
radial-gradient(circle at 85% 75%, rgba(63, 214, 173, 0.12), transparent 32%),
#0d0914;
}
.ambient {
position: absolute;
width: 320px;
height: 320px;
border-radius: 999px;
filter: blur(80px);
opacity: 0.24;
pointer-events: none;
}
What does the workspace layout do?
- The grid centers the command-palette shell in the viewport.
- The layered gradients establish depth behind the interface.
- The ambient rule prepares blurred decorative shapes that never capture pointer input.
- Save src/index.css.
- Confirm that the command-palette shell moves to the center of the viewport.
Layout is not centered?
- Check that className="workspace" in src/App.tsx matches the CSS selector.
- Confirm that min-height: 100vh remains inside the workspace rule.
- Ask for help with the layout rule: Help me debug why my workspace CSS is not centering the React interface.
- Add the workspace content styles below the existing CSS:
.ambient-one {
top: -140px;
right: 8%;
background: #915cff;
}
.ambient-two {
bottom: -170px;
left: 4%;
background: #3fd6ad;
}
.workspace-copy {
z-index: 1;
width: min(720px, 100%);
text-align: center;
}
.eyebrow,
.palette-kicker {
margin: 0 0 10px;
color: #aa8dff;
font-size: 0.74rem;
font-weight: 800;
letter-spacing: 0.15em;
text-transform: uppercase;
}
What does this section style?
- The workspace content stays above the decorative background with a readable maximum width.
- The eyebrow and palette kicker share a compact visual label style.
- The ambient position rules keep the colored glows near opposite viewport corners.
- Save src/index.css.
- Confirm that the small labels become purple uppercase text above their headings.
Labels still look unstyled?
- Match eyebrow and palette-kicker between the component and stylesheet.
- Check for a missing comma between the two CSS selectors.
- Ask for help with the shared selector: Help me debug the eyebrow and palette-kicker CSS selectors.
- Add the heading typography below the existing CSS:
h1,
h2,
p {
margin-top: 0;
}
h1 {
max-width: 680px;
margin-right: auto;
margin-bottom: 18px;
margin-left: auto;
font-size: clamp(2.7rem, 8vw, 5.8rem);
line-height: 0.94;
letter-spacing: -0.065em;
}
.lede {
max-width: 610px;
margin-right: auto;
margin-bottom: 28px;
margin-left: auto;
color: #b8afc8;
font-size: 1.04rem;
line-height: 1.7;
}
What does this typography do?
- The fluid heading size adapts between laptop and narrow browser widths.
- The tight line height gives the main message a compact display treatment.
- The lede rule is ready for supporting copy as the interface grows.
- Save src/index.css.
- Confirm that the main heading becomes large and tightly spaced.
Heading size did not change?
- Confirm that the page heading uses an h1 element.
- Check the clamp() value for matching parentheses.
- Ask for help with the typography rule: Help me find why the h1 styles in src/index.css are not visible.
- Add the palette header and search container styles below the existing CSS:
.palette-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
padding: 24px 24px 16px;
}
.palette-header h2 {
margin-bottom: 0;
font-size: clamp(1.25rem, 4vw, 1.75rem);
letter-spacing: -0.035em;
}
.search-wrap {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 10px;
margin: 0 16px 12px;
padding: 12px 14px;
border: 1px solid rgba(163, 127, 255, 0.34);
border-radius: 14px;
color: #a98cff;
background: rgba(8, 5, 14, 0.56);
box-shadow: 0 0 0 4px rgba(145, 92, 255, 0.08);
}
What does the palette framing do?
- The header separates the palette question from the workspace message.
- The search grid reserves space for an icon and the growing input.
- The border plus outer glow makes the search control the visual focus.
- Save src/index.css.
- Confirm that the search field gains a rounded dark container with a purple focus ring.
Search container still looks plain?
- Confirm that the wrapper uses className="search-wrap".
- Check that the search rule closes after the box-shadow declaration.
- Ask for help with the search wrapper: Help me debug why the search-wrap CSS is not styling my command-palette input.
- Add the input and future result-state styles below the existing CSS:
.search-wrap input {
width: 100%;
border: 0;
outline: 0;
color: #f9f5ff;
background: transparent;
}
.command-list {
display: grid;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
}
.empty-state {
display: grid;
min-height: 270px;
place-content: center;
gap: 7px;
color: #9b8ea8;
text-align: center;
}
.empty-state strong {
color: #efe8f7;
}
What do these state styles prepare?
- The input inherits the palette surface instead of drawing a second browser border.
- The command list uses consistent spacing without default list markers.
- The empty state centers its message when a query has no matching commands.
- Save src/index.css.
- Confirm that the visible input blends into its dark search container.
Input still has a browser border?
- Confirm that the input remains inside the search-wrap element.
- Check that the selector includes a space between .search-wrap and input.
- Ask for help with the nested selector: Help me find why the search-wrap input rule is not removing the browser border.
✔️ The guide interface is visible
Great. Save every open file before the final server check.
ⓧ I'd like to double check the guide files
Compare each edited file with these complete references.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="A motion-driven command palette built with React and TypeScript."
/>
<title>Delightful Command Palette</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.tsx'
import './index.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
function App() {
return (
<main className="workspace">
<section className="workspace-copy" aria-labelledby="page-title">
<p className="eyebrow">Design engineering playground</p>
<h1 id="page-title">Make state changes feel connected.</h1>
<header className="palette-header">
<div>
<p className="palette-kicker">Quick actions</p>
<h2>Where do you want to go?</h2>
</div>
</header>
<div className="search-wrap">
<span aria-hidden="true">/</span>
<input
placeholder="Search commands"
aria-label="Search commands"
aria-controls="command-results"
aria-expanded="true"
/>
</div>
</section>
</main>
)
}
export default App
:root {
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
color: #f7f3ff;
background: #0d0914;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* {
box-sizing: border-box;
}
body {
min-width: 320px;
min-height: 100vh;
margin: 0;
}
button,
input {
font: inherit;
}
button {
color: inherit;
}
.workspace {
position: relative;
display: grid;
min-height: 100vh;
place-items: center;
overflow: hidden;
padding: 32px;
background:
radial-gradient(circle at 20% 15%, rgba(136, 83, 255, 0.16), transparent 34%),
radial-gradient(circle at 85% 75%, rgba(63, 214, 173, 0.12), transparent 32%),
#0d0914;
}
.ambient {
position: absolute;
width: 320px;
height: 320px;
border-radius: 999px;
filter: blur(80px);
opacity: 0.24;
pointer-events: none;
}
.ambient-one {
top: -140px;
right: 8%;
background: #915cff;
}
.ambient-two {
bottom: -170px;
left: 4%;
background: #3fd6ad;
}
.workspace-copy {
z-index: 1;
width: min(720px, 100%);
text-align: center;
}
.eyebrow,
.palette-kicker {
margin: 0 0 10px;
color: #aa8dff;
font-size: 0.74rem;
font-weight: 800;
letter-spacing: 0.15em;
text-transform: uppercase;
}
h1,
h2,
p {
margin-top: 0;
}
h1 {
max-width: 680px;
margin-right: auto;
margin-bottom: 18px;
margin-left: auto;
font-size: clamp(2.7rem, 8vw, 5.8rem);
line-height: 0.94;
letter-spacing: -0.065em;
}
.lede {
max-width: 610px;
margin-right: auto;
margin-bottom: 28px;
margin-left: auto;
color: #b8afc8;
font-size: 1.04rem;
line-height: 1.7;
}
.palette-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
padding: 24px 24px 16px;
}
.palette-header h2 {
margin-bottom: 0;
font-size: clamp(1.25rem, 4vw, 1.75rem);
letter-spacing: -0.035em;
}
.search-wrap {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 10px;
margin: 0 16px 12px;
padding: 12px 14px;
border: 1px solid rgba(163, 127, 255, 0.34);
border-radius: 14px;
color: #a98cff;
background: rgba(8, 5, 14, 0.56);
box-shadow: 0 0 0 4px rgba(145, 92, 255, 0.08);
}
.search-wrap input {
width: 100%;
border: 0;
outline: 0;
color: #f9f5ff;
background: transparent;
}
.command-list {
display: grid;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
}
.empty-state {
display: grid;
min-height: 270px;
place-content: center;
gap: 7px;
color: #9b8ea8;
text-align: center;
}
.empty-state strong {
color: #efe8f7;
}
Before you restart the server, which parts of the initial command-palette shell do you expect to see?
- Return to the VS Code terminal running Vite.
- Press Ctrl+C to stop the current development server.
- Start the final workspace state by running:
npm run dev
What does this final check prove?
A clean server start confirms that the pinned manifest and guide files work together. Vite should print a local URL without showing a TypeScript compilation failure.
- Hold Ctrl while clicking the local URL printed in PowerShell.
- Confirm that the browser shows the dark creative workspace with the command-palette heading.
- Confirm that the browser shows the rounded search control beneath the palette question.
- Leave the Vite development server running for the next step.
That setup work is complete. Your pinned React workspace is running and ready to turn a deliberate state model into an interface.
Final interface does not load?
- Read the first file path named in the Vite terminal error.
- Compare that file with the complete reference tab above.
- Confirm that src/App.css is no longer imported anywhere.
- Ask for help with the complete startup path: Help me diagnose why my pinned command-palette workspace fails during npm run dev.
Your development loop is ready. Next, you will map the interaction states in Figma before directing Claude to draft the static command structure.
Design the States and Direct the AI
Your pinned React workspace is already running in the browser. You now need a state model that explains what the palette should communicate before implementation choices take over.
A clear state map gives Claude constraints it can follow. Your own prediction keeps architecture decisions in your hands.
The resulting draft gives you a working interaction to inspect before animation enters the system.
In this step, get ready to:
- Map six interaction states in Figma with clear visual priorities.
- Predict the component boundaries before requesting a static React and TypeScript draft.
- Review the draft against your prediction before testing its state changes.
Map the palette states in Figma
A state map shows one meaningful interface condition per frame. Comparing those frames reveals which elements change position and which elements act as spatial anchors.
What belongs in each state?
- Closed: The palette is absent. The workspace remains stable. The open control leads the hierarchy.
- Open: The overlay and palette appear. The workspace stays anchored. The search field receives attention first.
- Filtered: The visible command rows change. The panel and search field stay stable. Relevant matches lead the hierarchy.
- Empty: The result region displays a recovery message. The palette shell stays stable. The missing-result feedback receives attention first.
- Selected: The selection treatment moves between commands. The row content stays stable. The active command leads the hierarchy.
- Reduced motion: Position and hierarchy stay stable. Opacity carries feedback. Spatial travel is absent.
- Press the Windows key to open the search bar.
- Type Figma and press Enter.
- Use the new-file control on the Figma home screen to create a blank design file.
Keeping six frames comparable can feel fiddly. A shared frame size lets each state reveal one deliberate difference.
- Create six compact frames in one horizontal row.
- Label the frames closed, open, filtered, empty, selected, and reduced motion.
You should now see one fixed place for every interaction state. That shared structure makes changes easier to compare.
- Add a Changes annotation to every frame.
- Add a Stays stable annotation to every frame.
Each frame now separates potential movement from the anchors that preserve orientation.
- Add a Notice first annotation to every frame.
- Add this design intent beside the frames: "Build a command palette for creative-workspace visitors whose motion communicates what changed, what is selected, and where interface elements moved."
You now have a design checkpoint that can challenge weak implementation choices.
Predict the structure before prompting Claude
Useful AI assistance begins with a decision record. Your prediction gives you something concrete to compare with the generated draft.
- Return to the Figma file containing your state map.
- Add a note area beside the frames titled AI pairing notes.
You should see a dedicated space for your decisions beside the visual states.
- Write your predicted responsibilities for the workspace shell, palette panel, command list, command row, and empty state.
- Author the exact Command fields as id, label, description, group, and shortcut.
Your note now separates interface responsibilities from the data shape each command needs.
- Record your prediction for which component owns palette visibility, the search query, and the selected result.
Your prompt contract
- Input: Include your component-boundary prediction and the exact command fields.
- State ownership: Require the draft to identify where visibility, query, and selection live.
- Output: Request a static React and TypeScript draft.
- Behavior: Require palette opening, query-based filtering, selected-result rendering, and an empty state.
- Constraints: Use no animation library. Use no effects. Add no dependencies.
- Response order: Require a short explanation of data flow before any code.
- Open Claude through the browser or desktop app tied to your existing access.
- Start a fresh conversation for the static command palette draft.
- Compose one prompt from your AI pairing notes using every requirement in the prompt contract.
- Send the prompt after confirming the data-flow explanation comes before the code request.
Claude's response is now a proposal with explicit boundaries. Your review determines which parts enter the project.
- Read the data-flow explanation before reviewing any generated code.
- Compare the proposed component boundaries with your prediction.
- Inspect every generated block for its inputs, state owner, rendered output, and failure mode.
- Record an accept, revise, or reject decision beside every generated block in your AI pairing notes.
- Reject any abstraction that the explanation does not justify.
Reconcile the static draft in your app
The guide structure is your architectural baseline. The reviewed draft earns adoption where it preserves that structure and satisfies the state map.
Static draft acceptance checklist
- The Command shape uses only the five fields you authored.
- Typed command data drives every rendered result row.
- The open control reveals the palette.
- The query derives a filtered command list during rendering.
- The selected treatment identifies one visible command.
- A query with no matches renders the empty state.
- The draft contains no animation library usage, effects, or new dependencies.
- The existing CSS structure remains responsible for the visual presentation.
- Switch back to the open delightful-command-palette workspace in VS Code.
- Select src/App.tsx in the Explorer sidebar.
- Apply only the reviewed blocks that satisfy the static draft acceptance checklist.
- Save src/App.tsx.
Your static draft is ready for its first honest test.
Before you test the draft, do you expect correct state rendering to make the transitions feel connected?
- Switch to the browser tab using the local URL from the previous step.
- Click Open command palette.
- Enter theme in the search field.
- Replace the query with Create.
You will see the matching command rows update immediately. The rows appear and disappear abruptly.
The selected treatment snaps to the next matching result. This is the intended limitation of the static draft.
That friction is useful because it proves your state logic works. The transition gap is now visible.
- Clear the search field.
- Enter No commands found in the search field.
You should see No commands found with Try a broader phrase. underneath it.
- Switch back to your Figma state map.
- Record the abrupt result filtering in your AI pairing notes.
- Record the snapping selected treatment in your AI pairing notes.
Static draft not rendering?
- Confirm that src/App.tsx is saved.
- Confirm that the development server remains active in the PowerShell terminal from the previous step.
- Remove any generated import that expects an unapproved dependency.
- Compare every field in the generated command data with the five fields in your AI pairing notes.
- Ask for help with the reviewed draft: Help me compare my static React command palette against these requirements without adding effects, animation libraries, or dependencies.
Your state map and static interaction now expose the exact transition gap worth solving. Next, you will take ownership of the typed interaction model and verify every behavior before adding choreography.
Own the Typed Interaction Model
Your static React palette now reflects the six states from your design map. Its abrupt filtering also proves that the interface needs a coherent interaction model before animation can communicate anything useful.
This step gives TypeScript a precise command shape. React then owns visibility, query, selection, feedback, and motion preference as one predictable source of truth.
In this step, get ready to:
- Test the command type contract with an intentional diagnostic.
- Own interaction state in React while deriving filtered results during each render.
- Connect keyboard, pointer, empty-state, and command-feedback behavior.
Prove the type contract
A type contract limits every command to the fields and groups your interface understands. Testing that boundary once makes the protection visible before you rely on it across the interaction.
- In the open VS Code workspace, select src/App.tsx from the file sidebar.
- Find the CommandGroup union near the top of the file.
- Confirm that Command requires id, label, description, group, and shortcut.
Before you create the diagnostic, do you think TypeScript accepts an unquoted letter where shortcut expects a string?
- In the first command, temporarily remove the quotation marks around the N assigned to shortcut.
VS Code marks the unquoted value as a diagnostic. The editor now treats that letter as an identifier instead of the string required by Command.
- Ask Claude to explain the diagnostic without providing replacement code.
- Identify the expected field type in Claude's explanation.
- Restore the valid shortcut with this line:
shortcut: 'N',
What did the type check prove?
The shortcut field accepts a string because the Command type defines that boundary. The same contract protects every object in the typed commands array.
- Save src/App.tsx.
- Confirm that VS Code removes the diagnostic from the restored shortcut.
Centralize state and derive results
The palette needs one owner for each value that can change. Derived state handles values that React can calculate from existing state during the same render.
- In src/App.tsx, find the state and filtering logic at the start of App().
- Replace that block with the complete state model below:
const [isOpen, setIsOpen] = useState(true)
const [query, setQuery] = useState('')
const [selectedId, setSelectedId] = useState(commands[0].id)
const [status, setStatus] = useState('Palette ready')
const [reduceMotion, setReduceMotion] = useState(false)
const normalizedQuery = query.trim().toLowerCase()
const filteredCommands = commands.filter((command) =>
`${command.label} ${command.description} ${command.group}`
.toLowerCase()
.includes(normalizedQuery),
)
const selectedCommand =
filteredCommands.find((command) => command.id === selectedId) ??
filteredCommands[0]
How does the state model work?
- The five useState calls own visibility, search input, selected identity, feedback, and the visitor's motion preference.
- The normalizedQuery value prepares the current query for case-insensitive matching.
- The filteredCommands array is calculated from commands and query during each render.
- The selectedCommand value uses the selected identifier when it remains visible. It falls back to the first filtered result when needed.
- Save src/App.tsx.
- Return to the browser tab running the palette.
- Type theme into the search field.
You should see only Toggle interface theme. The filtered result comes directly from the current query without a second synchronized state value.
Does filtering stop working?
- Check that normalizedQuery is declared before filteredCommands.
- Confirm that the template string includes command.label, command.description, and command.group.
- Use Help me debug the derived filtering in my React command palette.
Connect every interaction
The state model becomes useful when every input changes one clear owner. Small handler functions keep opening, activation, and selection behavior separate from the rendered controls.
- Add the palette-opening handler below selectedCommand in App() by pasting this function:
const openPalette = () => {
setQuery('')
setSelectedId(commands[0].id)
setIsOpen(true)
}
What does opening reset?
The openPalette() function clears the previous search. It also returns selection to the first command before showing the palette.
- Find the Open command palette button.
- Set its click handler to this line:
onClick={openPalette}
Why use a named handler?
The button now delegates the complete reset to openPalette(). That keeps the event binding short while preserving one definition of opening behavior.
- Save src/App.tsx.
- Close the palette with its Esc button.
- Reopen the palette with Open command palette.
The search field should be empty. The first command should be selected again.
- Add the command-activation handler below openPalette() by pasting this function:
const runCommand = (command: Command) => {
setStatus(`Ran: ${command.label}`)
setIsOpen(false)
}
What does command activation own?
The runCommand() function records which command ran. It then closes the palette so the status is visible in the workspace.
- Find the command result button inside the filteredCommands.map() block.
- Set its click handler to this line:
onClick={() => runCommand(command)}
How does the clicked command reach state?
Each rendered button closes over its own command object. Clicking a row passes that exact typed object to runCommand().
- Save src/App.tsx.
- Click the selected command in the browser.
The palette should close. The workspace status should start with Ran: and name the command you selected.
- Add the wraparound selection handler below runCommand() by pasting this function:
const moveSelection = (direction: 1 | -1) => {
if (filteredCommands.length === 0) return
const currentIndex = Math.max(
0,
filteredCommands.findIndex((command) => command.id === selectedCommand?.id),
)
const nextIndex =
(currentIndex + direction + filteredCommands.length) %
filteredCommands.length
setSelectedId(filteredCommands[nextIndex].id)
}
How does wraparound selection work?
- The early return protects the empty state from selection calculations.
- The currentIndex value locates the selected command in the current filtered list.
- The modulo calculation moves past either edge by wrapping to the opposite end.
- The final setter stores only the next command identifier.
- Find the palette section that receives keyboard input.
- Add this keyboard handler to that section:
onKeyDown={(event) => {
if (event.key === 'ArrowDown') {
event.preventDefault()
moveSelection(1)
}
if (event.key === 'ArrowUp') {
event.preventDefault()
moveSelection(-1)
}
if (event.key === 'Enter' && selectedCommand) {
event.preventDefault()
runCommand(selectedCommand)
}
if (event.key === 'Escape') setIsOpen(false)
}}
What does the keyboard handler control?
- The Arrow keys call moveSelection() with a direction.
- The Enter key runs the currently derived selection.
- The Escape key closes the palette from anywhere inside it.
- The prevented default behavior keeps the browser from moving the page while the palette owns those keys.
- Save src/App.tsx.
- Reopen the palette in the browser.
- Press ArrowDown repeatedly.
- Press ArrowUp after the selection wraps.
The selected treatment should move through all six commands. Moving past either end should continue from the opposite end.
- Find the search input inside src/App.tsx.
- Replace its existing change handler with this block:
onChange={(event) => {
const nextQuery = event.currentTarget.value
const nextMatch = commands.find((command) =>
`${command.label} ${command.description} ${command.group}`
.toLowerCase()
.includes(nextQuery.trim().toLowerCase()),
)
setQuery(nextQuery)
setSelectedId(nextMatch?.id ?? '')
}}
Why update query and selection together?
The input stores the new query as the source value. It also moves selection to the first matching command so keyboard activation never points at a result hidden by the filter.
- Find the command result button inside the mapped list.
- Add these pointer handlers beside its existing click handler:
onMouseEnter={() => setSelectedId(command.id)}
onClick={() => runCommand(command)}
How do pointer interactions share the model?
Hovering stores the same selectedId used by the keyboard. Clicking passes the same typed command to runCommand().
- Save src/App.tsx.
- Return to the open palette in the browser.
- Move the pointer across several command rows.
- Type zzzz into the search field.
The selected treatment should follow the pointer. The unmatched query should replace the list with No commands found.
Do keyboard or pointer controls disagree?
- Confirm that both interaction paths update selectedId.
- Check that Enter passes selectedCommand to runCommand().
- Verify that the input sets selectedId from nextMatch?.id.
- Use Help me trace keyboard and pointer selection in my React command palette.
✔️ Awesome, I've got everything!
Your typed interaction model is connected. Double check that you saved src/App.tsx.
ⓧ I'd like to double check the full code
Compare your current src/App.tsx with this complete pre-animation version.
import { useState } from 'react'
type CommandGroup = 'Create' | 'Navigate' | 'System'
type Command = {
id: string
label: string
description: string
group: CommandGroup
shortcut: string
}
const commands: Command[] = [
{
id: 'new-file',
label: 'Create a new file',
description: 'Start with a clean document',
group: 'Create',
shortcut: 'N',
},
{
id: 'new-board',
label: 'Create a mood board',
description: 'Collect references in one visual space',
group: 'Create',
shortcut: 'B',
},
{
id: 'recent-projects',
label: 'Open recent projects',
description: 'Return to your latest work',
group: 'Navigate',
shortcut: 'R',
},
{
id: 'component-library',
label: 'Browse component library',
description: 'Inspect reusable interface patterns',
group: 'Navigate',
shortcut: 'C',
},
{
id: 'toggle-theme',
label: 'Toggle interface theme',
description: 'Change the workspace appearance',
group: 'System',
shortcut: 'T',
},
{
id: 'preferences',
label: 'Open preferences',
description: 'Adjust workspace behavior',
group: 'System',
shortcut: 'P',
},
]
function App() {
const [isOpen, setIsOpen] = useState(true)
const [query, setQuery] = useState('')
const [selectedId, setSelectedId] = useState(commands[0].id)
const [status, setStatus] = useState('Palette ready')
const [reduceMotion, setReduceMotion] = useState(false)
const normalizedQuery = query.trim().toLowerCase()
const filteredCommands = commands.filter((command) =>
`${command.label} ${command.description} ${command.group}`
.toLowerCase()
.includes(normalizedQuery),
)
const selectedCommand =
filteredCommands.find((command) => command.id === selectedId) ??
filteredCommands[0]
const openPalette = () => {
setQuery('')
setSelectedId(commands[0].id)
setIsOpen(true)
}
const runCommand = (command: Command) => {
setStatus(`Ran: ${command.label}`)
setIsOpen(false)
}
const moveSelection = (direction: 1 | -1) => {
if (filteredCommands.length === 0) return
const currentIndex = Math.max(
0,
filteredCommands.findIndex((command) => command.id === selectedCommand?.id),
)
const nextIndex =
(currentIndex + direction + filteredCommands.length) %
filteredCommands.length
setSelectedId(filteredCommands[nextIndex].id)
}
return (
<main className="workspace">
<div className="ambient ambient-one" />
<div className="ambient ambient-two" />
<section className="workspace-copy" aria-labelledby="page-title">
<p className="eyebrow">Design engineering playground</p>
<h1 id="page-title">Make state changes feel connected.</h1>
<p className="lede">
React owns the interaction state. TypeScript protects its shape.
Motion communicates what changed and where it went.
</p>
<div className="actions">
<button
className="primary-button"
type="button"
onClick={openPalette}
>
Open command palette
</button>
<label className="motion-toggle">
<input
type="checkbox"
checked={reduceMotion}
onChange={(event) => setReduceMotion(event.currentTarget.checked)}
/>
<span>Reduce motion</span>
</label>
</div>
{status && <p className="status">{status}</p>}
</section>
{isOpen && (
<div
className="overlay"
onMouseDown={(event) => {
if (event.target === event.currentTarget) setIsOpen(false)
}}
>
<section
className="palette"
aria-label="Command palette"
onKeyDown={(event) => {
if (event.key === 'ArrowDown') {
event.preventDefault()
moveSelection(1)
}
if (event.key === 'ArrowUp') {
event.preventDefault()
moveSelection(-1)
}
if (event.key === 'Enter' && selectedCommand) {
event.preventDefault()
runCommand(selectedCommand)
}
if (event.key === 'Escape') setIsOpen(false)
}}
>
<header className="palette-header">
<div>
<p className="palette-kicker">Quick actions</p>
<h2>Where do you want to go?</h2>
</div>
<button
className="close-button"
type="button"
onClick={() => setIsOpen(false)}
aria-label="Close command palette"
>
Esc
</button>
</header>
<div className="search-wrap">
<span aria-hidden="true">/</span>
<input
autoFocus
value={query}
onChange={(event) => {
const nextQuery = event.currentTarget.value
const nextMatch = commands.find((command) =>
`${command.label} ${command.description} ${command.group}`
.toLowerCase()
.includes(nextQuery.trim().toLowerCase()),
)
setQuery(nextQuery)
setSelectedId(nextMatch?.id ?? '')
}}
placeholder="Search commands"
aria-label="Search commands"
aria-controls="command-results"
aria-expanded="true"
/>
<kbd>{filteredCommands.length}</kbd>
</div>
<div className="results-viewport">
{filteredCommands.length > 0 ? (
<ul
id="command-results"
className="command-list"
role="listbox"
>
{filteredCommands.map((command) => {
const isSelected = command.id === selectedCommand?.id
return (
<li key={command.id}>
<button
className="command"
type="button"
role="option"
aria-selected={isSelected}
onMouseEnter={() => setSelectedId(command.id)}
onClick={() => runCommand(command)}
>
{isSelected && (
<span className="active-indicator" />
)}
<span className="command-copy">
<strong>{command.label}</strong>
<small>{command.description}</small>
</span>
<span className="command-meta">
<span>{command.group}</span>
<kbd>{command.shortcut}</kbd>
</span>
</button>
</li>
)
})}
</ul>
) : (
<div className="empty-state">
<strong>No commands found</strong>
<span>Try a broader phrase.</span>
</div>
)}
</div>
<footer className="palette-footer">
<span>Arrow keys move</span>
<span>Enter runs</span>
<span>Escape closes</span>
</footer>
</section>
</div>
)}
</main>
)
}
export default App
Before you run the final check, which state do you expect after activating Toggle interface theme with Enter?
- Switch back to the PowerShell terminal from earlier.
- Start the development server by running this command:
npm run dev
The terminal should print a local URL. The command palette should render when you visit that URL in the browser.
- Open the palette with Open command palette.
- Clear the search field.
- Press ArrowDown to move the selected treatment.
- Type theme into the search field.
- Press ArrowDown to confirm the single result remains selected through wraparound.
- Press Enter to activate the selected command.
The palette should close. You should see Ran: Toggle interface theme in the workspace status.
That success is intentionally abrupt. React now owns a coherent interaction model that the next step can translate into visible continuity.
Does the final interaction fail?
- Check the PowerShell output for the local development URL.
- Confirm that the palette section contains the onKeyDown handler.
- Verify that runCommand() updates status before closing the palette.
- Use Help me debug the final typed command-palette interaction.
Your palette now filters, selects, activates, dismisses, and reports commands through one typed model. Next, you will turn those abrupt state changes into coordinated choreography.
Turn State Changes into Choreography
Your typed React interaction model now handles filtering, selection, activation, dismissal, empty results, and status feedback. Each state change still appears instantly.
This step gives those changes visual continuity with Motion for React. Named variants describe each visual state. AnimatePresence gives disappearing elements time to complete their exit state.
In this step, get ready to:
- Define named visual states for the palette panel and result rows.
- Animate conditional panel content with stable identities.
- Test interrupted filtering without changing React state ownership.
Define named motion states
A variant maps a meaningful state name to visual properties. The names keep animation decisions outside the rendered markup.
- Switch back to src/App.tsx in the open workspace.
- Find the current React import:
import { useState } from 'react'
What is changing here?
This import currently gives the component access to React state. The next edit adds the Motion tools beside it.
- Replace the current import area with these two imports:
import { useState } from 'react'
import { AnimatePresence, motion } from 'motion/react'
What do these imports provide?
- The motion export creates elements that can animate between visual states.
- The AnimatePresence export tracks conditional children while they enter or leave.
- Add the panel state maps directly below the commands array:
const fullPanelVariants = {
hidden: { opacity: 0, scale: 0.96, y: 16 },
visible: {
opacity: 1,
scale: 1,
y: 0,
transition: { type: 'spring', stiffness: 430, damping: 34 },
},
exit: { opacity: 0, scale: 0.98, y: 10, transition: { duration: 0.14 } },
}
const reducedPanelVariants = {
hidden: { opacity: 0 },
visible: { opacity: 1, transition: { duration: 0.12 } },
exit: { opacity: 0, transition: { duration: 0.1 } },
}
How do the panel variants work?
- The hidden state places the full-motion panel below its destination. It also makes the panel slightly smaller.
- The visible state restores the panel to its resting position with a spring transition.
- The exit state gives the disappearing panel a short departure path.
- The reduced map keeps the same state names. It uses opacity without spatial travel.
- Save src/App.tsx.
- Return to the running app in your browser.
You should still see the interactive command palette. This confirms that the new import and panel maps compile before they control an element.
Seeing an import or syntax problem?
- Confirm the second import comes from motion/react.
- Confirm both panel maps sit outside App.
- Compare the braces around each named state.
- Help me inspect the Motion import and panel variant syntax.
- Add the result state maps directly below reducedPanelVariants:
const fullItemVariants = {
hidden: { opacity: 0, y: 8 },
visible: (index: number) => ({
opacity: 1,
y: 0,
transition: { delay: index * 0.035, duration: 0.16 },
}),
exit: { opacity: 0, y: -6, transition: { duration: 0.1 } },
}
const reducedItemVariants = {
hidden: { opacity: 0 },
visible: { opacity: 1, transition: { duration: 0.1 } },
exit: { opacity: 0, transition: { duration: 0.08 } },
}
How do the result variants work?
- The full-motion map gives each row a short vertical entry path.
- The index value staggers later results by a small amount.
- The reduced map removes vertical travel.
- Both maps expose the same state names to the result elements.
- Save src/App.tsx.
- Return to the browser to confirm the command list still renders.
The four state maps now describe the intended choreography. The next substep connects them to conditional elements.
Seeing a TypeScript diagnostic?
- Confirm the parameter uses index: number.
- Check the spelling of fullItemVariants.
- Check the spelling of reducedItemVariants.
- Help me diagnose my result variant objects.
Animate the panel and feedback
React removes a conditional element when its condition becomes false. Presence tracking keeps a keyed element available long enough for its exit animation to finish.
- Find the end of moveSelection inside App.
- Add the active variant choices directly below that function:
const panelVariants = reduceMotion
? reducedPanelVariants
: fullPanelVariants
const itemVariants = reduceMotion ? reducedItemVariants : fullItemVariants
Why choose variants during render?
The existing reduceMotion state selects one complete visual map during each render. The interaction data stays unchanged.
- Find the isOpen conditional that renders the overlay.
- Wrap the complete conditional with AnimatePresence.
- Convert the overlay from div to motion.div.
- Set the overlay key to palette-overlay.
- Give the overlay opacity values for initial, animate, and exit.
- Convert the palette from section to motion.section.
- Set its variants prop to panelVariants.
- Set its named states to hidden, visible, and exit.
Trace the panel transition
A change to isOpen triggers a React render. Presence tracking reads the stable overlay key to determine whether that element entered or left.
The panel resolves its named target through panelVariants. Motion changes the visual properties while React remains the state owner.
- Update the overlay closing tag to motion.div.
- Update the palette closing tag to motion.section.
- Save src/App.tsx.
Before you test the panel, do you expect closing it to remove it instantly or let the exit state finish?
- Close the palette with the Esc button.
- Reopen it with the Open command palette button.
You should see the overlay fade. You should also see the panel settle into place when it opens.
Does the panel still jump?
- Confirm AnimatePresence wraps the complete isOpen conditional.
- Confirm the overlay key is palette-overlay.
- Confirm the palette uses panelVariants.
- Help me find why the panel exit animation is missing.
Status feedback also changes identity when a command runs. Using the status string as a key gives the previous message an exit phase.
- Find the existing conditional status paragraph.
- Replace it with this presence-aware version:
<AnimatePresence>
{status && (
<motion.p
className="status"
key={status}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
{status}
</motion.p>
)}
</AnimatePresence>
Why key the status text?
The current status string becomes the identity of the rendered message. A new string causes the previous message to leave before its replacement settles in.
- Save src/App.tsx.
- Open the command palette.
- Type theme into the search field.
- Press Enter.
You should see the palette close. The status should transition to Ran: Toggle interface theme.
Does the status stay static?
- Confirm the status element is motion.p.
- Confirm its key uses the current status value.
- Confirm the conditional sits inside AnimatePresence.
- Help me debug the status transition.
Animate filtered results and test interruptions
Filtering changes which result rows exist after every keystroke. Stable keys let Motion follow those identities while React continues to derive filteredCommands during render.
- Find the conditional results block inside results-viewport.
- Wrap that conditional with AnimatePresence.
- Set the wrapper mode to popLayout.
- Set the wrapper's initial behavior to false.
- Convert the result list from ul to motion.ul.
- Give the populated list the stable key results.
Why stable keys matter
A stable key ties a rendered child to the same logical item across React renders. The keys results and empty distinguish the populated state from the empty state.
Each command ID preserves identity within the populated list. Motion can use those identities while queries change quickly.
- Convert each mapped result row from li to motion.li.
- Keep command.id as the row key.
- Pass the row's index through its custom prop.
- Set each row's variant map to itemVariants.
- Set each row's named states to hidden, visible, and exit.
- Convert the empty-state element from div to motion.div.
- Give the empty state the key empty.
- Apply itemVariants plus the same three named states to the empty state.
- Update every matching closing tag to its Motion element name.
- Save src/App.tsx.
Before you type quickly, do you expect the animation layer to change selectedId or leave it under React's control?
- Open the command palette.
- Type theme into the search field.
- Replace the query with zzzz.
- Clear the search field.
- Type several different queries rapidly.
You should see populated results and the empty state transition in both directions. Rapid typing should remain responsive while the selection logic keeps using the derived command list.
Do results still disappear abruptly?
- Confirm the complete populated-or-empty conditional sits inside AnimatePresence.
- Confirm each result row is a motion.li element.
- Confirm each row keeps command.id as its key.
- Help me debug the filtered result transitions.
✔️ Awesome, I've got everything!
Great. Double check that you saved src/App.tsx.
ⓧ I'd like to double check the full code
Compare your cumulative src/App.tsx with this reference.
import { useState } from 'react'
import { AnimatePresence, motion } from 'motion/react'
type CommandGroup = 'Create' | 'Navigate' | 'System'
type Command = {
id: string
label: string
description: string
group: CommandGroup
shortcut: string
}
const commands: Command[] = [
{
id: 'new-file',
label: 'Create a new file',
description: 'Start with a clean document',
group: 'Create',
shortcut: 'N',
},
{
id: 'new-board',
label: 'Create a mood board',
description: 'Collect references in one visual space',
group: 'Create',
shortcut: 'B',
},
{
id: 'recent-projects',
label: 'Open recent projects',
description: 'Return to your latest work',
group: 'Navigate',
shortcut: 'R',
},
{
id: 'component-library',
label: 'Browse component library',
description: 'Inspect reusable interface patterns',
group: 'Navigate',
shortcut: 'C',
},
{
id: 'toggle-theme',
label: 'Toggle interface theme',
description: 'Change the workspace appearance',
group: 'System',
shortcut: 'T',
},
{
id: 'preferences',
label: 'Open preferences',
description: 'Adjust workspace behavior',
group: 'System',
shortcut: 'P',
},
]
const fullPanelVariants = {
hidden: { opacity: 0, scale: 0.96, y: 16 },
visible: {
opacity: 1,
scale: 1,
y: 0,
transition: { type: 'spring', stiffness: 430, damping: 34 },
},
exit: { opacity: 0, scale: 0.98, y: 10, transition: { duration: 0.14 } },
}
const reducedPanelVariants = {
hidden: { opacity: 0 },
visible: { opacity: 1, transition: { duration: 0.12 } },
exit: { opacity: 0, transition: { duration: 0.1 } },
}
const fullItemVariants = {
hidden: { opacity: 0, y: 8 },
visible: (index: number) => ({
opacity: 1,
y: 0,
transition: { delay: index * 0.035, duration: 0.16 },
}),
exit: { opacity: 0, y: -6, transition: { duration: 0.1 } },
}
const reducedItemVariants = {
hidden: { opacity: 0 },
visible: { opacity: 1, transition: { duration: 0.1 } },
exit: { opacity: 0, transition: { duration: 0.08 } },
}
function App() {
const [isOpen, setIsOpen] = useState(true)
const [query, setQuery] = useState('')
const [selectedId, setSelectedId] = useState(commands[0].id)
const [status, setStatus] = useState('Palette ready')
const [reduceMotion, setReduceMotion] = useState(false)
const normalizedQuery = query.trim().toLowerCase()
const filteredCommands = commands.filter((command) =>
`${command.label} ${command.description} ${command.group}`
.toLowerCase()
.includes(normalizedQuery),
)
const selectedCommand =
filteredCommands.find((command) => command.id === selectedId) ??
filteredCommands[0]
const openPalette = () => {
setQuery('')
setSelectedId(commands[0].id)
setIsOpen(true)
}
const runCommand = (command: Command) => {
setStatus(`Ran: ${command.label}`)
setIsOpen(false)
}
const moveSelection = (direction: 1 | -1) => {
if (filteredCommands.length === 0) return
const currentIndex = Math.max(
0,
filteredCommands.findIndex((command) => command.id === selectedCommand?.id),
)
const nextIndex =
(currentIndex + direction + filteredCommands.length) %
filteredCommands.length
setSelectedId(filteredCommands[nextIndex].id)
}
const panelVariants = reduceMotion
? reducedPanelVariants
: fullPanelVariants
const itemVariants = reduceMotion ? reducedItemVariants : fullItemVariants
return (
<main className="workspace">
<div className="ambient ambient-one" />
<div className="ambient ambient-two" />
<section className="workspace-copy" aria-labelledby="page-title">
<p className="eyebrow">Design engineering playground</p>
<h1 id="page-title">Make state changes feel connected.</h1>
<p className="lede">
React owns the interaction state. TypeScript protects its shape.
Motion communicates what changed and where it went.
</p>
<div className="actions">
<button
className="primary-button"
type="button"
onClick={openPalette}
>
Open command palette
</button>
<label className="motion-toggle">
<input
type="checkbox"
checked={reduceMotion}
onChange={(event) => setReduceMotion(event.currentTarget.checked)}
/>
<span>Reduce motion</span>
</label>
</div>
<AnimatePresence>
{status && (
<motion.p
className="status"
key={status}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
{status}
</motion.p>
)}
</AnimatePresence>
</section>
<AnimatePresence>
{isOpen && (
<motion.div
className="overlay"
key="palette-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onMouseDown={(event) => {
if (event.target === event.currentTarget) setIsOpen(false)
}}
>
<motion.section
className="palette"
aria-label="Command palette"
variants={panelVariants}
initial="hidden"
animate="visible"
exit="exit"
onKeyDown={(event) => {
if (event.key === 'ArrowDown') {
event.preventDefault()
moveSelection(1)
}
if (event.key === 'ArrowUp') {
event.preventDefault()
moveSelection(-1)
}
if (event.key === 'Enter' && selectedCommand) {
event.preventDefault()
runCommand(selectedCommand)
}
if (event.key === 'Escape') setIsOpen(false)
}}
>
<header className="palette-header">
<div>
<p className="palette-kicker">Quick actions</p>
<h2>Where do you want to go?</h2>
</div>
<button
className="close-button"
type="button"
onClick={() => setIsOpen(false)}
aria-label="Close command palette"
>
Esc
</button>
</header>
<div className="search-wrap">
<span aria-hidden="true">/</span>
<input
autoFocus
value={query}
onChange={(event) => {
const nextQuery = event.currentTarget.value
const nextMatch = commands.find((command) =>
`${command.label} ${command.description} ${command.group}`
.toLowerCase()
.includes(nextQuery.trim().toLowerCase()),
)
setQuery(nextQuery)
setSelectedId(nextMatch?.id ?? '')
}}
placeholder="Search commands"
aria-label="Search commands"
aria-controls="command-results"
aria-expanded="true"
/>
<kbd>{filteredCommands.length}</kbd>
</div>
<div className="results-viewport">
<AnimatePresence mode="popLayout" initial={false}>
{filteredCommands.length > 0 ? (
<motion.ul
id="command-results"
className="command-list"
role="listbox"
key="results"
>
{filteredCommands.map((command, index) => {
const isSelected = command.id === selectedCommand?.id
return (
<motion.li
key={command.id}
custom={index}
variants={itemVariants}
initial="hidden"
animate="visible"
exit="exit"
>
<button
className="command"
type="button"
role="option"
aria-selected={isSelected}
onMouseEnter={() => setSelectedId(command.id)}
onClick={() => runCommand(command)}
>
{isSelected && (
<span className="active-indicator" />
)}
<span className="command-copy">
<strong>{command.label}</strong>
<small>{command.description}</small>
</span>
<span className="command-meta">
<span>{command.group}</span>
<kbd>{command.shortcut}</kbd>
</span>
</button>
</motion.li>
)
})}
</motion.ul>
) : (
<motion.div
className="empty-state"
key="empty"
variants={itemVariants}
initial="hidden"
animate="visible"
exit="exit"
>
<strong>No commands found</strong>
<span>Try a broader phrase.</span>
</motion.div>
)}
</AnimatePresence>
</div>
<footer className="palette-footer">
<span>Arrow keys move</span>
<span>Enter runs</span>
<span>Escape closes</span>
</footer>
</motion.section>
</motion.div>
)}
</AnimatePresence>
</main>
)
}
export default App
The final check proves that the development path still works after the choreography changes. It also tests the interruption behavior named in the state map.
- Switch back to the PowerShell terminal from earlier.
- Stop the current development process with Ctrl+C if it is still running.
Before you restart the app, do you expect Motion to change the filtering result or change only how that result appears?
- Start the development server by running:
npm run dev
What does this command confirm?
The development script starts Vite with the updated application. A local URL in PowerShell confirms that the browser feedback loop is available.
- Open the local URL printed in PowerShell.
- Open the command palette.
- Type theme into the search field.
- Replace the query with zzzz.
- Clear the search field.
- Close the palette with Escape.
- Reopen the palette with Open command palette.
- Type several different queries rapidly.
- Press ArrowDown after the results settle.
- Press Enter to run the selected command.
You should see the panel animate when it opens or closes. You should also see coordinated transitions between populated results and the empty state.
That choreography is working. React still decides which state exists while Motion communicates how each rendered state arrives or leaves.
Does the final check fail?
- Check PowerShell for the first file path named in the error.
- Compare src/App.tsx with the full-code reference above.
- Confirm every converted Motion element has a matching closing tag.
- Help me diagnose the final choreography check.
Your command palette now gives opening, closing, filtering, and empty-state changes coordinated entry and exit choreography. Next, you will connect reflow and selection through spatial continuity.
Add Spatial Continuity and Accessible Control
Your palette now uses Motion for React to coordinate entry and exit. React keeps filtering correct during rapid changes.
Selection still jumps between rows when the list reflows. This step adds spatial continuity while preserving an opacity-only reduced-motion treatment.
In this step, get ready to:
- Animate result reflow with layout continuity.
- Connect the selected indicator across command rows.
- Verify the complete interaction in both motion modes.
Add layout continuity and shared identity
A layout animation compares an element's previous position with its new position. Motion uses that difference to animate list reflow without manual coordinates.
- Switch back to src/App.tsx in the open VS Code workspace.
- Find the results list with key="results".
- Add the layout prop so the opening tag matches this reference:
<motion.ul
id="command-results"
className="command-list"
role="listbox"
key="results"
layout
>
What does the list track?
- The layout prop lets Motion animate render-driven changes to the list's size or position.
- React continues to decide which commands belong in the list.
- Find the motion.li element inside the filtered command map.
- Add the layout prop so each row matches this reference:
<motion.li
key={command.id}
layout
custom={index}
variants={itemVariants}
initial="hidden"
animate="visible"
exit="exit"
>
Why track each row?
- Each row receives its own animated path during filtering.
- The stable command.id key helps Motion follow the same command between renders.
- The existing item variants continue to control entry and exit.
- Save src/App.tsx.
Before you filter the list, do you expect the remaining rows to jump or glide into their new positions?
- Switch to the browser running the palette.
- Type create into the search field.
You'll see the remaining command rows reflow into place. The selected treatment still changes rows without a continuous visual identity.
Do the rows still jump?
- Confirm that the results list has the layout prop.
- Confirm that every mapped motion.li has the layout prop.
- Check that each row still uses command.id as its key.
- Help me debug result-row layout animation.
A shared element keeps one visual identity while its rendered location changes. The active indicator gains that identity through layoutId="active-command".
- Find the selected-state block inside the command button.
- Replace its indicator with this shared-element reference:
{isSelected && (
<motion.span
className="active-indicator"
layoutId="active-command"
transition={
reduceMotion
? { duration: 0 }
: {
type: 'spring',
stiffness: 520,
damping: 38,
}
}
/>
)}
How does the indicator travel?
- The shared layoutId tells Motion that each selected row renders the same visual element.
- The spring creates the full-motion travel between rows.
- The reduced-motion branch sets the transition duration to 0.
- Save src/App.tsx.
Before you move the selection, do you expect the highlight to disappear between rows or remain visually connected?
- Return to the open palette in your browser.
- Press the ArrowDown key several times.
- Press the ArrowUp key several times.
- Filter the command list with a short query.
You'll see the selected indicator travel between rows. Filtering also moves the remaining commands into their new positions.
Does the indicator still jump?
- Check that the selected span uses the exact active-command layout ID.
- Confirm that only the selected row renders the active indicator.
- Verify that the indicator remains a motion.span element.
- Help me debug shared indicator movement.
Add gestures and reduced-motion control
Gesture feedback confirms that a control can be pressed. A reduced-motion treatment keeps useful opacity feedback while removing spatial travel.
- Find the primary motion.button that calls openPalette.
- Add conditional gesture feedback so the opening tag matches this reference:
<motion.button
className="primary-button"
type="button"
onClick={openPalette}
whileHover={reduceMotion ? undefined : { y: -2 }}
whileTap={reduceMotion ? undefined : { scale: 0.97 }}
>
Why condition the primary gestures?
- Full-motion mode lifts the primary button by two pixels during hover.
- Press feedback applies a small scale change.
- Reduced-motion mode passes undefined to both gesture props.
- Find the command motion.button inside each result row.
- Add conditional gesture feedback so its opening tag matches this reference:
<motion.button
className="command"
type="button"
role="option"
aria-selected={isSelected}
onMouseEnter={() => setSelectedId(command.id)}
onClick={() => runCommand(command)}
whileHover={reduceMotion ? undefined : { x: 3 }}
whileTap={reduceMotion ? undefined : { scale: 0.99 }}
>
What do the row gestures communicate?
- Hover moves a command row by three pixels in full-motion mode.
- Press feedback confirms activation with a restrained scale change.
- The existing pointer handlers continue to own selection and command execution.
- Save src/App.tsx.
Before you compare both modes, which controls do you expect to move when reduced motion is disabled?
- Clear the Reduce motion checkbox.
- Hover over the primary button.
- Open the command palette.
- Hover over a command row.
- Press a command row with the pointer.
- Select the Reduce motion checkbox.
- Repeat the hover checks.
Full-motion mode gives both controls restrained spatial feedback. Reduced-motion mode keeps the controls spatially stable.
- Close the palette with the Escape key.
- Reopen the palette with the primary button.
- Type a query into the search field.
- Move selection with the ArrowDown key.
The panel and rows now fade in place. The indicator changes rows without spatial travel.
Do controls move in reduced mode?
- Confirm that both gesture props check reduceMotion before returning transform values.
- Verify that the checkbox updates the existing reduceMotion state.
- Check that panelVariants switches between the full and reduced variant objects.
- Help me debug conditional Motion gestures.
✔️ Awesome, I've got everything!
Great work. Your saved src/App.tsx now connects layout continuity, shared identity, gesture feedback, and reduced-motion control.
ⓧ I'd like to double check the full code
Compare your final src/App.tsx with this complete reference.
import { useState } from 'react'
import { AnimatePresence, motion } from 'motion/react'
type CommandGroup = 'Create' | 'Navigate' | 'System'
type Command = {
id: string
label: string
description: string
group: CommandGroup
shortcut: string
}
const commands: Command[] = [
{
id: 'new-file',
label: 'Create a new file',
description: 'Start with a clean document',
group: 'Create',
shortcut: 'N',
},
{
id: 'new-board',
label: 'Create a mood board',
description: 'Collect references in one visual space',
group: 'Create',
shortcut: 'B',
},
{
id: 'recent-projects',
label: 'Open recent projects',
description: 'Return to your latest work',
group: 'Navigate',
shortcut: 'R',
},
{
id: 'component-library',
label: 'Browse component library',
description: 'Inspect reusable interface patterns',
group: 'Navigate',
shortcut: 'C',
},
{
id: 'toggle-theme',
label: 'Toggle interface theme',
description: 'Change the workspace appearance',
group: 'System',
shortcut: 'T',
},
{
id: 'preferences',
label: 'Open preferences',
description: 'Adjust workspace behavior',
group: 'System',
shortcut: 'P',
},
]
const fullPanelVariants = {
hidden: { opacity: 0, scale: 0.96, y: 16 },
visible: {
opacity: 1,
scale: 1,
y: 0,
transition: { type: 'spring', stiffness: 430, damping: 34 },
},
exit: { opacity: 0, scale: 0.98, y: 10, transition: { duration: 0.14 } },
}
const reducedPanelVariants = {
hidden: { opacity: 0 },
visible: { opacity: 1, transition: { duration: 0.12 } },
exit: { opacity: 0, transition: { duration: 0.1 } },
}
const fullItemVariants = {
hidden: { opacity: 0, y: 8 },
visible: (index: number) => ({
opacity: 1,
y: 0,
transition: { delay: index * 0.035, duration: 0.16 },
}),
exit: { opacity: 0, y: -6, transition: { duration: 0.1 } },
}
const reducedItemVariants = {
hidden: { opacity: 0 },
visible: { opacity: 1, transition: { duration: 0.1 } },
exit: { opacity: 0, transition: { duration: 0.08 } },
}
function App() {
const [isOpen, setIsOpen] = useState(true)
const [query, setQuery] = useState('')
const [selectedId, setSelectedId] = useState(commands[0].id)
const [status, setStatus] = useState('Palette ready')
const [reduceMotion, setReduceMotion] = useState(false)
const normalizedQuery = query.trim().toLowerCase()
const filteredCommands = commands.filter((command) =>
`${command.label} ${command.description} ${command.group}`
.toLowerCase()
.includes(normalizedQuery),
)
const selectedCommand =
filteredCommands.find((command) => command.id === selectedId) ??
filteredCommands[0]
const openPalette = () => {
setQuery('')
setSelectedId(commands[0].id)
setIsOpen(true)
}
const runCommand = (command: Command) => {
setStatus(`Ran: ${command.label}`)
setIsOpen(false)
}
const moveSelection = (direction: 1 | -1) => {
if (filteredCommands.length === 0) return
const currentIndex = Math.max(
0,
filteredCommands.findIndex((command) => command.id === selectedCommand?.id),
)
const nextIndex =
(currentIndex + direction + filteredCommands.length) %
filteredCommands.length
setSelectedId(filteredCommands[nextIndex].id)
}
const panelVariants = reduceMotion
? reducedPanelVariants
: fullPanelVariants
const itemVariants = reduceMotion ? reducedItemVariants : fullItemVariants
return (
<main className="workspace">
<div className="ambient ambient-one" />
<div className="ambient ambient-two" />
<section className="workspace-copy" aria-labelledby="page-title">
<p className="eyebrow">Design engineering playground</p>
<h1 id="page-title">Make state changes feel connected.</h1>
<p className="lede">
React owns the interaction state. TypeScript protects its shape.
Motion communicates what changed and where it went.
</p>
<div className="actions">
<motion.button
className="primary-button"
type="button"
onClick={openPalette}
whileHover={reduceMotion ? undefined : { y: -2 }}
whileTap={reduceMotion ? undefined : { scale: 0.97 }}
>
Open command palette
</motion.button>
<label className="motion-toggle">
<input
type="checkbox"
checked={reduceMotion}
onChange={(event) => setReduceMotion(event.currentTarget.checked)}
/>
<span>Reduce motion</span>
</label>
</div>
<AnimatePresence>
{status && (
<motion.p
className="status"
key={status}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
{status}
</motion.p>
)}
</AnimatePresence>
</section>
<AnimatePresence>
{isOpen && (
<motion.div
className="overlay"
key="palette-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onMouseDown={(event) => {
if (event.target === event.currentTarget) setIsOpen(false)
}}
>
<motion.section
className="palette"
aria-label="Command palette"
variants={panelVariants}
initial="hidden"
animate="visible"
exit="exit"
onKeyDown={(event) => {
if (event.key === 'ArrowDown') {
event.preventDefault()
moveSelection(1)
}
if (event.key === 'ArrowUp') {
event.preventDefault()
moveSelection(-1)
}
if (event.key === 'Enter' && selectedCommand) {
event.preventDefault()
runCommand(selectedCommand)
}
if (event.key === 'Escape') setIsOpen(false)
}}
>
<header className="palette-header">
<div>
<p className="palette-kicker">Quick actions</p>
<h2>Where do you want to go?</h2>
</div>
<button
className="close-button"
type="button"
onClick={() => setIsOpen(false)}
aria-label="Close command palette"
>
Esc
</button>
</header>
<div className="search-wrap">
<span aria-hidden="true">/</span>
<input
autoFocus
value={query}
onChange={(event) => {
const nextQuery = event.currentTarget.value
const nextMatch = commands.find((command) =>
`${command.label} ${command.description} ${command.group}`
.toLowerCase()
.includes(nextQuery.trim().toLowerCase()),
)
setQuery(nextQuery)
setSelectedId(nextMatch?.id ?? '')
}}
placeholder="Search commands"
aria-label="Search commands"
aria-controls="command-results"
aria-expanded="true"
/>
<kbd>{filteredCommands.length}</kbd>
</div>
<div className="results-viewport">
<AnimatePresence mode="popLayout" initial={false}>
{filteredCommands.length > 0 ? (
<motion.ul
id="command-results"
className="command-list"
role="listbox"
key="results"
layout
>
{filteredCommands.map((command, index) => {
const isSelected = command.id === selectedCommand?.id
return (
<motion.li
key={command.id}
layout
custom={index}
variants={itemVariants}
initial="hidden"
animate="visible"
exit="exit"
>
<motion.button
className="command"
type="button"
role="option"
aria-selected={isSelected}
onMouseEnter={() => setSelectedId(command.id)}
onClick={() => runCommand(command)}
whileHover={reduceMotion ? undefined : { x: 3 }}
whileTap={reduceMotion ? undefined : { scale: 0.99 }}
>
{isSelected && (
<motion.span
className="active-indicator"
layoutId="active-command"
transition={
reduceMotion
? { duration: 0 }
: {
type: 'spring',
stiffness: 520,
damping: 38,
}
}
/>
)}
<span className="command-copy">
<strong>{command.label}</strong>
<small>{command.description}</small>
</span>
<span className="command-meta">
<span>{command.group}</span>
<kbd>{command.shortcut}</kbd>
</span>
</motion.button>
</motion.li>
)
})}
</motion.ul>
) : (
<motion.div
className="empty-state"
key="empty"
variants={itemVariants}
initial="hidden"
animate="visible"
exit="exit"
>
<strong>No commands found</strong>
<span>Try a broader phrase.</span>
</motion.div>
)}
</AnimatePresence>
</div>
<footer className="palette-footer">
<span>Arrow keys move</span>
<span>Enter runs</span>
<span>Escape closes</span>
</footer>
</motion.section>
</motion.div>
)}
</AnimatePresence>
</main>
)
}
export default App
What should you compare?
- Confirm that layout appears on the results list and every command row.
- Confirm that the active indicator uses layoutId="active-command".
- Confirm that both button types condition their gesture props on reduceMotion.
Test the complete production path
The final interaction check covers behavior that a compiler cannot judge. TypeScript and Vite then validate the production path.
- Return to the browser with the palette open.
- Clear the Reduce motion checkbox.
- Move selection with the ArrowDown key.
- Move selection with the ArrowUp key.
- Run the selected command with the Enter key.
- Reopen the palette with the primary button.
- Type an unmatched query into the search field.
- Replace the query rapidly with several short searches.
You'll see keyboard selection wrap through filtered results. Running a command closes the palette and updates the status message.
- Reopen the palette.
- Activate a command with the pointer.
- Reopen the palette again.
- Select the Reduce motion checkbox.
- Move selection across several rows.
- Close the palette with the Escape key.
- Reopen the palette with the primary button.
Reduced-motion mode uses opacity for panel and row transitions. The selected indicator changes rows without spatial travel.
Before you build, do you expect the production check to complete without type or bundling errors?
- Switch to the PowerShell terminal in the delightful-command-palette workspace.
- Validate the production path by running this command:
npm run build
What does the build check?
- The build script checks the project with TypeScript.
- Vite creates the production bundle after the type check succeeds.
- A successful build proves that the final Motion props fit the typed React implementation.
You'll see the build complete without TypeScript or Vite errors. PowerShell then returns control to the prompt.
Does the production build fail?
- Check src/App.tsx for a missing closing tag around either edited button.
- Compare the layoutId transition block with the full-code reference.
- Confirm that each conditional gesture receives an object or undefined.
- Help me diagnose the production build.
You made the whole interaction hold together. The palette now communicates reflow, selection, gestures, and reduced-motion intent through one coherent system.
Secret mission
Run a Motion Critique, Not a Prompt Lottery
Compare the same selected palette state across full-motion and reduced-motion treatments. Use a constrained Claude critique to defend three recommendations with evidence.
Clean Up Your Resources
Clean Up Your Resources
Your app runs locally without ongoing cloud costs. Choose whether to keep the artifacts, pause the development server, or delete the implementation and audit board.
Resources you used:
- The local delightful-command-palette folder containing the finished app.
- The Vite development server process if it is still running.
- The Figma motion critique board with paired captures and reviewed recommendations.
Keep everything running
No action needed. Choose this if you plan to keep developing the palette or revisit your motion critique.
- Keep the delightful-command-palette folder on your Windows laptop.
- Keep the motion critique board in Figma as a record of your design decisions.
- Leave the Vite development server running only while you are actively testing the palette.
Pause - I'll come back to this later
Stop the development server to free the terminal. Keep the project folder and critique board so you can resume later.
- Return to the PowerShell terminal from earlier.
- Press Ctrl+C if the Vite development server is still running.
- Keep the delightful-command-palette folder in its current location.
- Keep the motion critique board in the Figma file from earlier.
PowerShell returns to its prompt when Vite stops. Your application files and design audit stay untouched.
Delete - I don't want to use this again
This option removes the local project and motion critique board. Deletion is final, so the Pause option is the safe choice if you may return.
- Return to the PowerShell terminal from earlier.
- Press Ctrl+C if the Vite development server is still running.
- Close VS Code.
- Press the Windows key to display Windows search.
- Select File Explorer.
- Navigate to the parent folder where you created delightful-command-palette.
- Select the delightful-command-palette folder.
You are now targeting only the local project folder. Your other files remain outside this deletion.
- Press Shift+Delete to request permanent deletion.
- Confirm the deletion in the Windows dialog.
- Check the parent folder for delightful-command-palette.
You should no longer see the project folder. That clears the app and its installed dependencies from your laptop.
- Switch back to the Figma file from earlier.
- Select the motion critique board.
- Press Delete to remove the board from the Figma file.
- Confirm that the paired motion captures and reviewed recommendations no longer appear on the canvas.
Cleanup is complete. You removed the local implementation and its design audit without affecting your existing Figma or Claude access.
Nice Work!
Nice Work!
You did it! Your typed command palette now turns interface state changes into clear motion without sacrificing accessible feedback.
What you learned:
- Build a typed command palette with React and TypeScript that supports filtering, keyboard control, pointer selection, empty states, and command feedback.
- Translate a Figma state map into coordinated choreography with Motion for React, named variants, AnimatePresence, layout, and layoutId.
- Create an accessible reduced-motion experience that preserves feedback through opacity-only transitions while removing spatial travel.
- Complete the optional Secret Mission by producing a professional motion critique board with Claude recommendations marked as accepted, revised, or rejected with your reasoning.
Ready to quiz yourself?