{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tile-reveal",
  "type": "registry:ui",
  "title": "Tile Reveal",
  "description": "Content revealed by a grid of tiles flipping, shrinking or dropping away in a wave — on view, on click or from a prop.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/tile-reveal.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport type TileRevealPattern = \"corner\" | \"center\" | \"pointer\" | \"random\" | \"sweep\";\nexport type TileRevealEffect = \"flip\" | \"scale\" | \"drop\";\nexport type TileRevealTone = \"ink\" | \"accent\" | \"mixed\";\nexport type TileRevealTrigger = \"view\" | \"click\" | \"manual\";\n\nexport interface TileRevealProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\"> {\n  /** The content under the cover. It is inert until the reveal finishes. */\n  children: React.ReactNode;\n  /** Tile rows. rows × cols is capped at 200 tiles. */\n  rows?: number;\n  /** Tile columns. */\n  cols?: number;\n  /** Where the wave starts: a corner, the centre, the pointer, random order, or a left-to-right sweep. */\n  pattern?: TileRevealPattern;\n  /** How each tile leaves. */\n  effect?: TileRevealEffect;\n  /** Total reveal time in ms, from the first tile moving to the last one gone. */\n  duration?: number;\n  /** Cover colouring: theme ink, the accent, or an ink checker with accent tiles. */\n  tone?: TileRevealTone;\n  /** Accent colour for the \"accent\" and \"mixed\" tones and the focus ring. */\n  accent?: string;\n  /** \"view\" reveals when scrolled into view, \"click\" waits for a press, \"manual\" follows `revealed`. */\n  trigger?: TileRevealTrigger;\n  /** With trigger=\"manual\": true reveals, false covers again instantly. */\n  revealed?: boolean;\n  /** Accessible name and visible label of the cover button (trigger=\"click\"). */\n  label?: string;\n  /** Called once the last tile is gone and the content is interactive. */\n  onReveal?: () => void;\n}\n\n/* ----------------------------- Reduced motion ------------------------------ */\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\n/** SSR-safe: hydrates as \"no preference\", then follows the media query. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n/* --------------------------------- Layout ---------------------------------- */\n\nconst MAX_TILES = 200;\n\n/** Clamps the grid to at most 200 tiles by shrinking the larger side. */\nfunction gridSize(rows: number, cols: number) {\n  let r = Math.max(1, Math.min(20, Math.round(rows)));\n  let c = Math.max(1, Math.min(20, Math.round(cols)));\n  while (r * c > MAX_TILES) {\n    if (c >= r) c--;\n    else r--;\n  }\n  return { r, c };\n}\n\n/** Deterministic 0–1 hash per tile, so \"random\" renders the same on the server and the client. */\nfunction hash(i: number) {\n  let h = Math.imul(i + 0x9e3779b9, 0x85ebca6b);\n  h ^= h >>> 13;\n  h = Math.imul(h, 0xc2b2ae35);\n  h ^= h >>> 16;\n  return (h >>> 0) / 4294967296;\n}\n\n/**\n * Each tile's start time as a fraction (0–1) of the wave. Distances are measured in tile units,\n * so the wave front stays round on non-square grids.\n */\nfunction waveOrder(pattern: TileRevealPattern, r: number, c: number, origin: [number, number] | null) {\n  const order: number[] = [];\n  const ox = origin ? origin[0] * c : c / 2;\n  const oy = origin ? origin[1] * r : r / 2;\n  const far = Math.max(Math.hypot(ox, oy), Math.hypot(c - ox, oy), Math.hypot(ox, r - oy), Math.hypot(c - ox, r - oy)) || 1;\n  for (let row = 0; row < r; row++) {\n    for (let col = 0; col < c; col++) {\n      const x = col + 0.5;\n      const y = row + 0.5;\n      let t: number;\n      if (pattern === \"corner\") t = (x + y) / (c + r);\n      else if (pattern === \"random\") t = hash(row * c + col);\n      else if (pattern === \"sweep\") t = (x + y * 0.18) / (c + r * 0.18);\n      else t = Math.hypot(x - ox, y - oy) / far; // center, pointer\n      order.push(t);\n    }\n  }\n  const min = Math.min(...order);\n  const max = Math.max(...order);\n  return order.map((t) => (max > min ? (t - min) / (max - min) : 0));\n}\n\n/** The end state of a tile per effect, as an inline transform. */\nconst GONE: Record<TileRevealEffect, string> = {\n  flip: \"perspective(1400px) rotateY(-90deg)\",\n  scale: \"scale(0)\",\n  drop: \"translateY(60%) scale(0.86)\",\n};\n\nconst EASE_OUT = \"cubic-bezier(0.65, 0, 0.35, 1)\";\nconst EASE_FADE = \"cubic-bezier(0.4, 0, 1, 1)\";\nconst FADE_MS = 320; // reduced-motion cross-fade\n\n/**\n * A grid of tiles covering content, cleared in a wave — flipping, shrinking or dropping away from\n * a corner, the centre, the pointer, at random or in a sweep — as an entrance or transition. The\n * content is inert until the last tile is gone. Triggers on view, on click (the cover is a real\n * button) or from the `revealed` prop. Reduced motion cross-fades the whole cover instead.\n */\nexport function TileReveal({\n  children,\n  rows = 6,\n  cols = 10,\n  pattern = \"corner\",\n  effect = \"flip\",\n  duration = 1100,\n  tone = \"ink\",\n  accent = \"#FF7448\",\n  trigger = \"view\",\n  revealed,\n  label = \"Reveal\",\n  onReveal,\n  className = \"\",\n  style,\n  onPointerMove,\n  ...props\n}: TileRevealProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const contentRef = React.useRef<HTMLDivElement>(null);\n  const coverRef = React.useRef<HTMLButtonElement>(null);\n  const pointerRef = React.useRef<[number, number] | null>(null);\n  const refocusRef = React.useRef(false);\n\n  const initiallyOpen = trigger === \"manual\" && !!revealed;\n  const [open, setOpen] = React.useState(initiallyOpen);\n  const [settled, setSettled] = React.useState(initiallyOpen);\n  const [origin, setOrigin] = React.useState<[number, number] | null>(null);\n\n  // trigger=\"manual\": follow the prop. Covering again is instant; revealing animates.\n  const [prevRevealed, setPrevRevealed] = React.useState(revealed);\n  if (trigger === \"manual\" && revealed !== prevRevealed) {\n    setPrevRevealed(revealed);\n    setOpen(!!revealed);\n    if (!revealed) setSettled(false);\n  }\n\n  const { r, c } = gridSize(rows, cols);\n  const total = Math.max(200, Math.round(duration));\n  const tileMs = Math.max(160, Math.round(total * 0.42));\n  const spread = total - tileMs;\n  const order = waveOrder(pattern, r, c, pattern === \"pointer\" ? origin : null);\n  const finishMs = reduceMotion ? FADE_MS : total;\n\n  /** Starts the wave; `at` is the origin as fractions of the box (pattern=\"pointer\"). */\n  const reveal = (at: [number, number] | null) => {\n    setOrigin(at ?? pointerRef.current);\n    setOpen(true);\n  };\n\n  // trigger=\"view\": reveal once a third of the box is on screen.\n  React.useEffect(() => {\n    const root = rootRef.current;\n    if (trigger !== \"view\" || open || !root) return;\n    const io = new IntersectionObserver(\n      ([entry]) => {\n        if (!entry.isIntersecting) return;\n        io.disconnect();\n        setOrigin(pointerRef.current);\n        setOpen(true);\n      },\n      { threshold: 0.35 },\n    );\n    io.observe(root);\n    return () => io.disconnect();\n  }, [trigger, open]);\n\n  // When the last tile is gone: drop the cover, make the content interactive, hand focus over.\n  const finish = React.useEffectEvent(() => {\n    setSettled(true);\n    onReveal?.();\n  });\n  React.useEffect(() => {\n    if (!open || settled) return;\n    const id = window.setTimeout(finish, finishMs + 40);\n    return () => window.clearTimeout(id);\n  }, [open, settled, finishMs]);\n\n  React.useEffect(() => {\n    if (!settled || !refocusRef.current) return;\n    refocusRef.current = false;\n    contentRef.current?.focus({ preventScroll: true });\n  }, [settled]);\n\n  const toFraction = (clientX: number, clientY: number): [number, number] | null => {\n    const rect = rootRef.current?.getBoundingClientRect();\n    if (!rect || !rect.width || !rect.height) return null;\n    return [Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)), Math.min(1, Math.max(0, (clientY - rect.top) / rect.height))];\n  };\n\n  const tileClass = (i: number) => {\n    const row = Math.floor(i / c);\n    const col = i % c;\n    if (tone === \"accent\") return \"bg-[var(--tr-accent)] shadow-[inset_0_0_0_0.5px_rgb(0_0_0/0.12)]\";\n    if (tone === \"mixed\") {\n      if (hash(i * 7 + 3) < 0.14) return \"bg-[var(--tr-accent)] shadow-[inset_0_0_0_0.5px_rgb(0_0_0/0.12)]\";\n      return (row + col) % 2\n        ? \"bg-zinc-800 shadow-[inset_0_0_0_0.5px_rgb(255_255_255/0.06)] dark:bg-zinc-700\"\n        : \"bg-zinc-900 shadow-[inset_0_0_0_0.5px_rgb(255_255_255/0.06)] dark:bg-zinc-800\";\n    }\n    return \"bg-zinc-900 shadow-[inset_0_0_0_0.5px_rgb(255_255_255/0.07)] dark:bg-zinc-800\";\n  };\n\n  const tiles = order.map((t, i) => {\n    const delay = Math.round(t * spread);\n    // Flipping tiles stay opaque until they are nearly edge-on; the others fade as they go.\n    const fadeMs = effect === \"flip\" ? Math.round(tileMs * 0.3) : tileMs;\n    const fadeDelay = effect === \"flip\" ? delay + Math.round(tileMs * 0.7) : delay;\n    const tileStyle: React.CSSProperties = reduceMotion\n      ? {}\n      : {\n          transform: open ? GONE[effect] : \"none\",\n          opacity: open ? 0 : 1,\n          transitionProperty: \"transform, opacity\",\n          transitionDuration: `${tileMs}ms, ${fadeMs}ms`,\n          transitionDelay: `${delay}ms, ${fadeDelay}ms`,\n          transitionTimingFunction: `${EASE_OUT}, ${EASE_FADE}`,\n        };\n    // Tiles overlap their neighbours by 1px, so sub-pixel seams never let the content peek through.\n    return <span key={i} className={`-m-px block will-change-transform ${tileClass(i)}`} style={tileStyle} />;\n  });\n\n  const coverStyle: React.CSSProperties = {\n    gridTemplateColumns: `repeat(${c}, minmax(0, 1fr))`,\n    gridTemplateRows: `repeat(${r}, minmax(0, 1fr))`,\n    // Reduced motion: no tile movement, the whole cover cross-fades.\n    ...(reduceMotion ? { opacity: open ? 0 : 1, transition: `opacity ${FADE_MS}ms ease` } : null),\n  };\n\n  const labelPill = (\n    <span\n      aria-hidden=\"true\"\n      className={`pointer-events-none absolute left-1/2 top-1/2 inline-flex -translate-x-1/2 -translate-y-1/2 items-center gap-2 rounded-full border border-white/15 bg-zinc-950/80 px-3.5 py-2 font-mono text-[10px] uppercase tracking-[0.14em] text-white shadow-[0_8px_24px_-12px_rgb(0_0_0/0.6)] transition-opacity duration-200 ${\n        open ? \"opacity-0\" : \"opacity-100\"\n      }`}\n    >\n      <svg viewBox=\"0 0 16 16\" className=\"size-3\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\">\n        <path d=\"M2 2h5v5H2zM9 9h5v5H9z\" />\n        <path d=\"M9 2h5v5H9zM2 9h5v5H2z\" opacity=\"0.4\" />\n      </svg>\n      {label}\n    </span>\n  );\n\n  return (\n    <div\n      ref={rootRef}\n      className={`relative isolate overflow-hidden ${className}`}\n      style={{ [\"--tr-accent\" as string]: accent, ...style }}\n      onPointerMove={(e) => {\n        pointerRef.current = toFraction(e.clientX, e.clientY);\n        onPointerMove?.(e);\n      }}\n      {...props}\n    >\n      <div ref={contentRef} inert={!settled} tabIndex={-1} className=\"h-full w-full outline-hidden\">\n        {children}\n      </div>\n\n      {!settled &&\n        (trigger === \"click\" ? (\n          <button\n            ref={coverRef}\n            type=\"button\"\n            aria-label={label}\n            disabled={open}\n            onClick={(e) => {\n              refocusRef.current = true;\n              // Keyboard activation has no pointer position: start from the centre.\n              reveal(e.detail === 0 ? [0.5, 0.5] : toFraction(e.clientX, e.clientY));\n            }}\n            className=\"group absolute inset-0 grid cursor-pointer outline-hidden focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-solid focus-visible:outline-[var(--tr-accent)] disabled:cursor-default\"\n            style={coverStyle}\n          >\n            {tiles}\n            {labelPill}\n          </button>\n        ) : (\n          <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 grid\" style={coverStyle}>\n            {tiles}\n          </div>\n        ))}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "backgrounds"
  ],
  "docs": "Documentation: https://wancraft.my/components/tile-reveal"
}
