{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"morphing-action-dock","title":"Morphing Action Dock","author":"CoDesign-Spa27","description":"A floating action dock that merges into one surface and expands into contextual actions.","dependencies":["lucide-react","motion"],"files":[{"path":"components/ui-components/morphing-action-dock.tsx","content":"\"use client\";\n\nimport { Compass, Feather, FlameKindling, type LucideIcon, Moon } from \"lucide-react\";\nimport { AnimatePresence, LayoutGroup, MotionConfig, motion, useReducedMotion } from \"motion/react\";\nimport { useState } from \"react\";\n\ntype DockDirection = -1 | 0 | 1;\n\ntype DockItem = {\n  id: string;\n  label: string;\n  icon: LucideIcon;\n  tone: string;\n  eyebrow: string;\n  paragraph: string;\n  cue: string;\n};\n\ntype MorphingActionDockProps = {\n  items?: DockItem[];\n  className?: string;\n  defaultActiveId?: string;\n};\n\nconst DOCK_ITEMS: DockItem[] = [\n  {\n    id: \"signal\",\n    label: \"Signal\",\n    icon: Compass,\n    tone: \"from-zinc-900 via-zinc-800 to-zinc-700\",\n    eyebrow: \"Clear Direction\",\n    paragraph:\n      \"A good interface reduces the number of things a person has to keep in their head. It points gently, removes hesitation, and lets the next action feel obvious without being loud.\",\n    cue: \"Structure the next step\",\n  },\n  {\n    id: \"craft\",\n    label: \"Craft\",\n    icon: Feather,\n    tone: \"from-neutral-900 via-neutral-800 to-stone-700\",\n    eyebrow: \"Quiet Detail\",\n    paragraph:\n      \"The details that make software feel expensive are usually small: the right easing, a button that answers the finger, a surface that appears from where your eye already is.\",\n    cue: \"Tune the invisible parts\",\n  },\n  {\n    id: \"energy\",\n    label: \"Energy\",\n    icon: FlameKindling,\n    tone: \"from-slate-900 via-zinc-800 to-neutral-700\",\n    eyebrow: \"Motion With Intent\",\n    paragraph:\n      \"Animation should explain a state change, not decorate a delay. When motion has a job, it makes the product feel quicker, more spatial, and easier to trust.\",\n    cue: \"Use motion as feedback\",\n  },\n  {\n    id: \"calm\",\n    label: \"Calm\",\n    icon: Moon,\n    tone: \"from-black via-neutral-800 to-zinc-700\",\n    eyebrow: \"Lower The Noise\",\n    paragraph:\n      \"The strongest components know when to disappear. They hold their shape, avoid surprise, and give people a place to rest while they decide what to do next.\",\n    cue: \"Keep the surface steady\",\n  },\n];\n\nconst spring = {\n  type: \"spring\" as const,\n  duration: 0.46,\n  bounce: 0.16,\n};\n\nconst contentSpring = {\n  type: \"spring\" as const,\n  duration: 0.5,\n  bounce: 0,\n};\n\nconst fastOut = [0.4, 0, 0.2, 1] as const;\nconst bgShadow =\n  \"shadow-[0px_32px_64px_-16px_transparent,0px_16px_32px_-8px_transparent,0px_8px_16px_-4px_transparent,0px_4px_8px_-2px_transparent,0px_-8px_16px_-1px_transparent,0px_2px_4px_-1px_transparent,0px_0px_0px_1px_transparent,inset_0px_0px_0px_1px_rgba(0,0,0,0.1),inset_0px_1px_0px_rgb(0,0,0,0.15)] dark:bg-neutral-900 dark:shadow-[0px_32px_64px_-16px_transparent,0px_16px_32px_-8px_transparent,0px_8px_16px_-4px_transparent,0px_4px_8px_-2px_transparent,0px_-8px_16px_-1px_transparent,0px_2px_4px_-1px_transparent,0px_0px_0px_1px_transparent,inset_0px_0px_0px_1px_rgba(255,255,255,0.1),inset_0px_1px_0px_rgb(255,255,255,0.15)]\";\n\nconst contentVariants = {\n  enter: (direction: DockDirection) => ({\n    opacity: 0,\n    x: direction === 0 ? 0 : direction > 0 ? \"110%\" : \"-110%\",\n  }),\n  center: {\n    opacity: 1,\n    x: 0,\n  },\n  exit: (direction: DockDirection) => ({\n    opacity: 0,\n    x: direction === 0 ? 0 : direction > 0 ? \"-110%\" : \"110%\",\n  }),\n};\n\nfunction cn(...classes: Array<string | false | null | undefined>) {\n  return classes.filter(Boolean).join(\" \");\n}\n\nexport function MorphingActionDock({\n  items = DOCK_ITEMS,\n  className,\n  defaultActiveId = DOCK_ITEMS[0]?.id,\n}: MorphingActionDockProps) {\n  const shouldReduceMotion = useReducedMotion();\n  const [activeId, setActiveId] = useState(defaultActiveId);\n  const [pinnedId, setPinnedId] = useState<string | null>(null);\n  const [isDockOpen, setIsDockOpen] = useState(false);\n  const [direction, setDirection] = useState<DockDirection>(1);\n  const activeItem = items.find((item) => item.id === activeId) ?? items[0];\n  const activeIndex = Math.max(\n    0,\n    items.findIndex((item) => item.id === activeItem?.id),\n  );\n\n  function setActiveById(id: string, source: \"pointer\" | \"keyboard\" | \"press\") {\n    const nextIndex = items.findIndex((item) => item.id === id);\n\n    if (nextIndex === -1 || id === activeId) {\n      return;\n    }\n\n    if (source === \"keyboard\" || shouldReduceMotion) {\n      setDirection(0);\n    } else {\n      setDirection(nextIndex > activeIndex ? 1 : -1);\n    }\n\n    setActiveId(id);\n  }\n\n  function setPointerActive(id: string) {\n    setActiveById(id, \"pointer\");\n  }\n\n  function togglePinned(id: string) {\n    setActiveById(id, \"press\");\n    setPinnedId((current) => (current === id ? null : id));\n  }\n\n  function clearPointerActive() {\n    if (pinnedId) {\n      setActiveById(pinnedId, \"pointer\");\n      return;\n    }\n\n    setIsDockOpen(false);\n    setActiveById(defaultActiveId, \"pointer\");\n  }\n\n  if (!activeItem) {\n    return null;\n  }\n\n  const contentDirection = shouldReduceMotion ? 0 : direction;\n\n  return (\n    <MotionConfig transition={contentSpring}>\n      <LayoutGroup>\n        <div\n          className={cn(\"relative flex h-fit w-fit items-center justify-center\", className)}\n          onFocusCapture={() => setIsDockOpen(true)}\n          onBlurCapture={(event) => {\n            if (!event.currentTarget.contains(event.relatedTarget) && !pinnedId) {\n              clearPointerActive();\n            }\n          }}\n          onPointerEnter={() => setIsDockOpen(true)}\n          onPointerLeave={clearPointerActive}\n        >\n          <div className=\"relative flex flex-col items-center\">\n            <AnimatePresence initial={false}>\n              {isDockOpen || pinnedId ? (\n                <motion.div\n                  animate={{ opacity: 1, y: 0, scale: 1 }}\n                  className=\" absolute bottom-12 mb-3 overflow-hidden header-shadow rounded-lg text-foreground p-1.5\"\n                  exit={{\n                    opacity: 0,\n                    y: shouldReduceMotion ? 0 : 8,\n                    scale: shouldReduceMotion ? 1 : 0.98,\n                  }}\n                  initial={{\n                    opacity: 0,\n                    y: shouldReduceMotion ? 0 : 8,\n                    scale: shouldReduceMotion ? 1 : 0.98,\n                  }}\n                  layout\n                  layoutId=\"morphing-action-dock-tray\"\n                  style={{ transformOrigin: \"50% 100%\" }}\n                  transition={\n                    shouldReduceMotion\n                      ? { duration: 0.16, ease: fastOut }\n                      : {\n                          opacity: { duration: 0.18, ease: fastOut },\n                          y: spring,\n                          scale: spring,\n                          layout: spring,\n                        }\n                  }\n                >\n                  <motion.div\n                    aria-hidden=\"true\"\n                    className={cn(\"absolute inset-0 rounded-lg bg-background opacity-18 blur-xl\")}\n                    layoutId=\"morphing-action-dock-glow\"\n                  />\n                  <AnimatePresence custom={contentDirection} initial={false} mode=\"popLayout\">\n                    <motion.div\n                      animate=\"center\"\n                      className=\"relative w-80 p-3 will-change-transform\"\n                      custom={contentDirection}\n                      exit=\"exit\"\n                      initial=\"enter\"\n                      key={activeItem.id}\n                      transition={\n                        shouldReduceMotion ? { duration: 0.12, ease: fastOut } : contentSpring\n                      }\n                      variants={contentVariants}\n                    >\n                      <motion.p\n                        animate={{ opacity: 1, y: 0 }}\n                        className=\"text-balance text-sm leading-6 text-foreground/75\"\n                        initial={{\n                          opacity: 0,\n                          y: shouldReduceMotion ? 0 : 5,\n                        }}\n                        transition={{\n                          duration: shouldReduceMotion ? 0.12 : 0.17,\n                          delay: shouldReduceMotion ? 0 : 0.025,\n                          ease: fastOut,\n                        }}\n                      >\n                        {activeItem.paragraph}\n                      </motion.p>\n                    </motion.div>\n                  </AnimatePresence>\n                </motion.div>\n              ) : null}\n            </AnimatePresence>\n\n            <div\n              className={cn(\n                \"relative rounded-xl border border-border/70  p-1 shadow-[0_24px_80px_-48px_rgb(0_0_0/0.9),inset_0_1px_0_rgb(255_255_255/0.08)] backdrop-blur-xl\",\n              )}\n            >\n              <motion.div\n                aria-hidden=\"true\"\n                animate={{\n                  transform: `translate3d(${activeIndex * 42}px, 0, 0)`,\n                }}\n                className={cn(\n                  \"absolute top-1 bottom-1 rounded-lg  shadow-[0_12px_36px_-18px_rgb(0_0_0/0.8)]\",\n                  bgShadow,\n                )}\n                style={{ left: 4, width: 38, willChange: \"transform\" }}\n                transition={shouldReduceMotion ? { duration: 0 } : spring}\n              />\n              <div className=\"relative flex items-center gap-1\">\n                {items.map((item) => {\n                  const Icon = item.icon;\n                  const isActive = item.id === activeItem.id;\n                  const isItemPinned = pinnedId === item.id;\n\n                  return (\n                    <button\n                      aria-label={`Show ${item.label} paragraph${isItemPinned ? \", pinned\" : \"\"}`}\n                      aria-pressed={isItemPinned}\n                      className={cn(\n                        \"relative z-10 flex size-[38px] items-center justify-center rounded-lg outline-none transition-[color,transform,opacity] duration-[160ms] ease-[cubic-bezier(0.23,1,0.32,1)] active:scale-[0.97] focus-visible:ring-0 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background border border-border/50\",\n                        isActive\n                          ? `text-foreground `\n                          : \"text-muted-foreground hover:text-foreground\",\n                      )}\n                      key={item.id}\n                      onClick={() => togglePinned(item.id)}\n                      onFocus={() => setActiveById(item.id, \"keyboard\")}\n                      onPointerEnter={(event) => {\n                        if (event.pointerType !== \"touch\") {\n                          setPointerActive(item.id);\n                        }\n                      }}\n                      type=\"button\"\n                    >\n                      <Icon className=\"size-4\" strokeWidth={2.25} />\n                      <span className=\"sr-only\">{item.label}</span>\n                      {isItemPinned ? (\n                        <span className=\"absolute bottom-1 size-1 rounded-full bg-background/80\" />\n                      ) : null}\n                    </button>\n                  );\n                })}\n              </div>\n            </div>\n          </div>\n        </div>\n      </LayoutGroup>\n    </MotionConfig>\n  );\n}\n\nexport default MorphingActionDock;\n","type":"registry:ui"}],"categories":["dock","actions"],"type":"registry:ui"}