# For coding agents

The user said: "Bring my UI to life with shapcn." This page tells you what to do. Follow the steps in order. Do not skip a step.

shapcn gives icons motion. An icon grows or bends when the pointer is on it. An icon plays a short motion when it is pressed. A toggle button morphs from one icon into the other. You add this to the icons that the app already has.

The role catalog is a JSON file: [/r/recipes.json](https://shapcn.com/r/recipes.json). It lists every role (theme toggle, copy button, menu button, and more) and the one behavior that fits it. Fetch it first. The file is the source of truth for icon names and for hover and press styles.

```sh
curl -s https://shapcn.com/r/recipes.json
```

## Rules

- Change how an icon moves. Never change what a button does or means.
- Apply exactly one behavior from the catalog to each icon.
- Use only these styles. Hover: `grow`, `bend`, `bold`, `cascade`, `move`. Press: `pop`, `squash`, `squat-jump`, `burst`. No other style exists.
- Every icon name in a morph pair must be a real Lucide name. Check each name (step 4).
- Keep every visual state that the old code showed: fill, color, size, stroke width. A fill that the app already had stays. New code does not add a fill.
- When two roles match, a control inside a nav bar, a tab bar or a sidebar menu uses `nav-icon`. Otherwise the specific role wins. Use `generic-action` only when no specific role fits and the purpose is clear.
- Animate only an icon that is inside a button or a link. An icon outside a button or a link is decoration. Leave it as it is (for example the search icon inside an input).
- Register only the icons that the app uses. Never register all of Lucide.
- Keep the app's own icon mapping for toggles (theme, password visibility, mute). Never flip it to match the catalog. The `stateMeaning` text of a catalog entry is a default for new code only.
- Do not animate icons in a dense list or table: skip them when one list or table has 8 or more repeated icon buttons, unless the user asks.
- If you are not sure of the role of an icon, ask the user. Do not guess.
- Do not edit `components/ui/*` files, unless the icon sits inside that file. If it does, tell the user: a later `shadcn add` can overwrite the file.
- Do not commit, unless the user asks.

## Step 1: Detect the stack

Read `package.json` and the lockfile. Find these four facts.

- The framework: React (also Next.js), Vue, Svelte, or plain HTML.
- The icon source: `lucide-react`, `@lucide/react`, `@lucide/vue`, `@lucide/svelte`, `lucide-vue-next`, `lucide-svelte`, inline `<svg>`, or another family (Tabler, Phosphor, Remix, Hugeicons).
- The package manager: `package-lock.json` is npm, `pnpm-lock.yaml` is pnpm, `yarn.lock` is yarn, `bun.lock` is bun.
- Whether the project uses shadcn/ui: look for `components.json`.

If the project has no `package.json` (plain HTML), use the script tag in step 2.

## Step 2: Install

Pick one path.

### React or Next.js with shadcn/ui

Add the registry item. It installs the `shape-aware-morph` package and writes two files: `components/ui/morph-icon.tsx` (the component) and `lib/morph-icons.ts` (the list of icons that the component can draw).

```sh
npx shadcn@latest add https://shapcn.com/r/morph-icon.json
```

The component registers only the icons in `lib/morph-icons.ts`. You add each icon that you use in step 4. The component has a `role` prop (for example `role="theme-toggle"`) that sets hover, press and easing from the catalog.

### Any project with npm

```sh
npm i shape-aware-morph
```

Use `pnpm add`, `yarn add`, or `bun add` in place of `npm i` if the project uses that manager.

### Plain HTML

Add one script tag to the page. It defines `<morph-icon>` and loads all the Lucide icons. The file is about 170 KB gzip. Tell the user this cost. If the project has a bundler, use npm instead and register only the icons that the app uses.

```html
<script type="module" src="https://shapcn.com/morph-icon.js"></script>
```

### Check the built size

Before you change any code, run the production build and write down the size of the JavaScript. After step 6, run it again. Tell the user the difference in the report. The engine is about 80 KB gzip, and each icon you register adds a little. Registering all of Lucide adds about 175 KB gzip. That is why you register only the icons that you use.

## Step 3: Inventory every icon and classify its role

Find every icon in the source. Use these searches.

```sh
grep -rEn "from ['\"](lucide-react|@lucide/[a-z-]+|lucide-vue-next|lucide-svelte)['\"]" src app components
grep -rEn "<svg" src app components
```

Make a list. For each icon write down: the file, the line, the icon name, the button or link that holds it (or "none"), the label of that button, and every visual state that the old code showed. A visual state is a fill, a color, a size, a stroke width, or a class that changes with the state of the app (for example a solid red heart when liked). You keep each of them in step 4.

Then match each icon to a role in `recipes.json`. Use the `detect` fields of each role: the `labels` words, the `iconNames`, the `reactNames` (the names that `lucide-react` exports), and the `aria` hints. Match on the meaning of the button, not on the icon alone. A `Sun` icon in a weather widget is not a theme toggle.

- Not inside a button or a link: the icon is decoration. Skip it. Example: the search icon inside a text input.
- Inside a nav bar, a tab bar or a sidebar menu: the role is `nav-icon`, even if a specific role also matches (for example a Settings icon in a nav bar).
- Otherwise, one specific role fits: use it.
- Otherwise, the purpose of the control is clear but no role fits: use `generic-action`.
- Otherwise, the purpose is unclear, or two specific roles fit: ask the user. Say which icon, which file, and which roles you considered. Leave the icon as it is until the user answers.
- Icons in a dense list or a table: skip them when one list or table has 8 or more repeated icon buttons (for example a delete button on every row), unless the user asked for them.
- External link icons: skip them. The catalog says not to animate them.
- Icons in a shadcn `components/ui/*` file: change them only if the icon sits inside that file, and tell the user in the report. A later `shadcn add` can overwrite the file.
- A name that the catalog detects (for example `share-2`) stays the same name. `share-2` stays `share-2`. Do not rename it to `share`. Register it by its camelCase name (`share2`).

## Step 4: Apply one behavior to each icon

Read the `behavior` of the role.

- `type: "style"`: the icon keeps its shape. Only hover and press styles apply. Some entries have an `onSuccess` or `withAsyncState` part. Use it only if the app already has that state (for example a check after a share).
- `type: "toggle"`: two icons. The icon follows a state of the app. Hover and press styles come from the entry.
- `type: "status-chain"`: the icon changes for a short time (copy, then check), and then goes back.
- `type: "press-change"`: the press style plays and the icon changes to a check.
- `type: "none"`: do nothing.

For toggles, keep the mapping that the app has (which icon shows for which state). Do not flip it to match the `stateMeaning` text of the catalog. That text is a default for new code only. Keep every visual state from your list in step 3. If the old code showed a fill (the entry has `keepFill`), keep the fill: use the `filled` prop (or the CSS below) while the state is on, and keep the same icon name. Do not morph to another icon for a state that the old code showed with a fill. New code never adds a fill that was not there.

Add every icon name that you use to `lib/morph-icons.ts` (shadcn) or to your `registerIcons` call (other setups). Import the icon from `shape-aware-morph/lucide` by its camelCase name, and list it under its Lucide name: `chevronDown` is listed as `"chevron-down"`, and `volume2` as `"volume-2"`.

Check that every icon name exists in the Lucide module of the package. Run this after you install the package. It prints `false` for a name that does not exist.

```sh
node --input-type=module -e "import('shape-aware-morph/lucide').then((m) => console.log(['sun','moon'].map((n) => n + ': ' + (n in m.default)).join('\n')))"
```

Keep the size. Set `size` to the pixel size of the old icon. For example, `h-4 w-4` is `size="16"`. The color follows the CSS `color` of the parent, as before. Set `stroke-width` only if the old icon had one that was not the default `2`.

### React with the shadcn component

```ts
// lib/morph-icons.ts: list every icon that you use
import { chevronDown, heart, moon, sun } from "shape-aware-morph/lucide"

export const morphIcons = { sun, moon, heart, "chevron-down": chevronDown }
```

```tsx
import { Button } from "@/components/ui/button"
import { MorphIcon } from "@/components/ui/morph-icon"

export function ThemeToggle({ dark, onToggle }: { dark: boolean; onToggle: () => void }) {
  return (
    <Button variant="outline" size="icon" aria-label="Toggle theme" onClick={onToggle}>
      <MorphIcon name={dark ? "moon" : "sun"} role="theme-toggle" size={20} />
    </Button>
  )
}
```

`role` is the role id of the catalog. It sets hover, press, easing and duration, so you do not repeat them. Explicit props (`hover`, `press`, `easing`, `duration`) win over the role. `role` is not the HTML role attribute. To keep a fill that the app already showed, add `filled`:

```tsx
<MorphIcon name="heart" role="like" size={20} filled={liked} />
```

The component draws an empty box of the same size on the server, then the icon. It sets `aria-hidden` for you. If a name is not in `lib/morph-icons.ts`, it draws an empty box and writes a warning to the console.

### React without shadcn

```tsx
import { useEffect, useState } from "react"
import { defineMorphIcon, registerIcons } from "shape-aware-morph"
import { moon, sun } from "shape-aware-morph/lucide"

registerIcons({ sun, moon }) // only the icons that the app uses

export function ThemeToggle({ dark, onToggle }: { dark: boolean; onToggle: () => void }) {
  const [ready, setReady] = useState(false)
  useEffect(() => {
    defineMorphIcon()
    setReady(true)
  }, [])
  return (
    <button type="button" aria-label="Toggle theme" onClick={onToggle}>
      {ready ? (
        <morph-icon name={dark ? "moon" : "sun"} size="20" hover="grow" press="squash" easing="spring" aria-hidden="true" />
      ) : (
        <span style={{ display: "inline-block", width: 20, height: 20 }} aria-hidden="true" />
      )}
    </button>
  )
}
```

Call `defineMorphIcon` in an effect, not at module level. On the server there is no DOM. `registerIcons` has no DOM work, so it can run at module level. The empty `span` keeps the size, so the layout does not shift. To keep a fill, add this rule to your CSS and set `data-filled` on the icon while the state is on:

```css
morph-icon[data-filled] svg * { fill: currentColor; }
```

### Vue

Tell Vue that `morph-icon` is a custom element.

```ts
// vite.config.ts
import vue from "@vitejs/plugin-vue"

export default {
  plugins: [
    vue({ template: { compilerOptions: { isCustomElement: (tag) => tag === "morph-icon" } } }),
  ],
}
```

```vue
<script setup>
import { onMounted } from "vue"
import { defineMorphIcon, registerIcons } from "shape-aware-morph"
import { moon, sun } from "shape-aware-morph/lucide"

registerIcons({ sun, moon }) // only the icons that the app uses
defineProps({ dark: Boolean })
defineEmits(["toggle"])
onMounted(() => defineMorphIcon())
</script>

<template>
  <button type="button" aria-label="Toggle theme" @click="$emit('toggle')">
    <morph-icon :name="dark ? 'moon' : 'sun'" size="20" hover="grow" press="squash" easing="spring" aria-hidden="true" />
  </button>
</template>
```

### Svelte

```svelte
<script>
  import { onMount } from "svelte"
  import { defineMorphIcon, registerIcons } from "shape-aware-morph"
  import { moon, sun } from "shape-aware-morph/lucide"

  registerIcons({ sun, moon }) // only the icons that the app uses
  export let dark = false
  export let onToggle = () => {}
  onMount(() => defineMorphIcon())
</script>

<button type="button" aria-label="Toggle theme" on:click={onToggle}>
  <morph-icon name={dark ? "moon" : "sun"} size="20" hover="grow" press="squash" easing="spring" aria-hidden="true"></morph-icon>
</button>
```

### Plain HTML

```html
<script type="module" src="https://shapcn.com/morph-icon.js"></script>

<button type="button" id="theme" aria-label="Toggle theme">
  <morph-icon name="sun" size="20" hover="grow" press="squash" easing="spring" aria-hidden="true"></morph-icon>
</button>

<script type="module">
  const button = document.getElementById("theme")
  const icon = button.querySelector("morph-icon")
  let dark = false
  button.addEventListener("click", () => {
    dark = !dark
    document.documentElement.classList.toggle("dark", dark)
    icon.pressTo(dark ? "moon" : "sun")
  })
</script>
```

### Click and change with pressTo

For a toggle, the press and the change of icon are one motion. The press starts on pointer down. The click comes a moment later. So you have two ways to do it, and both work.

- Set the `name` attribute (or the `name` prop in a framework) from the state, in the click handler. The new icon joins the press that plays. The React, Vue, and Svelte examples above do this.
- Call `icon.pressTo(name)`. Use it when the icon is not bound to state, as in plain HTML. `pressTo` uses the `press` attribute of the icon, or `squash` if there is none.

For a status chain, change the name when the real state changes. Do not use a timer to fake a state.

```ts
// copy button: copy, then check, then copy again
async function onCopy(icon: HTMLElement & { pressTo(name: string): Promise<boolean> }, text: string) {
  await navigator.clipboard.writeText(text) // if this throws, stay on "copy"
  icon.pressTo("check")
  setTimeout(() => icon.setAttribute("name", "copy"), 1500)
}
```

## Step 5: Accessibility

- An icon next to a text label is decoration. Set `aria-hidden="true"` on the icon.
- An icon-only button needs an `aria-label` on the button, not on the icon. If the button already has one, keep it.
- A toggle keeps its `aria-pressed` or `aria-expanded`. The icon must show the same state as that attribute.
- Reduced motion is automatic. When the user sets `prefers-reduced-motion: reduce`, the icon changes at once with no motion. Do not add your own check.
- Do not animate icons in dense lists or tables, unless the user asks.
- Keep the visual size and the color. The page must not shift.
- Keep every visual state from your list in step 3 (fill, color, size).

## Step 6: Verify in a browser

Start the dev server. Open the app in a browser and do these checks for every icon that you changed.

- The icon is drawn (not an empty box).
- Hover the icon. It plays the hover style. Move the pointer away. It returns to its shape.
- Click the icon. A toggle morphs to the other icon, and the state of the app changes as before. Click again. It morphs back.
- Use the keyboard. Tab to the button and press Enter or Space. It plays the press.
- The console has no errors and no warnings from `morph-icon`.
- The layout does not shift. Run the layout check below.
- The icon has the same size, color and fill as before, in every state.
- Buttons that hold an icon with a `data-icon` attribute (for example the shadcn Button with `data-icon="inline-start"`): compare the width of each button at mount (the first frame) and at rest (one second later). The two widths must be equal. If they differ, the placeholder lost the `data-icon` attribute.

The layout check lists the box of every button and link that holds an icon. Run it in the browser console (or with Playwright `page.evaluate`) on the old code (`git stash`) and on the new code. Every row must be the same.

```js
// layout-check
JSON.stringify([...document.querySelectorAll('button,a,[role=button]')].filter((e) => e.querySelector('svg,morph-icon')).map((e) => { const r = e.getBoundingClientRect(); return [(e.getAttribute('aria-label') || e.textContent.trim()).slice(0, 30), Math.round(r.x), Math.round(r.y), Math.round(r.width * 10) / 10, Math.round(r.height * 10) / 10] }))
```

Run the existing tests, the type check and the linter of the project (for example `npm run lint`). Fix what you broke.

## Step 7: Report

Do not commit, unless the user asked. Leave the changes as a diff for the user to review. Then write a short table. One row for each icon.

| File | Icon | Role | Behavior | Why |
| --- | --- | --- | --- | --- |
| `components/theme-toggle.tsx` | `sun` and `moon` | theme-toggle | `role="theme-toggle"`: hover grow, press squash, morph on click | Matches the catalog entry |

After the table, list in three short groups:

- Icons that you skipped, and why (dense list, external link, decoration, not sure).
- Visual states that you kept (for example "the filled heart when liked").
- The size cost: the JavaScript size before and after the change.
- Files that you changed in `components/ui/` (a later `shadcn add` can overwrite them).
- Questions for the user (icons with an unclear role).
- What you checked in the browser, and the result.

## Ask the user

Ask when any of these is true. Ask one short question for each icon.

- No role in the catalog fits the icon.
- Two roles fit the icon.
- The button does something that the name or label does not explain.
- A toggle has two states, but the code has no clear on and off meaning.
- The purpose of the control is unclear. (If the purpose is clear but no role fits, use `generic-action` and do not ask.)
- The icon is inside a dense list or a table, and you are not sure the user wants motion there.

## Links

- [Role catalog (JSON)](https://shapcn.com/r/recipes.json)
- [Install](https://shapcn.com/docs/install.md)
- [Hover and press](https://shapcn.com/docs/hover-press.md)
- [Frameworks](https://shapcn.com/docs/frameworks.md)
- [Accessibility](https://shapcn.com/docs/accessibility.md)
