{
  "name": "theme-button-default",
  "type": "registry:block",
  "tier": "free",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.craftui.space/r/craftui-base.json"
  ],
  "files": [
    {
      "path": "components/craftui/blocks/theme-button/default.tsx",
      "content": "\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport React from \"react\";\n\nexport interface ThemeOption {\n  id: string;\n  label: string;\n  /** 1–3 hex colors — the first is the collapsed swatch, all render as a stack. */\n  dots: string[];\n}\n\nexport interface FontOption {\n  id: string;\n  label: string;\n  /** A CSS font-family stack; used both to preview the glyph and as the value. */\n  stack: string;\n}\n\nexport interface ThemeButtonProps {\n  /** Theme list, or a function returning it. Falls back to the built-in set. */\n  themes?: ThemeOption[] | (() => ThemeOption[]);\n  /** Font list, or a function returning it. Falls back to the built-in set. */\n  fonts?: FontOption[] | (() => FontOption[]);\n\n  /** Uncontrolled initial selection (by id). */\n  defaultThemeId?: string;\n  defaultFontId?: string;\n\n  /** Controlled selection (by id). When set, that dimension is controlled. */\n  themeId?: string;\n  fontId?: string;\n\n  /** Fires with the full option after a pick. Do whatever you want with it. */\n  onThemeChange?: (theme: ThemeOption) => void;\n  onFontChange?: (font: FontOption) => void;\n  /** Fires when the menu opens (\"font\" | \"theme\") or closes (null). */\n  onOpenChange?: (mode: Mode | null) => void;\n\n  /** Label next to the spinner. Default \"Process\". */\n  processLabel?: string;\n  /** Hide the spinner + label segment entirely. Default true. */\n  showProcess?: boolean;\n  /** Extra classes merged onto the pill shell. */\n  className?: string;\n}\n\nconst DEFAULT_THEMES: ThemeOption[] = [\n  { id: \"brand\", label: \"Brand\", dots: [\"#2f6bff\", \"#4f8bff\", \"#1f4fd8\"] },\n  { id: \"indigo\", label: \"Indigo\", dots: [\"#4f46e5\", \"#6366f1\", \"#3730a3\"] },\n  { id: \"yellow\", label: \"Yellow\", dots: [\"#eab308\", \"#facc15\", \"#ca8a04\"] },\n  { id: \"pink\", label: \"Pink\", dots: [\"#ec4899\", \"#f472b6\", \"#db2777\"] },\n  { id: \"rose\", label: \"Rose\", dots: [\"#f43f5e\", \"#fb7185\", \"#e11d48\"] },\n];\n\nconst DEFAULT_FONTS: FontOption[] = [\n  {\n    id: \"geist\",\n    label: \"Geist\",\n    stack: \"'Geist', ui-sans-serif, system-ui, sans-serif\",\n  },\n  {\n    id: \"inter\",\n    label: \"Inter\",\n    stack: \"'Inter', ui-sans-serif, system-ui, sans-serif\",\n  },\n  {\n    id: \"grotesk\",\n    label: \"Space Grotesk\",\n    stack: \"'Space Grotesk', ui-sans-serif, sans-serif\",\n  },\n  {\n    id: \"playfair\",\n    label: \"Playfair\",\n    stack: \"'Playfair Display', ui-serif, Georgia, serif\",\n  },\n  {\n    id: \"merriweather\",\n    label: \"Merriweather\",\n    stack: \"'Merriweather', ui-serif, Georgia, serif\",\n  },\n];\n\n// ease-out-cubic: content is entering/exiting, so it should feel instant then settle\nconst EASE_OUT = [0.215, 0.61, 0.355, 1] as const;\n\n// Apple-style spring config: perceptual duration + bounce, rather than mass/stiffness/damping.\n// The shell grows (bigger element, longer travel) so expand is slower; collapse is ~30% faster.\nconst EXPAND_SPRING = { type: \"spring\", duration: 0.4, bounce: 0.16 } as const;\nconst COLLAPSE_SPRING = { type: \"spring\", duration: 0.28, bounce: 0 } as const;\n\n// Content crossfade: enter reads at full length, exit is ~25% faster so it clears the morph.\nconst CONTENT_IN = { duration: 0.2, ease: EASE_OUT } as const;\nconst CONTENT_OUT = { duration: 0.15, ease: EASE_OUT } as const;\n\nconst SHELL_ID = \"theme-morph-shell\";\n\nexport type Mode = \"font\" | \"theme\";\n\nfunction resolveList<T>(\n  input: T[] | (() => T[]) | undefined,\n  fallback: T[],\n): T[] {\n  if (!input) return fallback;\n  const list = typeof input === \"function\" ? input() : input;\n  return list.length > 0 ? list : fallback;\n}\n\nfunction Dots({ colors, size = 18 }: { colors: string[]; size?: number }) {\n  return (\n    <span className=\"flex items-center\" aria-hidden=\"true\">\n      {colors.map((c, i) => (\n        <span\n          key={c + i}\n          // ring color tracks the popover surface so the dots read on both themes\n          className=\"rounded-full shadow-[0_0_0_2px_var(--popover)]\"\n          style={{\n            width: size,\n            height: size,\n            background: c,\n            marginLeft: i === 0 ? 0 : -size * 0.28,\n          }}\n        />\n      ))}\n    </span>\n  );\n}\n\n// Font swatch — the \"Ag\" is rendered in the option's own family so the glyph\n// previews the typeface, mirroring how Dots previews a color.\nfunction FontGlyph({ stack }: { stack: string }) {\n  return (\n    <span\n      aria-hidden=\"true\"\n      className=\"flex h-[26px] w-[26px] items-center justify-center rounded-lg border border-border text-[13px] leading-none\"\n      style={{ fontFamily: stack }}\n    >\n      Ag\n    </span>\n  );\n}\n\n// ── Inlined iOS \"pulse\" spinner (full loader code, no external import) ────────\n// Copied in verbatim so the block is self-contained on download.\nfunction IosSpinnerPulse({\n  width = 18,\n  height = 18,\n  color = \"currentColor\",\n  isAnimating = true,\n  duration = 1.0,\n  ease = \"easeOut\",\n}: {\n  width?: number;\n  height?: number;\n  color?: string;\n  isAnimating?: boolean;\n  duration?: number;\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  ease?: any;\n}) {\n  const count = 12;\n  return (\n    <svg\n      width={width}\n      height={height}\n      viewBox=\"0 0 100 100\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      aria-label=\"Loading\"\n      role=\"img\"\n    >\n      {Array.from({ length: count }).map((_, i) => (\n        <motion.rect\n          key={i}\n          x={48.5}\n          y={26}\n          width={3}\n          height={10}\n          rx={1.5}\n          fill={color}\n          transform={`rotate(${(360 / count) * i}, 50, 50)`}\n          initial={{ opacity: 0.1 }}\n          animate={{ opacity: isAnimating ? [0.1, 1, 0.35, 0.1] : 0.35 }}\n          transition={{\n            duration,\n            repeat: isAnimating ? Infinity : 0,\n            delay: i * (duration / count),\n            ease,\n            times: [0, 0.08, 0.3, 1],\n          }}\n        />\n      ))}\n    </svg>\n  );\n}\n\nfunction ThemeButton({\n  themes: themesProp,\n  fonts: fontsProp,\n  defaultThemeId,\n  defaultFontId,\n  themeId,\n  fontId,\n  onThemeChange,\n  onFontChange,\n  onOpenChange,\n  processLabel = \"Process\",\n  showProcess = true,\n  className = \"\",\n}: ThemeButtonProps = {}) {\n  const themes = React.useMemo(\n    () => resolveList(themesProp, DEFAULT_THEMES),\n    [themesProp],\n  );\n  const fonts = React.useMemo(\n    () => resolveList(fontsProp, DEFAULT_FONTS),\n    [fontsProp],\n  );\n\n  const [expanded, setExpanded] = React.useState<Mode | null>(null);\n  const [activeIndex, setActiveIndex] = React.useState(0);\n  const reduceMotion = useReducedMotion();\n\n  const menuRef = React.useRef<HTMLDivElement>(null);\n  const itemRefs = React.useRef<(HTMLButtonElement | null)[]>([]);\n  const fontTriggerRef = React.useRef<HTMLButtonElement>(null);\n  const themeTriggerRef = React.useRef<HTMLButtonElement>(null);\n  // Which trigger to return focus to when the menu closes (null = don't steal\n  // focus, e.g. on outside-pointer dismissal where focus already moved).\n  const restoreFocusRef = React.useRef<Mode | null>(null);\n\n  // Per-dimension controllable state: controlled when the *Id prop is passed,\n  // otherwise uncontrolled starting from default*Id (or the first option).\n  const isThemeControlled = themeId !== undefined;\n  const isFontControlled = fontId !== undefined;\n  const [themeIdState, setThemeIdState] = React.useState(\n    defaultThemeId ?? themes[0]?.id,\n  );\n  const [fontIdState, setFontIdState] = React.useState(\n    defaultFontId ?? fonts[0]?.id,\n  );\n\n  const currentThemeId = isThemeControlled ? themeId : themeIdState;\n  const currentFontId = isFontControlled ? fontId : fontIdState;\n  const selectedTheme =\n    themes.find((t) => t.id === currentThemeId) ?? themes[0];\n  const selectedFont = fonts.find((f) => f.id === currentFontId) ?? fonts[0];\n\n  // The list currently shown in the open menu.\n  const items = expanded === \"theme\" ? themes : fonts;\n\n  const openMenu = (mode: Mode) => {\n    // Seed the roving index at the current selection so focus lands on it.\n    const idx =\n      mode === \"theme\"\n        ? themes.findIndex((t) => t.id === currentThemeId)\n        : fonts.findIndex((f) => f.id === currentFontId);\n    setActiveIndex(idx >= 0 ? idx : 0);\n    restoreFocusRef.current = mode; // return focus here on close\n    setExpanded(mode);\n    onOpenChange?.(mode);\n  };\n\n  const closeMenu = (restore: boolean) => {\n    if (!restore) restoreFocusRef.current = null;\n    setExpanded(null);\n    onOpenChange?.(null);\n  };\n\n  const selectTheme = (theme: ThemeOption) => {\n    if (!isThemeControlled) setThemeIdState(theme.id);\n    onThemeChange?.(theme);\n    closeMenu(true);\n  };\n\n  const selectFont = (font: FontOption) => {\n    if (!isFontControlled) setFontIdState(font.id);\n    onFontChange?.(font);\n    closeMenu(true);\n  };\n\n  const isExpanded = expanded !== null;\n\n  // Dismiss on outside click (no focus restore) or Escape (restore to trigger).\n  // Listeners attach only while open, after the opening click has resolved.\n  React.useEffect(() => {\n    if (!isExpanded) return;\n    const onPointerDown = (e: PointerEvent) => {\n      if (menuRef.current && !menuRef.current.contains(e.target as Node)) {\n        closeMenu(false);\n      }\n    };\n    const onKeyDown = (e: KeyboardEvent) => {\n      if (e.key === \"Escape\") {\n        e.preventDefault();\n        closeMenu(true);\n      }\n    };\n    document.addEventListener(\"pointerdown\", onPointerDown);\n    document.addEventListener(\"keydown\", onKeyDown);\n    return () => {\n      document.removeEventListener(\"pointerdown\", onPointerDown);\n      document.removeEventListener(\"keydown\", onKeyDown);\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [isExpanded]);\n\n  // On open: move focus into the menu (onto the active item). APG menu pattern.\n  React.useEffect(() => {\n    if (expanded === null) return;\n    const raf = requestAnimationFrame(() => {\n      itemRefs.current[activeIndex]?.focus();\n    });\n    return () => cancelAnimationFrame(raf);\n    // Runs when the menu opens; activeIndex is already seeded by openMenu.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [expanded]);\n\n  // On close: return focus to the trigger that opened the menu (Escape / select),\n  // unless dismissal explicitly opted out (outside pointer).\n  React.useEffect(() => {\n    if (expanded !== null) return;\n    const mode = restoreFocusRef.current;\n    if (!mode) return;\n    restoreFocusRef.current = null;\n    const raf = requestAnimationFrame(() => {\n      (mode === \"font\" ? fontTriggerRef : themeTriggerRef).current?.focus();\n    });\n    return () => cancelAnimationFrame(raf);\n  }, [expanded]);\n\n  // Roving arrow-key navigation inside the open menu.\n  const onMenuKeyDown = (e: React.KeyboardEvent) => {\n    const count = items.length;\n    if (count === 0) return;\n    let next = activeIndex;\n    switch (e.key) {\n      case \"ArrowDown\":\n        next = (activeIndex + 1) % count;\n        break;\n      case \"ArrowUp\":\n        next = (activeIndex - 1 + count) % count;\n        break;\n      case \"Home\":\n        next = 0;\n        break;\n      case \"End\":\n        next = count - 1;\n        break;\n      default:\n        return; // Enter/Space handled by the focused <button> natively\n    }\n    e.preventDefault();\n    setActiveIndex(next);\n    itemRefs.current[next]?.focus();\n  };\n\n  const shellTransition = reduceMotion\n    ? { duration: 0 }\n    : isExpanded\n      ? EXPAND_SPRING\n      : COLLAPSE_SPRING;\n  const contentIn = reduceMotion ? { duration: 0 } : CONTENT_IN;\n  const contentOut = reduceMotion ? { duration: 0 } : CONTENT_OUT;\n\n  return (\n    <>\n      {/* ---------- COLLAPSED ---------- */}\n      <AnimatePresence mode=\"popLayout\">\n        {!isExpanded && (\n          <motion.div\n            key=\"collapsed\"\n            layoutId={SHELL_ID}\n            transition={shellTransition}\n            // No overflow-hidden here: at rest nothing overflows the pill, and\n            // it would clip the focus ring on the edge-filling triggers.\n            className={`flex select-none items-center gap-5 rounded-[34px] overflow-hidden border border-border bg-popover text-popover-foreground shadow-[0_18px_40px_-18px_rgba(0,0,0,0.6)] ${className}`}\n            // morph-driven values stay inline so layoutId can interpolate them\n            style={{ borderRadius: 34, padding: \"16px 26px\" }}\n          >\n            <motion.div\n              layout=\"position\"\n              initial={{ opacity: 0, filter: \"blur(4px)\" }}\n              animate={{ opacity: 1, filter: \"blur(0px)\" }}\n              exit={{ opacity: 0, filter: \"blur(4px)\", transition: contentOut }}\n              transition={contentIn}\n              className=\"flex items-center gap-5 text-base\"\n            >\n              {/* Font segment — opens the typeface picker. The -my-4/py-4 +\n                  -mx-2/px-2 grow the hit area to fill the segment (full pill\n                  height) without shifting the visible layout. */}\n              <button\n                ref={fontTriggerRef}\n                onClick={() => openMenu(\"font\")}\n                aria-haspopup=\"menu\"\n                aria-expanded={expanded === \"font\"}\n                aria-label={`Font: ${selectedFont?.label}`}\n                translate=\"no\"\n                className=\"-mx-2 -my-4 cursor-pointer touch-manipulation rounded-full px-2 py-4 transition-opacity hover:opacity-70\"\n                style={{ fontFamily: selectedFont?.stack }}\n              >\n                {selectedFont?.label}\n              </button>\n              {showProcess && (\n                <>\n                  <Divider />\n                  <span className=\"flex items-center gap-1\">\n                    <span aria-hidden=\"true\" className=\"inline-flex\">\n                      <IosSpinnerPulse\n                        isAnimating={!reduceMotion}\n                        width={40}\n                        height={40}\n                      />\n                    </span>\n                    <span>{processLabel}</span>\n                  </span>\n                </>\n              )}\n              <Divider />\n              {/* Theme segment — opens the color picker (same enlarged hit area) */}\n              <button\n                ref={themeTriggerRef}\n                onClick={() => openMenu(\"theme\")}\n                aria-haspopup=\"menu\"\n                aria-expanded={expanded === \"theme\"}\n                aria-label={`Theme: ${selectedTheme?.label}`}\n                className=\"-mx-2 -my-4 flex cursor-pointer touch-manipulation items-center gap-3 rounded-full px-2 py-4 transition-opacity hover:opacity-70\"\n              >\n                <span\n                  aria-hidden=\"true\"\n                  className=\"h-4 w-4 rounded-full\"\n                  style={{ background: selectedTheme?.dots[0] }}\n                />\n                <span>{selectedTheme?.label}</span>\n              </button>\n            </motion.div>\n          </motion.div>\n        )}\n      </AnimatePresence>\n\n      {/* ---------- EXPANDED ---------- */}\n      <AnimatePresence mode=\"popLayout\">\n        {isExpanded && (\n          <motion.div\n            key=\"expanded\"\n            ref={menuRef}\n            role=\"menu\"\n            aria-label={\n              expanded === \"theme\" ? \"Select a theme\" : \"Select a font\"\n            }\n            aria-orientation=\"vertical\"\n            onKeyDown={onMenuKeyDown}\n            layoutId={SHELL_ID}\n            transition={shellTransition}\n            className={`flex flex-col gap-0.5 overflow-hidden rounded-[34px] border border-border bg-popover text-popover-foreground shadow-[0_30px_60px_-20px_rgba(0,0,0,0.65)] ${className}`}\n            // morph-driven values stay inline so layoutId can interpolate them\n            style={{ borderRadius: 34, padding: 14, width: 300 }}\n          >\n            <motion.div\n              layout=\"position\"\n              initial={{ opacity: 0, filter: \"blur(6px)\", scale: 0.96 }}\n              animate={{ opacity: 1, filter: \"blur(0px)\", scale: 1 }}\n              exit={{\n                opacity: 0,\n                filter: \"blur(6px)\",\n                scale: 0.96,\n                transition: contentOut,\n              }}\n              transition={contentIn}\n              className=\"flex flex-col gap-0.5\"\n            >\n              {expanded === \"theme\"\n                ? themes.map((theme, i) => {\n                    const isActive = theme.id === selectedTheme?.id;\n                    return (\n                      <button\n                        key={theme.id}\n                        ref={(el) => {\n                          itemRefs.current[i] = el;\n                        }}\n                        role=\"menuitemradio\"\n                        aria-checked={isActive}\n                        tabIndex={i === activeIndex ? 0 : -1}\n                        onClick={() => selectTheme(theme)}\n                        className={`flex w-full touch-manipulation items-center gap-4 rounded-[20px] px-3.5 py-3 text-left transition-colors ${\n                          isActive\n                            ? \"\"\n                            : \"border border-transparent hover:bg-accent/50\"\n                        }`}\n                      >\n                        <Dots colors={theme.dots} />\n                        <span className=\"flex-1 text-[17px]\" translate=\"no\">\n                          {theme.label}\n                        </span>\n                        {isActive && (\n                          <span\n                            aria-hidden=\"true\"\n                            className=\"h-2.5 w-2.5 rounded-full\"\n                            style={{ background: theme.dots[0] }}\n                          />\n                        )}\n                      </button>\n                    );\n                  })\n                : fonts.map((font, i) => {\n                    const isActive = font.id === selectedFont?.id;\n                    return (\n                      <button\n                        key={font.id}\n                        ref={(el) => {\n                          itemRefs.current[i] = el;\n                        }}\n                        role=\"menuitemradio\"\n                        aria-checked={isActive}\n                        tabIndex={i === activeIndex ? 0 : -1}\n                        onClick={() => selectFont(font)}\n                        className={`flex w-full touch-manipulation items-center gap-4 rounded-[20px] px-3.5 py-2.5 text-left transition-colors ${\n                          isActive\n                            ? \"\"\n                            : \"border border-transparent hover:bg-accent/50\"\n                        }`}\n                      >\n                        <FontGlyph stack={font.stack} />\n                        <span\n                          className=\"flex-1 text-[16px]\"\n                          style={{ fontFamily: font.stack }}\n                          translate=\"no\"\n                        >\n                          {font.label}\n                        </span>\n                        {isActive && (\n                          <span\n                            aria-hidden=\"true\"\n                            className=\"h-2.5 w-2.5 rounded-full bg-primary\"\n                          />\n                        )}\n                      </button>\n                    );\n                  })}\n            </motion.div>\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </>\n  );\n}\n\nfunction Divider() {\n  return (\n    <span\n      aria-hidden=\"true\"\n      className=\"h-[26px] w-0.5 rounded-full\"\n      style={{\n        backgroundImage:\n          \"repeating-linear-gradient(var(--border) 0 2px, transparent 2px 6px)\",\n      }}\n    />\n  );\n}\n\nexport default ThemeButton;\n",
      "type": "registry:component",
      "target": "~/components/craftui/blocks/theme-button/default.tsx"
    }
  ],
  "meta": {
    "displayName": "Theme Button - Theme Button",
    "description": "Collapsed status pill morphs into an expanded font or theme list via a shared layoutId spring, with a blur cross-fade on the inner content. Themes/fonts, selection, and change callbacks are all props. Includes an inlined iOS pulse spinner so the block ships self-contained.",
    "category": "morphing-buttons",
    "tags": [
      "morph",
      "button",
      "theme",
      "font",
      "menu",
      "interactive",
      "click",
      "spring",
      "layout"
    ],
    "animationType": "click"
  }
}