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

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

Rules

Step 1: Detect the stack

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

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

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

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.

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

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.

Step 4: Apply one behavior to each icon

Read the behavior of the role.

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.

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

// 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 }
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:

<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

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:

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

Vue

Tell Vue that morph-icon is a custom element.

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

export default {
  plugins: [
    vue({ template: { compilerOptions: { isCustomElement: (tag) => tag === "morph-icon" } } }),
  ],
}
<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

<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

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

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

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

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

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

FileIconRoleBehaviorWhy
components/theme-toggle.tsxsun and moontheme-togglerole="theme-toggle": hover grow, press squash, morph on clickMatches the catalog entry

After the table, list in three short groups:

Ask the user

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

Markdown version