{"$schema":"https://ui.shadcn.com/schema/registry.json","author":"Kaiyu Hsu <uicapsule@kyh.io>","categories":["cards-grids","skeuomorphism","silly"],"dependencies":["lucide-react","motion"],"description":"iOS home-screen edit mode — long-press to wobble, drag to reorder, tap the badge to remove.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport type { FC } from \"react\";\nimport {\n  Activity,\n  BookOpen,\n  Calculator,\n  CalendarDays,\n  Camera,\n  Clock,\n  CloudSun,\n  Compass,\n  Folder,\n  Heart,\n  House,\n  ListChecks,\n  Mail,\n  Map,\n  MessageCircle,\n  Minus,\n  Music2,\n  NotebookPen,\n  Phone,\n  Podcast,\n  Settings,\n  TrendingUp,\n  Wallet,\n} from \"lucide-react\";\nimport type { LucideIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nimport { PhoneFrame } from \"./phone-frame\";\n\ninterface AppItem {\n  id: string;\n  label: string;\n  icon: LucideIcon;\n  background: string;\n  iconClass: string;\n}\n\nconst APPS: AppItem[] = [\n  {\n    background: \"bg-gradient-to-b from-[#5ff777] to-[#0eb531]\",\n    icon: MessageCircle,\n    iconClass: \"text-white\",\n    id: \"messages\",\n    label: \"Messages\",\n  },\n  {\n    background: \"bg-white\",\n    icon: CalendarDays,\n    iconClass: \"text-red-500\",\n    id: \"calendar\",\n    label: \"Calendar\",\n  },\n  {\n    background: \"bg-gradient-to-b from-[#fda4af] to-[#e11d48]\",\n    icon: Camera,\n    iconClass: \"text-white\",\n    id: \"photos\",\n    label: \"Photos\",\n  },\n  {\n    background: \"bg-gradient-to-b from-[#3f4650] to-[#16181d]\",\n    icon: Clock,\n    iconClass: \"text-white\",\n    id: \"clock\",\n    label: \"Clock\",\n  },\n  {\n    background: \"bg-gradient-to-b from-[#4aa8f0] to-[#1d63d8]\",\n    icon: CloudSun,\n    iconClass: \"text-white\",\n    id: \"weather\",\n    label: \"Weather\",\n  },\n  {\n    background: \"bg-gradient-to-b from-[#8ce99a] to-[#2f9e44]\",\n    icon: Map,\n    iconClass: \"text-white\",\n    id: \"maps\",\n    label: \"Maps\",\n  },\n  {\n    background: \"bg-gradient-to-b from-[#74c0fc] to-[#1971c2]\",\n    icon: Mail,\n    iconClass: \"text-white\",\n    id: \"mail\",\n    label: \"Mail\",\n  },\n  {\n    background: \"bg-gradient-to-b from-[#fff3bf] to-[#fcc419]\",\n    icon: NotebookPen,\n    iconClass: \"text-amber-900\",\n    id: \"notes\",\n    label: \"Notes\",\n  },\n  {\n    background: \"bg-gradient-to-b from-[#ff8787] to-[#e8390e]\",\n    icon: Music2,\n    iconClass: \"text-white\",\n    id: \"music\",\n    label: \"Music\",\n  },\n  {\n    background: \"bg-gradient-to-b from-[#343a40] to-[#0b0c0e]\",\n    icon: Wallet,\n    iconClass: \"text-white\",\n    id: \"wallet\",\n    label: \"Wallet\",\n  },\n  {\n    background: \"bg-white\",\n    icon: Heart,\n    iconClass: \"text-pink-500\",\n    id: \"health\",\n    label: \"Health\",\n  },\n  {\n    background: \"bg-gradient-to-b from-[#b197fc] to-[#6741d9]\",\n    icon: Podcast,\n    iconClass: \"text-white\",\n    id: \"podcasts\",\n    label: \"Podcasts\",\n  },\n  {\n    background: \"bg-gradient-to-b from-[#ced4da] to-[#868e96]\",\n    icon: Settings,\n    iconClass: \"text-neutral-700\",\n    id: \"settings\",\n    label: \"Settings\",\n  },\n  {\n    background: \"bg-gradient-to-b from-[#a5d8ff] to-[#1c7ed6]\",\n    icon: Folder,\n    iconClass: \"text-white\",\n    id: \"files\",\n    label: \"Files\",\n  },\n  {\n    background: \"bg-gradient-to-b from-[#2b2d31] to-[#101113]\",\n    icon: Activity,\n    iconClass: \"text-lime-400\",\n    id: \"fitness\",\n    label: \"Fitness\",\n  },\n  {\n    background: \"bg-white\",\n    icon: ListChecks,\n    iconClass: \"text-orange-500\",\n    id: \"reminders\",\n    label: \"Reminders\",\n  },\n  {\n    background: \"bg-gradient-to-b from-[#ffa94d] to-[#f76707]\",\n    icon: BookOpen,\n    iconClass: \"text-white\",\n    id: \"books\",\n    label: \"Books\",\n  },\n  {\n    background: \"bg-gradient-to-b from-[#2b2d31] to-[#101113]\",\n    icon: TrendingUp,\n    iconClass: \"text-emerald-400\",\n    id: \"stocks\",\n    label: \"Stocks\",\n  },\n  {\n    background: \"bg-gradient-to-b from-[#495057] to-[#212529]\",\n    icon: Calculator,\n    iconClass: \"text-orange-400\",\n    id: \"calculator\",\n    label: \"Calculator\",\n  },\n  {\n    background: \"bg-white\",\n    icon: House,\n    iconClass: \"text-amber-500\",\n    id: \"home\",\n    label: \"Home\",\n  },\n];\n\nconst DOCK: AppItem[] = [\n  {\n    background: \"bg-gradient-to-b from-[#5ff777] to-[#0eb531]\",\n    icon: Phone,\n    iconClass: \"text-white\",\n    id: \"phone\",\n    label: \"Phone\",\n  },\n  {\n    background: \"bg-white\",\n    icon: Compass,\n    iconClass: \"text-sky-500\",\n    id: \"safari\",\n    label: \"Safari\",\n  },\n  {\n    background: \"bg-gradient-to-b from-[#4aa8f0] to-[#1d63d8]\",\n    icon: MessageCircle,\n    iconClass: \"text-white\",\n    id: \"dock-messages\",\n    label: \"Messages\",\n  },\n  {\n    background: \"bg-gradient-to-b from-[#ff8787] to-[#e8390e]\",\n    icon: Music2,\n    iconClass: \"text-white\",\n    id: \"dock-music\",\n    label: \"Music\",\n  },\n];\n\nconst COLUMNS = 4;\nconst LONG_PRESS_MS = 450;\n\nconst wobbleSeed = (id: string) => {\n  let hash = 0;\n  for (const char of id) {\n    hash = (hash * 31 + (char.codePointAt(0) ?? 0)) % 997;\n  }\n  return hash / 997;\n};\n\nconst AppIcon: FC<{ app: AppItem }> = ({ app }) => (\n  <div\n    className={`grid size-14 place-items-center rounded-[15px] shadow-lg shadow-black/25 ${app.background}`}\n  >\n    <app.icon className={`size-7 ${app.iconClass}`} strokeWidth={2.2} />\n  </div>\n);\n\nexport const JiggleMode = () => {\n  const [order, setOrder] = useState<string[]>(APPS.map((app) => app.id));\n  const [editing, setEditing] = useState(false);\n  const [draggingId, setDraggingId] = useState<string | null>(null);\n  const gridRef = useRef<HTMLDivElement | null>(null);\n  const pressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const wallpaperPressWhileEditingRef = useRef(false);\n  const reduceMotion = useReducedMotion() ?? false;\n  const jiggling = editing && !reduceMotion;\n\n  useEffect(\n    () => () => {\n      if (pressTimerRef.current) {\n        clearTimeout(pressTimerRef.current);\n      }\n    },\n    [],\n  );\n\n  const startPress = useCallback(() => {\n    if (pressTimerRef.current) {\n      clearTimeout(pressTimerRef.current);\n    }\n    pressTimerRef.current = setTimeout(() => setEditing(true), LONG_PRESS_MS);\n  }, []);\n\n  const cancelPress = useCallback(() => {\n    if (pressTimerRef.current) {\n      clearTimeout(pressTimerRef.current);\n      pressTimerRef.current = null;\n    }\n  }, []);\n\n  const removeApp = useCallback((id: string) => {\n    setOrder((previous) => previous.filter((entry) => entry !== id));\n  }, []);\n\n  const reorderTo = useCallback(\n    (id: string, pointX: number, pointY: number) => {\n      const grid = gridRef.current;\n      if (!grid) {\n        return;\n      }\n      const rect = grid.getBoundingClientRect();\n      const cellWidth = rect.width / COLUMNS;\n      const rows = Math.ceil(order.length / COLUMNS);\n      const cellHeight = rect.height / rows;\n      const column = Math.min(\n        COLUMNS - 1,\n        Math.max(0, Math.floor((pointX - rect.left) / cellWidth)),\n      );\n      const row = Math.min(rows - 1, Math.max(0, Math.floor((pointY - rect.top) / cellHeight)));\n      const target = Math.min(order.length - 1, row * COLUMNS + column);\n      const current = order.indexOf(id);\n      if (current === -1 || target === current) {\n        return;\n      }\n      setOrder((previous) => {\n        const next = previous.filter((entry) => entry !== id);\n        next.splice(target, 0, id);\n        return next;\n      });\n    },\n    [order],\n  );\n\n  const apps = order\n    .map((id) => APPS.find((app) => app.id === id))\n    .filter((app): app is AppItem => app !== undefined);\n\n  return (\n    <PhoneFrame tone=\"ochre\" className=\"bg-[#1a1209]\">\n      <div aria-hidden className=\"absolute inset-0\">\n        <div className=\"absolute -top-20 -left-24 size-72 rounded-full bg-[#d97706]/45 blur-3xl\" />\n        <div className=\"absolute right-[-60px] bottom-24 size-80 rounded-full bg-[#ea580c]/25 blur-3xl\" />\n        <div className=\"absolute bottom-[-80px] left-[-40px] size-64 rounded-full bg-[#be185d]/25 blur-3xl\" />\n      </div>\n\n      <div\n        className=\"absolute inset-0\"\n        onPointerDown={() => {\n          wallpaperPressWhileEditingRef.current = editing;\n          if (!editing) {\n            startPress();\n          }\n        }}\n        onPointerUp={() => {\n          cancelPress();\n          if (wallpaperPressWhileEditingRef.current && !draggingId) {\n            setEditing(false);\n          }\n          wallpaperPressWhileEditingRef.current = false;\n        }}\n        onPointerLeave={() => {\n          cancelPress();\n          wallpaperPressWhileEditingRef.current = false;\n        }}\n      />\n\n      <div className=\"pointer-events-none relative flex h-[52px] items-center justify-between px-8 text-[14px] font-semibold text-white\">\n        <span>9:41</span>\n        <AnimatePresence>\n          {editing && (\n            <motion.button\n              type=\"button\"\n              key=\"done\"\n              initial={{ opacity: 0, scale: 0.8 }}\n              animate={{ opacity: 1, scale: 1 }}\n              exit={{ opacity: 0, scale: 0.8 }}\n              onClick={() => {\n                setEditing(false);\n                setDraggingId(null);\n              }}\n              className=\"pointer-events-auto rounded-full bg-white/20 px-3.5 py-1 text-[13px] font-semibold text-white backdrop-blur-sm\"\n            >\n              Done\n            </motion.button>\n          )}\n        </AnimatePresence>\n      </div>\n\n      <div ref={gridRef} className=\"relative mx-auto mt-3 grid w-[308px] grid-cols-4 gap-y-6\">\n        <AnimatePresence mode=\"popLayout\">\n          {apps.map((app) => {\n            const seed = wobbleSeed(app.id);\n            const wobble = 2 + seed * 1.2;\n            return (\n              <motion.div\n                key={app.id}\n                layout={draggingId !== app.id}\n                exit={{ opacity: 0, scale: 0.4 }}\n                transition={{ bounce: 0.25, duration: 0.45, type: \"spring\" }}\n                className={`relative flex flex-col items-center ${\n                  draggingId === app.id ? \"z-30\" : \"z-10\"\n                }`}\n              >\n                <motion.div\n                  layout\n                  drag={editing}\n                  dragSnapToOrigin\n                  dragMomentum={false}\n                  dragElastic={0.1}\n                  onPointerDown={editing ? undefined : startPress}\n                  onPointerUp={cancelPress}\n                  onDragStart={() => setDraggingId(app.id)}\n                  onDrag={(_, info) => reorderTo(app.id, info.point.x, info.point.y)}\n                  onDragEnd={() => setDraggingId(null)}\n                  animate={\n                    editing\n                      ? {\n                          rotate: jiggling ? [-wobble, wobble] : 0,\n                          scale: draggingId === app.id ? 1.14 : 1,\n                        }\n                      : { rotate: 0, scale: 1 }\n                  }\n                  transition={\n                    editing\n                      ? {\n                          rotate: jiggling\n                            ? {\n                                duration: 0.13 + seed * 0.05,\n                                ease: \"easeInOut\",\n                                repeat: Infinity,\n                                repeatType: \"mirror\",\n                              }\n                            : { duration: 0.15 },\n                          scale: { duration: 0.3, type: \"spring\" },\n                        }\n                      : { rotate: { duration: 0.15 } }\n                  }\n                  whileTap={editing ? undefined : { scale: 0.85 }}\n                  style={{ transformOrigin: \"center 60%\" }}\n                  className={editing ? \"cursor-grab active:cursor-grabbing\" : \"cursor-pointer\"}\n                >\n                  <AppIcon app={app} />\n                  <AnimatePresence>\n                    {editing && draggingId !== app.id && (\n                      <motion.button\n                        type=\"button\"\n                        key=\"remove\"\n                        aria-label={`Remove ${app.label}`}\n                        initial={{ opacity: 0, scale: 0 }}\n                        animate={{ opacity: 1, scale: 1 }}\n                        exit={{ opacity: 0, scale: 0 }}\n                        transition={{ bounce: 0.4, duration: 0.3, type: \"spring\" }}\n                        onClick={(event) => {\n                          event.stopPropagation();\n                          removeApp(app.id);\n                        }}\n                        onPointerDown={(event) => event.stopPropagation()}\n                        className=\"absolute -top-1.5 -left-1.5 grid size-5 place-items-center rounded-full bg-neutral-200/95 text-neutral-700 shadow-md\"\n                      >\n                        <Minus className=\"size-3.5\" strokeWidth={3} />\n                      </motion.button>\n                    )}\n                  </AnimatePresence>\n                </motion.div>\n                <span className=\"mt-1.5 text-[11px] font-medium text-white/90 drop-shadow-sm\">\n                  {app.label}\n                </span>\n              </motion.div>\n            );\n          })}\n        </AnimatePresence>\n      </div>\n\n      <div className=\"absolute inset-x-3 bottom-3\">\n        <div className=\"flex items-center justify-around rounded-[30px] bg-white/15 p-3 backdrop-blur-xl\">\n          {DOCK.map((app) => {\n            const seed = wobbleSeed(app.id);\n            return (\n              <motion.div\n                key={app.id}\n                animate={jiggling ? { rotate: [-(2 + seed), 2 + seed] } : { rotate: 0 }}\n                transition={\n                  jiggling\n                    ? {\n                        rotate: {\n                          duration: 0.13 + seed * 0.05,\n                          ease: \"easeInOut\",\n                          repeat: Infinity,\n                          repeatType: \"mirror\",\n                        },\n                      }\n                    : { rotate: { duration: 0.15 } }\n                }\n                whileTap={{ scale: 0.85 }}\n                style={{ transformOrigin: \"center 60%\" }}\n              >\n                <AppIcon app={app} />\n              </motion.div>\n            );\n          })}\n        </div>\n      </div>\n\n      <AnimatePresence>\n        {!editing && (\n          <motion.p\n            key=\"hint\"\n            initial={{ opacity: 0 }}\n            animate={{ opacity: 1, transition: { delay: 1 } }}\n            exit={{ opacity: 0 }}\n            className=\"pointer-events-none absolute inset-x-0 bottom-[108px] text-center text-[11px] text-white/35\"\n          >\n            Hold an icon to edit\n          </motion.p>\n        )}\n      </AnimatePresence>\n    </PhoneFrame>\n  );\n};\n","path":"/jiggle-mode.tsx","target":"uicapsule/jiggle-mode/jiggle-mode.tsx","type":"registry:file"},{"content":"import type { ReactNode } from \"react\";\n\n// Screen is 330×716 (iPhone 15 proportions); the clay shell adds a 16px lip on every side.\nexport const SCREEN_WIDTH = 330;\nexport const SCREEN_HEIGHT = 716;\n\ninterface Clay {\n  base: string;\n  deep: string;\n  light: string;\n  /** RGB triplet for inner shading, tinted so each tone reads as one material. */\n  rim: string;\n}\n\n// Dark matte clay: `light` is the lit top face, `base` the body, `deep` the shaded underside.\nconst TONES = {\n  espresso: { base: \"#3a2b24\", deep: \"#1f1612\", light: \"#5a4436\", rim: \"32 20 14\" },\n  graphite: { base: \"#303036\", deep: \"#18181c\", light: \"#4a4a52\", rim: \"10 10 14\" },\n  moss: { base: \"#2f3a2c\", deep: \"#171d15\", light: \"#4a5a44\", rim: \"12 18 10\" },\n  ochre: { base: \"#4a3c20\", deep: \"#261e0e\", light: \"#6e5a30\", rim: \"30 22 6\" },\n  plum: { base: \"#3c2a3a\", deep: \"#1e141d\", light: \"#5c4058\", rim: \"26 12 24\" },\n  slate: { base: \"#2a3448\", deep: \"#141a26\", light: \"#435270\", rim: \"10 16 30\" },\n  teal: { base: \"#233a3a\", deep: \"#101d1d\", light: \"#38585a\", rim: \"6 22 22\" },\n  terracotta: { base: \"#4a2c22\", deep: \"#26150f\", light: \"#6c4232\", rim: \"34 14 8\" },\n} satisfies Record<string, Clay>;\n\nexport type ClayTone = keyof typeof TONES;\n\nconst shellShadow = (rim: string) =>\n  [\n    \"0 60px 100px -40px rgb(0 0 0 / 0.85)\",\n    \"0 26px 40px -24px rgb(0 0 0 / 0.7)\",\n    \"inset 0 6px 6px rgb(255 255 255 / 0.16)\",\n    `inset 0 -14px 22px rgb(${rim} / 0.75)`,\n    \"inset 12px 0 18px -12px rgb(255 255 255 / 0.1)\",\n    `inset -12px 0 18px -12px rgb(${rim} / 0.6)`,\n  ].join(\", \");\n\nconst Nub = ({ className, clay }: { className: string; clay: Clay }) => (\n  <span\n    aria-hidden=\"true\"\n    className={`absolute w-[9px] rounded-full ${className}`}\n    style={{\n      background: clay.base,\n      boxShadow: `inset 2px 2px 2px rgb(255 255 255 / 0.14), inset -2px -2px 3px rgb(${clay.rim} / 0.7), 0 2px 4px rgb(0 0 0 / 0.5)`,\n    }}\n  />\n);\n\ninterface PhoneFrameProps {\n  children: ReactNode;\n  /** Classes for the screen: background, text color, pointer handling. */\n  className?: string;\n  /** Hide the Dynamic Island when the screen draws its own status area. */\n  island?: boolean;\n  tone?: ClayTone;\n}\n\nexport const PhoneFrame = ({\n  children,\n  className,\n  island = true,\n  tone = \"graphite\",\n}: PhoneFrameProps) => {\n  const clay = TONES[tone];\n  return (\n    <div\n      className=\"relative shrink-0 rounded-[66px] p-[16px]\"\n      style={{\n        background: `linear-gradient(165deg, ${clay.light} 0%, ${clay.base} 48%, ${clay.deep} 100%)`,\n        boxShadow: shellShadow(clay.rim),\n      }}\n    >\n      <Nub clay={clay} className=\"top-[132px] -left-[6px] h-[36px]\" />\n      <Nub clay={clay} className=\"top-[188px] -left-[6px] h-[62px]\" />\n      <Nub clay={clay} className=\"top-[262px] -left-[6px] h-[62px]\" />\n      <Nub clay={clay} className=\"top-[206px] -right-[6px] h-[92px]\" />\n      <div\n        className={`relative isolate overflow-hidden rounded-[50px] select-none ${className ?? \"bg-black\"}`}\n        style={{\n          boxShadow: `0 0 0 2px rgb(${clay.rim} / 0.9), 0 -2px 3px rgb(${clay.rim} / 0.8), 0 3px 4px rgb(255 255 255 / 0.12)`,\n          height: SCREEN_HEIGHT,\n          width: SCREEN_WIDTH,\n        }}\n      >\n        {children}\n        {island && (\n          <div\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute top-[11px] left-1/2 z-50 h-[30px] w-[98px] -translate-x-1/2 rounded-full bg-black\"\n          />\n        )}\n      </div>\n    </div>\n  );\n};\n","path":"/phone-frame.tsx","target":"uicapsule/jiggle-mode/phone-frame.tsx","type":"registry:file"},{"content":"\"use client\";\n\nimport { JiggleMode } from \"./jiggle-mode\";\n\nconst Preview = () => (\n  <main className=\"flex h-dvh items-center justify-center overflow-hidden bg-[radial-gradient(ellipse_60%_70%_at_50%_45%,#6a4514_0%,#3a250b_45%,#1a1005_80%,#0f0903_100%)] p-5\">\n    <JiggleMode />\n  </main>\n);\n\nexport default Preview;\n","path":"/preview.tsx","target":"uicapsule/jiggle-mode/preview.tsx","type":"registry:file"}],"homepage":"https://www.uicapsule.com/ui/jiggle-mode","name":"jiggle-mode","registryDependencies":[],"title":"Jiggle Mode","type":"registry:block"}