{
  "name": "pixel-dissolve-card-default",
  "type": "registry:block",
  "tier": "free",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.craftui.space/r/craftui-base.json"
  ],
  "css": {
    ".pixcard-grid": {
      "grid-template-columns": "repeat(var(--pixcard-size, 14), 1fr)",
      "grid-template-rows": "repeat(var(--pixcard-size, 14), 1fr)"
    },
    ".pixcard-tile": {
      "background-size": "calc(var(--pixcard-size, 14) * 100%) calc(var(--pixcard-size, 14) * 100%)",
      "background-repeat": "no-repeat",
      "transform": "scale(1.02)",
      "animation": "pixcard-out 0.32s cubic-bezier(0.4, 0, 1, 1) forwards"
    },
    "@keyframes pixcard-out": {
      "from": {
        "opacity": "1"
      },
      "to": {
        "opacity": "0"
      }
    },
    "@media (prefers-reduced-motion: reduce)": {
      ".pixcard-tile": {
        "animation": "none"
      }
    }
  },
  "files": [
    {
      "path": "components/craftui/blocks/pixel-dissolve-card/default.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nexport type PixelDissolveSlide = {\n  image: string;\n  imageAlt?: string;\n  heading: string;\n};\n\nexport interface PixelDissolveCardProps {\n  slides?: PixelDissolveSlide[];\n  /** Grid resolution of the dissolve — `size` × `size` tiles. */\n  size?: number;\n  /** Seconds between each tile group vanishing. */\n  revealStep?: number;\n  className?: string;\n}\n\nconst DEFAULT_SIZE = 14;\nconst DEFAULT_REVEAL_STEP = 0.045;\n/** Tail added after the last group starts, so the timer outlives the animation. */\nconst SETTLE_S = 0.4;\n\nconst DEFAULT_SLIDES: PixelDissolveSlide[] = [\n  {\n    image:\n      \"https://images.unsplash.com/photo-1557672172-298e090bd0f1?q=80&w=900&auto=format&fit=crop\",\n    imageAlt: \"Layered gradient light study\",\n    heading:\n      \"A moment where care flows freely between beings. An experience you are familiar with, but perhaps forgotten.\",\n  },\n  {\n    image:\n      \"https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?q=80&w=900&auto=format&fit=crop\",\n    imageAlt: \"Abstract fluid shapes in warm tones\",\n    heading:\n      \"A quiet signal beneath the noise. The shape of an idea before it has learned to speak its own name.\",\n  },\n  {\n    image:\n      \"https://images.unsplash.com/photo-1541701494587-cb58502866ab?q=80&w=900&auto=format&fit=crop\",\n    imageAlt: \"Soft blue and pink light diffusion\",\n    heading:\n      \"Motion is memory in disguise. Every pixel that fades was once certain of its place in the whole. Perfectly imperfect.\",\n  },\n];\n\ntype Tile = { x: number; y: number; delay: number };\n\nfunction shuffle<T>(list: T[]): T[] {\n  const result = [...list];\n  for (let i = result.length - 1; i > 0; i--) {\n    const j = Math.floor(Math.random() * (i + 1));\n    [result[i], result[j]] = [result[j], result[i]];\n  }\n  return result;\n}\n\n/**\n * Assigns every cell to one of `size` groups — exactly `size` cells each, in\n * random positions — then gives each group its own delay. Tiles vanish in\n * scattered clusters rather than row by row.\n */\nfunction buildTiles(size: number, revealStep: number): Tile[] {\n  const groups: number[] = [];\n  for (let g = 0; g < size; g++) for (let i = 0; i < size; i++) groups.push(g);\n  const shuffled = shuffle(groups);\n\n  const order = shuffle(Array.from({ length: size }, (_, k) => k));\n  const delayOf = new Map<number, number>();\n  order.forEach((g, idx) => delayOf.set(g, idx * revealStep));\n\n  const tiles: Tile[] = [];\n  let i = 0;\n  for (let y = 0; y < size; y++) {\n    for (let x = 0; x < size; x++) {\n      tiles.push({ x, y, delay: delayOf.get(shuffled[i++])! });\n    }\n  }\n  return tiles;\n}\n\nconst EASE_OUT_QUART = [0.165, 0.84, 0.44, 1] as const;\nconst EASE_OUT_QUAD = [0.25, 0.46, 0.45, 0.94] as const;\n\n/** Per-word offset, so the line reads left to right as it resolves. */\nconst WORD_STAGGER_S = 0.02;\nconst WORD_DURATION_S = 0.2;\nconst HEADING_EXIT_S = 0.18;\n\nexport default function PixelDissolveCard({\n  slides = DEFAULT_SLIDES,\n  size = DEFAULT_SIZE,\n  revealStep = DEFAULT_REVEAL_STEP,\n  className = \"\",\n}: PixelDissolveCardProps) {\n  const [index, setIndex] = useState(0);\n  // The image currently dissolving away; null while idle.\n  const [outgoing, setOutgoing] = useState<{\n    image: string;\n    tiles: Tile[];\n  } | null>(null);\n  const timer = useRef<number | null>(null);\n  const reduceMotion = useReducedMotion();\n\n  useEffect(() => {\n    return () => {\n      if (timer.current !== null) window.clearTimeout(timer.current);\n    };\n  }, []);\n\n  const next = useCallback(() => {\n    if (outgoing) return; // ignore input mid-transition\n    const from = slides[index];\n    setIndex((i) => (i + 1) % slides.length);\n\n    if (reduceMotion) return;\n\n    setOutgoing({ image: from.image, tiles: buildTiles(size, revealStep) });\n    timer.current = window.setTimeout(\n      () => setOutgoing(null),\n      (size * revealStep + SETTLE_S) * 1000,\n    );\n  }, [index, outgoing, reduceMotion, revealStep, size, slides]);\n\n  const slide = slides[index];\n\n  return (\n    // `flex flex-col` overrides the button UA layout, which would otherwise\n    // centre the contents vertically and float the image off the top edge.\n    <button\n      type=\"button\"\n      onClick={next}\n      aria-label=\"Show next slide\"\n      className={`flex h-auto md:h-[550px] w-full max-w-[380px] cursor-pointer flex-col items-stretch overflow-hidden rounded-[18px] sm:rounded-[22px] bg-[#fbfbfa] p-0 text-left shadow-[0_20px_60px_-25px_rgba(0,0,0,0.25)] transition-transform duration-150 select-none active:scale-[0.98] ${className}`}\n    >\n      <div\n        className=\"relative h-56 sm:h-72 md:h-80 shrink-0 overflow-hidden\"\n        style={{ [\"--pixcard-size\" as string]: size }}\n      >\n        {/* Incoming image sits underneath and is revealed as tiles drop away. */}\n        <img\n          className=\"absolute inset-0 h-full w-full object-cover\"\n          src={slide.image}\n          alt={slide.imageAlt ?? \"\"}\n        />\n\n        {outgoing && (\n          <div\n            key={index}\n            className=\"pixcard-grid absolute inset-0 z-[1] grid\"\n            aria-hidden=\"true\"\n          >\n            {outgoing.tiles.map((t) => (\n              <div\n                key={`${t.x}-${t.y}`}\n                className=\"pixcard-tile\"\n                style={{\n                  backgroundImage: `url(\"${outgoing.image}\")`,\n                  backgroundPosition: `${(t.x / (size - 1)) * 100}% ${\n                    (t.y / (size - 1)) * 100\n                  }%`,\n                  animationDelay: `${t.delay}s`,\n                }}\n              />\n            ))}\n          </div>\n        )}\n      </div>\n\n      <div className=\"relative px-4 pt-5 pb-5 sm:px-6 sm:pt-6 md:px-[26px] md:pt-7 md:pb-[22px] text-[19px] sm:text-[22px] md:text-[27px] leading-[1.08] font-medium tracking-[-0.02em] text-[#131313]\">\n        <AnimatePresence mode=\"wait\">\n          <motion.h2\n            key={index}\n            exit={{ opacity: 0, y: -6 }}\n            transition={{ duration: HEADING_EXIT_S, ease: EASE_OUT_QUAD }}\n          >\n            {slide.heading.split(\" \").map((word, i) => (\n              <span key={`${i}-${word}`}>\n                <span className=\"inline-block overflow-hidden pb-[0.14em] align-bottom mb-[-0.14em] text-pretty text-left\">\n                  <motion.span\n                    className=\"inline-block\"\n                    initial={reduceMotion ? false : { y: \"105%\" }}\n                    animate={{ y: \"0%\" }}\n                    transition={{\n                      duration: WORD_DURATION_S,\n                      delay: i * WORD_STAGGER_S,\n                      ease: EASE_OUT_QUART,\n                    }}\n                  >\n                    {word}\n                  </motion.span>\n                </span>{\" \"}\n              </span>\n            ))}\n          </motion.h2>\n        </AnimatePresence>\n      </div>\n    </button>\n  );\n}\n",
      "type": "registry:component",
      "target": "~/components/craftui/blocks/pixel-dissolve-card/default.tsx"
    }
  ],
  "meta": {
    "displayName": "Pixel Dissolve Card - Pixel Dissolve Card",
    "description": "Clicking the card swaps the image underneath instantly, then dissolves the outgoing photo away as a size × size grid of tiles. Each tile is a background-position slice of the same image, and tiles are assigned to random groups so they vanish in scattered clusters instead of sweeping row by row.",
    "category": "card",
    "tags": [
      "card",
      "image",
      "pixel",
      "dissolve",
      "click",
      "animation"
    ],
    "animationType": "click"
  }
}