{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "morph-loader",
  "type": "registry:component",
  "title": "Morph Loader",
  "description": "A glossy gradient shape that smoothly morphs between circle, square and triangle, spinning and squashing slightly on each change.",
  "author": "Marcelo Treviño Velázquez <https://furin.dev>",
  "dependencies": [],
  "categories": [
    "Loaders"
  ],
  "docs": "Installed in components/furin/morph-loader/. Docs, live controls and props table: https://furin.dev/components/morph-loader/",
  "files": [
    {
      "path": "registry/morph-loader/MorphLoader.tsx",
      "content": "\"use client\";\nimport { useEffect, useId, useMemo, useRef } from \"react\";\nimport type { CSSProperties } from \"react\";\nimport \"./MorphLoader.css\";\n\nexport type MorphShape = \"circle\" | \"square\" | \"triangle\" | \"pentagon\" | \"hexagon\" | \"diamond\";\n\nexport interface MorphLoaderProps {\n  /** Lado del loader, en px. */\n  size?: number;\n  /** Secuencia de figuras por las que muta, en orden. */\n  shapes?: MorphShape[];\n  /** Color del degradado en la parte de abajo de la figura. */\n  from?: string;\n  /** Color del degradado en la parte de arriba de la figura. */\n  to?: string;\n  /** Segundos que se queda quieta cada figura antes de mutar. */\n  hold?: number;\n  /** Segundos que dura la transición entre figuras. */\n  duration?: number;\n  /** Gira la figura mientras muta. */\n  spin?: boolean;\n  /** Texto oculto que leen los lectores de pantalla. */\n  label?: string;\n  /** Clase extra para el contenedor. */\n  className?: string;\n  /** Estilos extra para el contenedor. */\n  style?: CSSProperties;\n}\n\nconst N = 144; // puntos del contorno: bastan para que el círculo se vea liso\n// Escala de cada figura para que pesen parecido a la vista (el triángulo necesita más radio que el círculo).\nconst ESCALA: Record<MorphShape, number> = { circle: 1, square: 1.12, triangle: 1.34, pentagon: 1.06, hexagon: 1.04, diamond: 1.22 };\nconst LADOS: Record<MorphShape, number> = { circle: 0, square: 4, triangle: 3, pentagon: 5, hexagon: 6, diamond: 4 };\nconst ROT0: Record<MorphShape, number> = { circle: 0, square: Math.PI / 4, triangle: -Math.PI / 2, pentagon: -Math.PI / 2, hexagon: 0, diamond: -Math.PI / 2 };\n\n// Radio del contorno en cada ángulo: círculo = constante; polígono = distancia al lado más cercano. Luego se suavizan las esquinas.\nfunction contorno(f: MorphShape): number[] {\n  const m = LADOS[f];\n  const raw = Array.from({ length: N }, (_, i) => {\n    if (!m) return 1;\n    const a = (i / N) * Math.PI * 2 - ROT0[f];\n    const sector = (Math.PI * 2) / m;\n    const loc = ((a % sector) + sector) % sector - sector / 2;\n    return Math.cos(Math.PI / m) / Math.cos(loc);\n  });\n  const suave = raw.slice();\n  const r = m ? 4 : 0;\n  for (let pasada = 0; pasada < 2; pasada++) {\n    const base = suave.slice();\n    for (let i = 0; i < N; i++) {\n      let acc = 0, peso = 0;\n      for (let k = -r; k <= r; k++) { const w = r + 1 - Math.abs(k); acc += base[(i + k + N) % N] * w; peso += w; }\n      suave[i] = acc / peso;\n    }\n  }\n  return suave.map((v) => v * ESCALA[f]);\n}\n\nconst ease = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);\n\n/** Loader que muta: una figura de degradado y brillo pasa suavemente de círculo a cuadrado y a triángulo (o las figuras que elijas), girando y aplastándose un poco en cada cambio. */\nexport function MorphLoader({\n  size = 140,\n  shapes = [\"circle\", \"square\", \"triangle\"],\n  from = \"#6a5cff\",\n  to = \"#4de6d0\",\n  hold = 0.55,\n  duration = 0.95,\n  spin = true,\n  label = \"Loading\",\n  className = \"\",\n  style,\n}: MorphLoaderProps) {\n  const uid = useId().replace(/[^a-zA-Z0-9]/g, \"\");\n  const secuencia = useMemo(() => (shapes.length ? shapes : ([\"circle\"] as MorphShape[])), [shapes]);\n  const raiz = useRef<HTMLDivElement>(null);\n  const grupo = useRef<SVGGElement>(null);\n  const trazo = useRef<SVGPathElement>(null);\n  const brillo = useRef<SVGPathElement>(null);\n  const sombra = useRef<SVGEllipseElement>(null);\n\n  useEffect(() => {\n    const el = raiz.current;\n    const g = grupo.current;\n    const camino = trazo.current;\n    if (!el || !g || !camino) return;\n    const formas = secuencia.map(contorno);\n    const R = 50 * 0.62; // en el lienzo de 100: deja lugar al triángulo y a la sombra\n    const construir = (rad: number[], giro: number) => {\n      let d = \"\";\n      for (let i = 0; i < N; i++) {\n        const a = (i / N) * Math.PI * 2 - Math.PI / 2 + giro;\n        d += `${i ? \"L\" : \"M\"}${(Math.cos(a) * rad[i] * R).toFixed(2)} ${(Math.sin(a) * rad[i] * R).toFixed(2)}`;\n      }\n      return d + \"Z\";\n    };\n    const quieto = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    const poner = (rad: number[], giro: number, aplast: number) => {\n      const d = construir(rad, (giro * Math.PI) / 180);\n      camino.setAttribute(\"d\", d);\n      brillo.current?.setAttribute(\"d\", d);\n      g.setAttribute(\"transform\", `translate(50 46) scale(${(1 + 0.05 * aplast).toFixed(3)} ${(1 - 0.12 * aplast).toFixed(3)})`);\n      sombra.current?.setAttribute(\"rx\", (R * (0.85 - 0.12 * aplast)).toFixed(2));\n      sombra.current?.setAttribute(\"opacity\", (0.55 - 0.15 * aplast).toFixed(3));\n    };\n    if (quieto || formas.length < 2) { poner(formas[Math.min(1, formas.length - 1)], 0, 0); return; }\n\n    let raf = 0;\n    let visible = true;\n    const t0 = performance.now();\n    const ciclo = hold + duration;\n    const paso = (ahora: number) => {\n      // El primer cuadro puede traer una marca de tiempo anterior a t0: sin el tope, k saldría -1 y no habría figura.\n      const t = Math.max(0, (ahora - t0) / 1000);\n      const k = Math.floor(t / ciclo);\n      const loc = t - k * ciclo;\n      const a = formas[k % formas.length], b = formas[(k + 1) % formas.length];\n      const p = loc < hold ? 0 : ease((loc - hold) / duration);\n      const rad = a.map((v, i) => v + (b[i] - v) * p);\n      // Aplasta un poco a la mitad del cambio, como si cediera y se recuperara.\n      const aplast = Math.sin(Math.PI * p);\n      poner(rad, spin ? (k + p) * 120 : 0, aplast);\n      raf = visible ? requestAnimationFrame(paso) : 0;\n    };\n    raf = requestAnimationFrame(paso);\n    const io = new IntersectionObserver(([e]) => {\n      visible = e.isIntersecting;\n      if (visible && !raf) raf = requestAnimationFrame(paso);\n    });\n    io.observe(el);\n    return () => { io.disconnect(); cancelAnimationFrame(raf); };\n  }, [secuencia, hold, duration, spin]);\n\n  const vars = { \"--furin-ml-size\": `${size}px`, \"--furin-ml-from\": from, \"--furin-ml-to\": to } as CSSProperties;\n  return (\n    <div ref={raiz} className={`furin-morph-loader ${className}`} style={{ ...vars, ...style }} role=\"status\">\n      <svg viewBox=\"0 0 100 100\" className=\"furin-morph-loader__svg\" aria-hidden=\"true\">\n        <defs>\n          <linearGradient id={`${uid}f`} x1=\"0\" y1=\"1\" x2=\"0.6\" y2=\"0\">\n            <stop offset=\"0\" stopColor={from} />\n            <stop offset=\"1\" stopColor={to} />\n          </linearGradient>\n          <radialGradient id={`${uid}s`} cx=\"0.34\" cy=\"0.22\" r=\"0.7\">\n            <stop offset=\"0\" stopColor=\"#fff\" stopOpacity=\"0.55\" />\n            <stop offset=\"0.45\" stopColor=\"#fff\" stopOpacity=\"0.08\" />\n            <stop offset=\"1\" stopColor=\"#fff\" stopOpacity=\"0\" />\n          </radialGradient>\n          <linearGradient id={`${uid}b`} x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n            <stop offset=\"0\" stopColor=\"#fff\" stopOpacity=\"0.55\" />\n            <stop offset=\"0.5\" stopColor=\"#fff\" stopOpacity=\"0.05\" />\n            <stop offset=\"1\" stopColor=\"#fff\" stopOpacity=\"0.25\" />\n          </linearGradient>\n          <filter id={`${uid}g`} x=\"-50%\" y=\"-50%\" width=\"200%\" height=\"200%\"><feGaussianBlur stdDeviation=\"3.2\" /></filter>\n        </defs>\n        <ellipse ref={sombra} cx=\"50\" cy=\"92\" rx=\"26\" ry=\"3.6\" fill=\"#000\" filter={`url(#${uid}g)`} opacity=\"0.55\" />\n        <g ref={grupo} transform=\"translate(50 46)\">\n          <path ref={trazo} d=\"\" fill={`url(#${uid}f)`} stroke={`url(#${uid}b)`} strokeWidth=\"0.9\" strokeLinejoin=\"round\" />\n          <path ref={brillo} d=\"\" fill={`url(#${uid}s)`} />\n        </g>\n      </svg>\n      <span className=\"furin-morph-loader__sr\">{label}…</span>\n    </div>\n  );\n}\n\nexport default MorphLoader;\n",
      "type": "registry:component",
      "target": "components/furin/morph-loader/MorphLoader.tsx"
    },
    {
      "path": "registry/morph-loader/MorphLoader.css",
      "content": ".furin-morph-loader {\n  --furin-ml-size: 140px;\n  --furin-ml-from: #6a5cff;\n  --furin-ml-to: #4de6d0;\n  position: relative;\n  display: block;\n  width: var(--furin-ml-size);\n  height: var(--furin-ml-size);\n  flex: none;\n}\n.furin-morph-loader__svg {\n  display: block;\n  width: 100%;\n  height: 100%;\n  overflow: visible;\n  /* Resplandor del mismo color que la figura. */\n  filter: drop-shadow(0 6px 22px color-mix(in srgb, var(--furin-ml-from) 55%, transparent));\n}\n.furin-morph-loader__sr {\n  position: absolute;\n  width: 1px;\n  height: 1px;\n  margin: -1px;\n  padding: 0;\n  overflow: hidden;\n  clip-path: inset(50%);\n  white-space: nowrap;\n  border: 0;\n}\n",
      "type": "registry:file",
      "target": "components/furin/morph-loader/MorphLoader.css"
    }
  ]
}
