{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "folder-motion",
  "title": "Folder (Motion)",
  "author": "AlgorizeTech",
  "description": "A tactile folder — skeleton cards or your own photos tuck behind a frosted-glass pocket and fan out into an arc on hover. Use it for file groups, galleries and drag-and-drop upload targets.",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://avaraui.com/r/tooltip.json",
    "utils"
  ],
  "files": [
    {
      "path": "src/components/custom/blocks/motion/FolderBlock.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { AnimatePresence, animate, motion, type PanInfo, useReducedMotion } from \"motion/react\";\nimport { ChevronLeft, ChevronRight, type LucideIcon, X } from \"lucide-react\";\nimport React, {\n  type ChangeEvent,\n  type DragEvent,\n  type ReactNode,\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"@/components/custom/blocks/TooltipBlock\";\n\nexport interface FolderFile {\n  /** Optional image thumbnail URL — shown as a photo peeking out. */\n  thumb?: string;\n  /** 0–100 upload progress for this item. */\n  progress?: number;\n  /** File name — shown in a tooltip on hover so people know what they named it. */\n  name?: string;\n}\n\n/**\n * The default folder colour: the theme's `--primary` taken down to a pastel\n * — lightness pinned at 0.88 and chroma cut to 40%, the weight of a `#bfdbfe`\n * — so it reads as a folder, not a button, and still shifts with the theme.\n * A grey primary gives a soft grey folder.\n */\nexport const FOLDER_THEME_TINT = \"oklch(from var(--primary) 0.88 calc(c * 0.4) h)\";\n\nexport interface FolderProps {\n  /** Title under the folder. */\n  label?: string;\n  /** Count / subtitle line. Falls back to \"{n} items\". */\n  count?: string;\n  /**\n   * Folder body colour. Any CSS colour; by default a soft tint of the theme\n   * colour (`FOLDER_THEME_TINT`), so the folder follows the theme without\n   * shouting. Pocket, icon and skeleton cards derive their shades from it.\n   */\n  color?: string;\n  /** A lucide icon shown on the front pocket. */\n  icon?: LucideIcon;\n  /** Frosted-glass blur on the front pocket, px. Default 10. */\n  blur?: number;\n  /** Folder width in px. Height derives from it. Default 260. */\n  size?: number;\n  /** The items peeking out — { thumb } for photos, otherwise a unified skeleton card. */\n  files?: FolderFile[];\n  /** How many skeleton cards to show when no files are given. Default 3. */\n  peek?: number;\n  /** Force-open — fans the cards out. Omit to let hover / drag drive it. */\n  open?: boolean;\n  /** Overall upload progress 0–100 — drives the bar across the pocket. */\n  progress?: number;\n  /** Extra decoration on the pocket (emoji, etc.) — sits under the icon. */\n  stickers?: ReactNode;\n  /** Show the title. Default true. */\n  showLabel?: boolean;\n  /** Show the count line. Default true. */\n  showCount?: boolean;\n  /** Enable drag-and-drop + click-to-browse. */\n  interactive?: boolean;\n  /** Let a tap toggle the fan — the touch/keyboard path to a hover-only reveal.\n   *  Turn it off when an ancestor already owns the click, so the two don't\n   *  fight over the same tap. Defaults to on whenever the folder isn't\n   *  `interactive`. */\n  toggleOnClick?: boolean;\n  /** Join the tab order and open the fan on focus, without a tap toggling it.\n   *  Only needed when `toggleOnClick` is false and an ancestor owns the click,\n   *  which would otherwise leave the fan unreachable by keyboard. */\n  focusable?: boolean;\n  accept?: string;\n  multiple?: boolean;\n  onFiles?: (files: File[]) => void;\n  /** Shows a small remove button on each fanned-out card, reporting the\n   *  file's index within `files`. Only appears while the fan is open. */\n  onRemoveFile?: (index: number) => void;\n  className?: string;\n}\n\n/** Surface open/close — one spring for every part of the folder so the tilt,\n *  the lift and the ground shadow all settle on the same beat. */\nconst SPRING = { type: \"spring\", stiffness: 260, damping: 24 } as const;\n/** Cards travel further than the pocket tilts, so they get a slightly softer,\n *  heavier spring — snappy enough to feel direct, damped enough not to wobble. */\nconst CARD_SPRING = { type: \"spring\", stiffness: 230, damping: 26, mass: 0.9 } as const;\n\n/** How many cards the fan shows at once before paging kicks in. */\nconst FAN_MAX = 5;\n\ntype Vertex = { x: number; y: number; r: number };\n\n/**\n * One continuous rounded path through `pts`, closed.\n *\n * The folder's back panel is drawn as a single path so the tab and the body\n * share one silhouette. Two stacked elements cannot do this: a fillet where\n * the tab meets the body would have to straddle the boundary between them, so\n * the join always shows a notch and the slant corners can never round.\n *\n * `smoothing` follows the squircle convention (0 is a plain circular fillet,\n * 0.6 is the default): tangent points slide outward along each edge while\n * control points stay at their circular distance, so curvature ramps in\n * gradually. Convex and concave corners use the same math, which is what lets\n * the slant's inner corner round too. The front pocket reuses it as a\n * clip-path, so the pocket's squircle and the body's fillets match exactly.\n */\nfunction roundedPolygon(pts: Vertex[], smoothing = 0.6) {\n  const n = pts.length;\n  const unit = (x: number, y: number) => {\n    const l = Math.hypot(x, y) || 1;\n    return { x: x / l, y: y / l };\n  };\n\n  const cs = pts.map((p, i) => {\n    const prev = pts[(i - 1 + n) % n];\n    const next = pts[(i + 1) % n];\n    const inV = unit(p.x - prev.x, p.y - prev.y);\n    const outV = unit(next.x - p.x, next.y - p.y);\n    const phi = Math.acos(Math.max(-1, Math.min(1, inV.x * outV.x + inV.y * outV.y)));\n    const flat = phi < 1e-4;\n    // tangent distance for a fillet of radius r across a turn of `phi`\n    const t = flat ? 0 : p.r * Math.tan(phi / 2);\n    // control-point distance from the corner: the exact circular-fillet value,\n    // held fixed while `smoothing` pushes the tangent points outward\n    const dC = flat ? 0 : t * (1 - ((4 / 3) * Math.tan(phi / 4)) / Math.tan(phi / 2));\n    return { p, inV, outV, t, dC, T: t * (1 + smoothing) };\n  });\n\n  // two fillets sharing an edge must never overrun it\n  for (let i = 0; i < n; i++) {\n    const a = cs[i];\n    const b = cs[(i + 1) % n];\n    const edge = Math.hypot(b.p.x - a.p.x, b.p.y - a.p.y);\n    const need = a.T + b.T;\n    if (need > edge && need > 0) {\n      const k = edge / need;\n      a.T *= k;\n      a.dC *= k;\n      b.T *= k;\n      b.dC *= k;\n    }\n  }\n\n  const d: string[] = [];\n  cs.forEach((c, i) => {\n    const ax = c.p.x - c.inV.x * c.T, ay = c.p.y - c.inV.y * c.T;\n    const bx = c.p.x + c.outV.x * c.T, by = c.p.y + c.outV.y * c.T;\n    const c1x = c.p.x - c.inV.x * c.dC, c1y = c.p.y - c.inV.y * c.dC;\n    const c2x = c.p.x + c.outV.x * c.dC, c2y = c.p.y + c.outV.y * c.dC;\n    d.push(`${i === 0 ? \"M\" : \"L\"} ${ax.toFixed(2)} ${ay.toFixed(2)}`);\n    d.push(`C ${c1x.toFixed(2)} ${c1y.toFixed(2)} ${c2x.toFixed(2)} ${c2y.toFixed(2)} ${bx.toFixed(2)} ${by.toFixed(2)}`);\n  });\n  return `${d.join(\" \")} Z`;\n}\n\n/* one unified minimal \"document\" card — thumbnail + title + body lines.\n * Exported so anything showing a \"pretend file\" (e.g. a drag-and-drop demo)\n * uses the exact same placeholder language as the folder's own peeking\n * cards, instead of inventing a second one. */\nexport function SkeletonCard() {\n  const line = \"var(--folder-card-line)\";\n  return (\n    <div className=\"relative flex h-full w-full flex-col gap-[9%] p-[13%]\" style={{ background: \"var(--folder-card)\" }}>\n      <div className=\"flex items-start gap-[8%]\">\n        <div className=\"shrink-0 rounded-[5px]\" style={{ width: \"26%\", aspectRatio: \"1\", background: \"var(--folder-card-accent)\" }} />\n        <div className=\"flex flex-1 flex-col gap-[10px] pt-[4%]\">\n          <span className=\"h-[4px] rounded-full\" style={{ width: \"86%\", background: \"var(--folder-card-line-strong)\" }} />\n          <span className=\"h-[3px] rounded-full\" style={{ width: \"58%\", background: line }} />\n        </div>\n      </div>\n      <div className=\"flex flex-col gap-[7px]\">\n        {[100, 90, 74].map((wpc) => (\n          <span key={wpc} className=\"h-[3px] rounded-full\" style={{ width: `${wpc}%`, background: line }} />\n        ))}\n      </div>\n    </div>\n  );\n}\n\nfunction FanNav({\n  dir,\n  onClick,\n  onEngage,\n  style,\n}: {\n  dir: \"prev\" | \"next\";\n  onClick: () => void;\n  onEngage: () => void;\n  style: React.CSSProperties;\n}) {\n  const Icon = dir === \"prev\" ? ChevronLeft : ChevronRight;\n  return (\n    <motion.button\n      type=\"button\"\n      aria-label={dir === \"prev\" ? \"Previous files\" : \"Next files\"}\n      // the fan lives inside the folder's own click target (browse files) —\n      // paging must not also open the file picker\n      onClick={(e) => {\n        e.stopPropagation();\n        onClick();\n        // A mouse click leaves the button focused, which the folder's focus\n        // wiring reads as \"keep the fan open\", so hovering out stops closing\n        // it. `detail` is 0 only for keyboard activation, so a real mouse\n        // click blurs itself and keyboard users are unaffected.\n        if (e.detail !== 0) e.currentTarget.blur();\n      }}\n      // The nav buttons sit at the edge of the 40px hover buffer, so a wide\n      // cursor approach can leave it for a frame and fire onMouseLeave just\n      // before the click lands, collapsing the fan mid-click. `onEngage`\n      // re-asserts hover as soon as a press starts.\n      onPointerDown={(e) => {\n        e.stopPropagation();\n        onEngage();\n      }}\n      className={cn(\n        \"absolute flex h-7 w-7 items-center justify-center rounded-full\",\n        \"bg-card/90 text-foreground/70 shadow-[0_2px_8px_rgba(0,0,0,0.14)] backdrop-blur\",\n        \"transition-colors duration-150\",\n        \"hover:bg-card hover:text-foreground\",\n      )}\n      style={style}\n      initial={{ opacity: 0, scale: 0.8 }}\n      animate={{ opacity: 1, scale: 1 }}\n      exit={{ opacity: 0, scale: 0.8 }}\n      whileTap={{ scale: 0.92 }}\n      transition={{ duration: 0.15, ease: [0.22, 1, 0.36, 1] }}\n    >\n      <Icon size={15} strokeWidth={2} />\n    </motion.button>\n  );\n}\n\nexport function Folder({\n  label,\n  count,\n  color = FOLDER_THEME_TINT,\n  icon: Icon,\n  blur = 10,\n  size = 260,\n  files,\n  peek = 3,\n  open: openProp,\n  progress,\n  stickers,\n  showLabel = true,\n  showCount = true,\n  interactive = false,\n  toggleOnClick,\n  focusable = false,\n  accept,\n  multiple = true,\n  onFiles,\n  onRemoveFile,\n  className,\n}: FolderProps) {\n  const inputRef = useRef<HTMLInputElement>(null);\n  const [dragging, setDragging] = useState(false);\n  const [hovered, setHovered] = useState(false);\n  const [focusedIn, setFocusedIn] = useState(false);\n  const [pinned, setPinned] = useState(false);\n  const [fanStart, setFanStart] = useState(0);\n  // Set while a card is being dragged, so the click that follows the release\n  // isn't read as a tap on the folder — which would toggle the fan shut on\n  // touch, where the fan only stays open by being pinned.\n  const draggedRef = useRef(false);\n  const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n  useEffect(\n    () => () => {\n      if (closeTimerRef.current) clearTimeout(closeTimerRef.current);\n    },\n    [],\n  );\n  const engage = () => {\n    if (closeTimerRef.current) {\n      clearTimeout(closeTimerRef.current);\n      closeTimerRef.current = null;\n    }\n    setHovered(true);\n  };\n  // No rate limit. Cards have no exit animation for a fast second page to\n  // interrupt, so holding an arrow pages as fast as the springs follow.\n  const page = (dir: 1 | -1) => setFanStart((s) => s + dir);\n  const reduce = useReducedMotion();\n  // The mounted card elements by React key, so a released card can be sent\n  // back to its slot (see onDragEnd).\n  const cardEls = useRef(new Map<number, HTMLDivElement>());\n\n  const items: FolderFile[] = files?.length ? files : Array.from({ length: peek }, () => ({}));\n  // A hover-only reveal is unreachable on touch (no hover at all) and by\n  // keyboard, so the fan also answers to focus, and to a tap when the folder\n  // isn't already spending its click on opening the file picker.\n  const tappable = (toggleOnClick ?? !interactive) && items.length > 0 && openProp === undefined;\n  const open = openProp ?? (dragging || hovered || focusedIn || pinned);\n  const fanned = open && items.length > 0;\n\n  const w = size;\n  const h = w * 0.82;\n  const pocketH = h * 0.6;\n  const tabW = w * 0.42;\n  const tabH = h * 0.13;\n  const r = w * 0.08;\n  const tabCut = tabH * 1.1;\n\n  // Clockwise from the tab's top-left. There is deliberately no vertex where\n  // the tab meets the body on the left: that edge is one straight run from\n  // (0, 0) to (0, h), which is precisely what removes the old notch.\n  const backPath = useMemo(\n    () =>\n      roundedPolygon([\n        { x: 0, y: 0, r: r * 0.55 }, // tab top-left\n        { x: tabW - tabCut, y: 0, r: r * 0.9 }, // slant starts (convex)\n        { x: tabW, y: tabH, r: r * 1.2 }, // slant meets the body top (concave)\n        { x: w, y: tabH, r }, // body top-right\n        { x: w, y: h, r }, // body bottom-right\n        { x: 0, y: h, r }, // body bottom-left\n      ]),\n    [w, h, tabW, tabH, tabCut, r],\n  );\n\n  // Squircle for the front pocket — same smoothing as the back panel's fillets.\n  const pocketPath = useMemo(\n    () =>\n      roundedPolygon([\n        { x: 0, y: 0, r },\n        { x: w, y: 0, r },\n        { x: w, y: pocketH, r },\n        { x: 0, y: pocketH, r },\n      ]),\n    [w, pocketH, r],\n  );\n\n  const body = color;\n\n  /* ── fan geometry ────────────────────────────────────────────────────── */\n  const cardW = w * 0.56;\n  const cardH = h * 0.56;\n  // resting height of the peeking cards — sitting a little low so their tops\n  // read as tucked into the folder rather than floating above the back panel\n  const topBase = h * 0.2;\n  const fanLift = -h * 0.46;\n  const fanArc = h * 0.09;\n  const fanScale = 0.94;\n\n  // The fan reaches past the folder's own box, so it sizes to the room\n  // available: tighten the gap first, drop a card only once they would bury\n  // each other. \"Available\" means the nearest ancestor that would actually\n  // clip or scroll, not the immediate parent, which is often a shrink-to-fit\n  // column no wider than the folder itself.\n  const [avail, setAvail] = useState<number | null>(null);\n  const wrapRef = useRef<HTMLDivElement>(null);\n  useEffect(() => {\n    let el = wrapRef.current?.parentElement ?? null;\n    while (el && el !== document.body) {\n      const cs = getComputedStyle(el);\n      if (/(auto|hidden|scroll|clip)/.test(cs.overflowX)) break;\n      el = el.parentElement;\n    }\n    const host = el ?? document.documentElement;\n    if (typeof ResizeObserver === \"undefined\") return;\n    const read = () => setAvail(host.clientWidth);\n    read();\n    const ro = new ResizeObserver(read);\n    ro.observe(host);\n    return () => ro.disconnect();\n  }, []);\n\n  const halfCard = (cardW * fanScale) / 2;\n  const NAV_ROOM = 42;\n  const availHalf = avail ? Math.max(halfCard, avail / 2 - NAV_ROOM) : Infinity;\n  const gapThatFits = (count: number) => (count <= 1 ? Infinity : (availHalf - halfCard) / ((count - 1) / 2));\n\n  const idealGap = w * 0.24;\n  const minGap = w * 0.14;\n  const floorCount = Math.min(3, items.length);\n  let fanCount = Math.min(FAN_MAX, items.length);\n  while (fanCount > floorCount && gapThatFits(fanCount) < minGap) fanCount--;\n  const fanGap = Math.max(0, Math.min(idealGap, gapThatFits(fanCount)));\n\n  // Circular window, so paging never runs out and the arrows never need a\n  // disabled state.\n  //\n  // The React key and the file index are deliberately different numbers.\n  // Keying by the wrapped file index means a full rotation moves the seam\n  // cards the entire width of the fan in one spring, and since z-index cannot\n  // animate they duck behind their neighbours mid-flight. Keying by the\n  // unbounded `fanStart + i` shifts every key by exactly one slot per page and\n  // only mounts or unmounts the one at the seam.\n  const n = items.length;\n  const slots = Array.from({ length: fanCount }, (_, i) => {\n    const rawKey = fanStart + i;\n    const idx = n > 0 ? ((rawKey % n) + n) % n : 0;\n    return { rawKey, idx, file: items[idx] };\n  });\n  const windowed = slots.map((s) => s.file);\n\n  // Arrows show whenever there's something else to bring into view — even a\n  // folder with just 2 files benefits from a way to swap which one is front\n  // and center.\n  const canPage = n > 1;\n  const mid = (windowed.length - 1) / 2;\n  const halfSpan = mid * fanGap + halfCard;\n\n  const cardAt = (i: number) => {\n    if (!fanned) {\n      // Tucked: a tight stack with the front card straight and the ones behind\n      // it tilted out to either side. The front slot is the last index rather\n      // than a fixed position, so the stack adapts to how many cards are\n      // showing: one card sits straight, two add a tilt behind it, and three or\n      // more fill the full stack.\n      const front = windowed.length - 1;\n      if (i === front) return { x: 0, y: 0, rot: 0, z: 3, op: 1 };\n      const left = i % 2 === 0;\n      return { x: left ? -w * 0.1 : w * 0.11, y: 0, rot: left ? -5 : 5, z: i + 1, op: 1 };\n    }\n    const off = i - mid;\n    const t = mid === 0 ? 0 : off / mid;\n    return {\n      x: off * fanGap,\n      // Parabolic rise: the middle card sits highest, the outer ones ease down.\n      y: fanLift - (1 - t * t) * fanArc,\n      rot: off * 8,\n      z: 10 - Math.abs(off),\n      op: 1,\n    };\n  };\n\n  // Each card's animate target, by slot.\n  const targetFor = (i: number) => {\n    const c = cardAt(i);\n    return { opacity: c.op, x: c.x, y: topBase + c.y, rotate: c.rot, scale: fanned ? fanScale : 1 };\n  };\n\n  // Cards ripple in at 40ms apart the first time a batch appears. Only a real\n  // batch staggers: paging brings in a single card, and making that one wait\n  // its index out would read as lag rather than rhythm. The cards that are new\n  // are worked out when the set of slots changes, against every slot shown\n  // before; they count as new until the targets next move, since a transition\n  // only applies when its target moves. The first set of slots mounts in place\n  // (no enter animation on first paint).\n  const slotKeys = slots.map((s) => s.rawKey);\n  const slotSig = slotKeys.join();\n  const targetSig = JSON.stringify(slots.map((_, i) => targetFor(i)));\n  const [entry, setEntry] = useState(() => ({\n    seen: new Set(slotKeys) as ReadonlySet<number>,\n    slotSig,\n    targetSig,\n    newKeys: slotKeys,\n    slotsChanged: false,\n  }));\n  let nextEntry = entry;\n  if (slotSig !== entry.slotSig) {\n    nextEntry = {\n      seen: new Set([...entry.seen, ...slotKeys]),\n      slotSig,\n      targetSig,\n      newKeys: slotKeys.filter((k) => !entry.seen.has(k)),\n      slotsChanged: true,\n    };\n  } else if (targetSig !== entry.targetSig) {\n    nextEntry = { ...entry, targetSig, newKeys: [] };\n  }\n  if (nextEntry !== entry) setEntry(nextEntry);\n  const firstPaint = !nextEntry.slotsChanged;\n  const { newKeys } = nextEntry;\n  const batchEntry = newKeys.length > 1;\n  const entryDelay = (key: number) => (batchEntry ? newKeys.indexOf(key) * 0.04 : null);\n\n  const handleFiles = useCallback(\n    (raw: FileList | null) => {\n      if (!raw?.length) return;\n      onFiles?.(Array.from(raw));\n    },\n    [onFiles],\n  );\n\n  const dropHandlers = interactive\n    ? {\n        onDrop: (e: DragEvent<HTMLDivElement>) => {\n          e.preventDefault();\n          setDragging(false);\n          handleFiles(e.dataTransfer.files);\n        },\n        onDragOver: (e: DragEvent<HTMLDivElement>) => {\n          e.preventDefault();\n          setDragging(true);\n        },\n        onDragLeave: (e: DragEvent<HTMLDivElement>) => {\n          if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragging(false);\n        },\n        onClick: () => inputRef.current?.click(),\n      }\n    : {};\n\n  const uploading = progress !== undefined && progress < 100;\n  const metaText =\n    count ??\n    (dragging\n      ? \"Drop to add\"\n      : files?.length\n        ? `${files.length} item${files.length > 1 ? \"s\" : \"\"}`\n        : interactive\n          ? \"Drop files or click\"\n          : undefined);\n\n  // Tint the peeking skeleton cards to match `color` — same OKLCH-relative\n  // approach as the pocket/icon, applied as custom-property overrides so\n  // SkeletonCard doesn't need to know about `color` at all.\n  const cardVars = {\n    \"--folder-card\": `oklch(from ${color} 0.97 calc(c * 0.3) h)`,\n    \"--folder-card-accent\": `oklch(from ${color} 0.88 calc(c * 0.6) h)`,\n  } as React.CSSProperties;\n\n  return (\n    <div ref={wrapRef} className={cn(\"inline-flex flex-col items-center gap-4\", className)} style={cardVars}>\n      <div\n        {...dropHandlers}\n        onMouseEnter={engage}\n        // A short grace period rather than an immediate close. The nav buttons\n        // sit at the edge of the hover buffer, so a wide cursor approach can\n        // leave it for a moment and collapse the fan mid-click. Any genuine\n        // re-entry, including the buttons' own onEngage, cancels this.\n        onMouseLeave={() => {\n          closeTimerRef.current = setTimeout(() => setHovered(false), 220);\n        }}\n        // Focus anywhere inside keeps the fan open; leaving the subtree closes it.\n        onFocus={() => setFocusedIn(true)}\n        onBlur={(e) => {\n          if (!e.currentTarget.contains(e.relatedTarget as Node)) setFocusedIn(false);\n        }}\n        {...(tappable && {\n          onClick: () => {\n            if (draggedRef.current) {\n              draggedRef.current = false;\n              return;\n            }\n            setPinned((p) => !p);\n          },\n          onKeyDown: (e: React.KeyboardEvent) => {\n            if (e.key === \"Enter\" || e.key === \" \") {\n              e.preventDefault();\n              setPinned((p) => !p);\n            }\n          },\n        })}\n        tabIndex={interactive || tappable || focusable ? 0 : undefined}\n        className={cn(\n          \"relative select-none rounded-[30px] outline-none\",\n          (interactive || tappable) && \"cursor-pointer\",\n          (interactive || tappable || focusable) &&\n            \"focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ring\",\n        )}\n        style={{ width: w, height: h, perspective: 900 }}\n        role={interactive || tappable ? \"button\" : undefined}\n        aria-label={\n          interactive\n            ? \"Upload files\"\n            : tappable\n              ? label\n                ? `${label} - show files`\n                : \"Show files\"\n              : focusable\n                ? label\n                  ? `${label} - files`\n                  : \"Files\"\n                : undefined\n        }\n        aria-expanded={tappable || focusable ? fanned : undefined}\n      >\n        {interactive && (\n          <input\n            ref={inputRef}\n            type=\"file\"\n            accept={accept}\n            multiple={multiple}\n            className=\"sr-only\"\n            onChange={(e: ChangeEvent<HTMLInputElement>) => handleFiles(e.target.files)}\n          />\n        )}\n\n        {/* The fan reaches well outside the folder's own box. This invisible\n            extension is a DOM child, so the pointer staying anywhere over the\n            fanned cards (or the gaps between them) still counts as hovering\n            the folder and the fan doesn't flicker shut. It sits BELOW the\n            cards (zIndex 1) — above them, a card's own hover (and its name\n            tooltip) would never be reachable. */}\n        {fanned && (\n          <div\n            aria-hidden\n            className=\"absolute\"\n            style={{\n              left: -(halfSpan - w / 2) - 40,\n              right: -(halfSpan - w / 2) - 40,\n              top: topBase + fanLift - fanArc - 20,\n              height: -fanLift + fanArc + cardH,\n              zIndex: 1,\n            }}\n          />\n        )}\n\n        {/* ground shadow */}\n        <motion.div\n          aria-hidden\n          className=\"absolute left-1/2 rounded-[50%] bg-black/25 blur-xl dark:bg-black/60\"\n          style={{ bottom: -h * 0.05, width: w * 0.82, height: h * 0.13, zIndex: 0 }}\n          animate={{ x: \"-50%\", scale: reduce ? 1 : open ? 1.1 : 1, opacity: open ? 0.9 : 0.6 }}\n          transition={reduce ? { duration: 0.15 } : SPRING}\n        />\n\n        {/* back panel + tab — one continuous silhouette, no seam to notch */}\n        <svg\n          width={w}\n          height={h}\n          viewBox={`0 0 ${w} ${h}`}\n          className=\"absolute inset-0\"\n          style={{ filter: \"drop-shadow(0 4px 10px rgba(0,0,0,0.09))\", zIndex: 1 }}\n          aria-hidden\n        >\n          <path d={backPath} style={{ fill: body }} />\n        </svg>\n\n        {/* the cards — below the pocket at rest, lifted clear of it when fanned */}\n        <div className=\"absolute inset-0\" style={{ zIndex: 2 }}>\n          {/* Deliberately NOT wrapped in AnimatePresence. AnimatePresence keeps\n              an exiting child rendered by splicing it back into the new child\n              array at the index it used to occupy. Paging left removes the LAST\n              key, and that splice inserts the exiting node *before* the final\n              surviving card — reordering it, so React moves that card's DOM\n              node and it ends up stranded at its tucked-stack transform, never\n              animating to its fan slot. Cards therefore mount/unmount plainly:\n              `initial` still gives each new card its enter animation, and the\n              one card leaving at the far edge of the fan pops rather than\n              fading. */}\n          {slots.map(({ rawKey, idx, file: f }, i) => {\n            const c = cardAt(i);\n            const off = i - mid;\n            const cardUploading = f.progress !== undefined && f.progress < 100;\n            const cardEl = (\n              <div className=\"relative h-full w-full\">\n                {f.thumb ? (\n                  // `draggable={false}` (and no pointer events on the image\n                  // itself): a bare <img> starts the browser's own drag — the\n                  // ghost-image kind — on the first pointer move, which\n                  // swallows the gesture before the card's drag can begin. So\n                  // photos couldn't be swiped while skeleton cards could.\n                  <img\n                    src={f.thumb}\n                    alt=\"\"\n                    draggable={false}\n                    style={{ width: \"100%\", height: \"100%\", objectFit: \"cover\", pointerEvents: \"none\" }}\n                  />\n                ) : (\n                  <SkeletonCard />\n                )}\n                {/* per-file progress — same bar language as the pocket's\n                    overall one, just small enough to sit on a peeking card */}\n                {cardUploading && (\n                  <div className=\"absolute inset-x-[10%] bottom-[8%] h-[2.5px] overflow-hidden rounded-full bg-foreground/15\">\n                    <motion.span\n                      className=\"block h-full rounded-full bg-primary\"\n                      initial={{ width: 0 }}\n                      animate={{ width: `${f.progress}%` }}\n                      transition={{ ease: \"linear\", duration: reduce ? 0 : 0.3 }}\n                    />\n                  </div>\n                )}\n                {/* remove — only once the fan is open (a tucked stack has no\n                    room to show it, and it'd be too easy to hit by accident).\n                    Inset rather than overhung off the corner: the card's own\n                    overflow-hidden would clip a badge hanging outside it. */}\n                {fanned && onRemoveFile && (\n                  <button\n                    type=\"button\"\n                    aria-label={`Remove ${f.name ?? \"file\"}`}\n                    onClick={(e) => {\n                      e.stopPropagation();\n                      onRemoveFile(idx);\n                      if (e.detail !== 0) e.currentTarget.blur();\n                    }}\n                    onPointerDown={(e) => e.stopPropagation()}\n                    // Visible badge stays a small 20px circle so it doesn't\n                    // overwhelm a ~76px card — the actual hit target is widened\n                    // to the 24px WCAG minimum with an invisible ::after.\n                    className=\"absolute right-1 top-1 z-10 flex h-5 w-5 items-center justify-center rounded-full bg-black/45 text-white backdrop-blur-sm transition-colors duration-150 hover:bg-black/65 after:absolute after:-inset-0.5 after:content-['']\"\n                  >\n                    <X size={11} strokeWidth={2.2} />\n                  </button>\n                )}\n              </div>\n            );\n            // Dragging any fanned card left/right pages the whole fan, same as\n            // the chevrons. No `dragConstraints`: the card is free to follow\n            // the pointer. A swipe past the threshold pages, which moves every\n            // card's slot; a shorter one changes nothing `animate` would\n            // notice (`c.x` is the same target as before), so the card is\n            // sent back to its slot explicitly, on the same spring.\n            const dragProps =\n              !reduce && fanned && canPage\n                ? {\n                    drag: \"x\" as const,\n                    dragMomentum: false,\n                    dragElastic: 0.35,\n                    whileDrag: { cursor: \"grabbing\" },\n                    onDragStart: () => {\n                      draggedRef.current = true;\n                    },\n                    onDragEnd: (e: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {\n                      e.stopPropagation();\n                      const { offset, velocity } = info;\n                      if (offset.x < -36 || velocity.x < -350) page(1);\n                      else if (offset.x > 36 || velocity.x > 350) page(-1);\n                      else {\n                        const el = cardEls.current.get(rawKey);\n                        if (el) animate(el, { x: c.x }, { ...CARD_SPRING, velocity: 0 });\n                      }\n                    },\n                  }\n                : {};\n            return (\n              <motion.div\n                key={rawKey}\n                ref={(el: HTMLDivElement | null) => {\n                  if (el) cardEls.current.set(rawKey, el);\n                  else cardEls.current.delete(rawKey);\n                }}\n                className=\"absolute left-1/2 top-0 overflow-hidden\"\n                style={{\n                  width: cardW,\n                  height: cardH,\n                  marginLeft: -cardW / 2,\n                  borderRadius: w * 0.05,\n                  zIndex: c.z,\n                  boxShadow: \"inset 0 0 0 1px rgba(0,0,0,0.04), 0 5px 16px -6px rgba(0,0,0,0.28)\",\n                  cursor: fanned && canPage ? \"grab\" : undefined,\n                }}\n                initial={\n                  firstPaint\n                    ? false\n                    : reduce\n                      ? { opacity: 0, x: c.x, y: topBase + c.y, rotate: c.rot, scale: 1 }\n                      : { opacity: 0, x: c.x, y: topBase + c.y + h * 0.14, rotate: c.rot, scale: 0.86 }\n                }\n                animate={{\n                  opacity: c.op,\n                  x: c.x,\n                  y: topBase + c.y,\n                  rotate: c.rot,\n                  scale: fanned ? fanScale : 1,\n                }}\n                transition={\n                  reduce\n                    ? { duration: 0 }\n                    : {\n                        ...CARD_SPRING,\n                        // a fresh batch ripples in per-item; opening spreads\n                        // outward from the middle; a close never waits —\n                        // dismissal has to feel immediate\n                        delay: entryDelay(rawKey) ?? (fanned ? Math.abs(off) * 0.03 : 0),\n                      }\n                }\n                {...dragProps}\n              >\n                {fanned && f.name ? (\n                  <Tooltip>\n                    <TooltipTrigger className=\"block h-full w-full\">{cardEl}</TooltipTrigger>\n                    <TooltipContent side=\"top\">{f.name}</TooltipContent>\n                  </Tooltip>\n                ) : (\n                  cardEl\n                )}\n              </motion.div>\n            );\n          })}\n        </div>\n\n        {/* paging — always available once there's more than one file to\n            browse; the window wraps, so the arrows never disable */}\n        <AnimatePresence>\n          {fanned && canPage && (\n            <div className=\"absolute inset-0\" style={{ zIndex: 12 }} key=\"nav\">\n              <FanNav\n                dir=\"prev\"\n                onClick={() => page(-1)}\n                onEngage={engage}\n                style={{ left: w / 2 - halfSpan - 34, top: topBase + fanLift + cardH / 2 - 14 }}\n              />\n              <FanNav\n                dir=\"next\"\n                onClick={() => page(1)}\n                onEngage={engage}\n                style={{ left: w / 2 + halfSpan + 6, top: topBase + fanLift + cardH / 2 - 14 }}\n              />\n            </div>\n          )}\n        </AnimatePresence>\n\n        {/* front pocket — frosted glass, tilts open, sits ABOVE the files */}\n        <motion.div\n          className=\"absolute inset-x-0 bottom-0 origin-bottom\"\n          // No `transformStyle: preserve-3d` here: inside a 3D rendering\n          // context Chrome samples a backdrop-filter's backdrop per composited\n          // layer, clipped to that layer's bounds, so the frosted glass showed\n          // each card as a hard-edged blurred rectangle instead of a soft\n          // spread. The tilt only needs the outer wrapper's `perspective`.\n          style={{\n            height: pocketH,\n            zIndex: 5,\n            borderRadius: r,\n            boxShadow: \"0 -2px 10px rgba(0,0,0,0.05)\",\n          }}\n          initial={false}\n          animate={{ rotateX: open ? -34 : 0 }}\n          transition={reduce ? { duration: 0 } : SPRING}\n        >\n          <div\n            className=\"relative overflow-hidden\"\n            style={{\n              width: w,\n              height: pocketH,\n              clipPath: `path(\"${pocketPath}\")`,\n              // Chrome clips a backdrop-filter to the element's border-radius\n              // but, on some GPU paths, not to a `path()` clip - the blurred\n              // backdrop then paints the full box and shows as dark blocks at\n              // the four corners outside the squircle. A rounded rect at\n              // 1.25r hugs this squircle to within 0.003r without ever cutting\n              // it, so the compositor clips the blur and the path only trims\n              // the last sub-pixel.\n              borderRadius: r * 1.25,\n              // Relative OKLCH lightness (not a color-mix toward white) so the\n              // bump is perceptually consistent for every base colour — mixing\n              // a fixed % of white lightens a near-black colour far more than\n              // a near-white one.\n              background: `oklch(from ${color} calc(l + 0.08) c h / 0.92)`,\n              backdropFilter: `blur(${blur}px)`,\n              WebkitBackdropFilter: `blur(${blur}px)`,\n              boxShadow: \"inset 0 1.5px 0 rgba(255,255,255,0.55)\",\n            }}\n          >\n            {(Icon || stickers) && (\n              <div\n                className=\"absolute inset-0 flex flex-col items-center justify-center gap-2\"\n                // Same relative-lightness reasoning as the pocket background,\n                // and the step flips direction at mid-lightness (`sign()`), so\n                // the icon darkens on a light body and lightens on a dark one\n                // — a near-black theme colour no longer crushes it to black.\n                style={{ color: `oklch(from ${color} calc(l - 0.22 * sign(l - 0.5)) c h)` }}\n              >\n                {Icon && <Icon size={Math.round(w * 0.15)} strokeWidth={1.8} />}\n                {stickers && <div className=\"flex items-center gap-3\">{stickers}</div>}\n              </div>\n            )}\n\n            {uploading && (\n              <div className=\"absolute inset-x-4 bottom-3 h-[3px] overflow-hidden rounded-full bg-foreground/10\">\n                <motion.span\n                  className=\"block h-full rounded-full bg-primary\"\n                  initial={{ width: 0 }}\n                  animate={{ width: `${progress}%` }}\n                  transition={{ ease: \"linear\", duration: 0.3 }}\n                />\n              </div>\n            )}\n          </div>\n        </motion.div>\n\n        {interactive && (\n          <motion.div\n            aria-hidden\n            className=\"pointer-events-none absolute -inset-2 rounded-[30px] border-2 border-dashed border-primary/60\"\n            style={{ zIndex: 6 }}\n            initial={false}\n            animate={{ opacity: dragging ? 1 : 0, scale: dragging ? 1 : 0.98 }}\n            transition={{ duration: 0.15 }}\n          />\n        )}\n      </div>\n\n      {((showLabel && label) || (showCount && metaText)) && (\n        <div className=\"flex flex-col items-center gap-1.5 text-center\">\n          {showLabel && label && (\n            <span className=\"text-[15px] font-semibold tracking-[-0.02em] text-foreground\">{label}</span>\n          )}\n          {showCount && metaText && (\n            <span className=\"rounded-full bg-muted px-2.5 py-0.5 text-[12px] font-medium text-muted-foreground\">\n              {metaText}\n            </span>\n          )}\n        </div>\n      )}\n\n      <style>{`\n        :root {\n          --folder-card: #ffffff;\n          --folder-card-accent: #e6e8ee;\n          --folder-card-line: rgba(0,0,0,0.08);\n          --folder-card-line-strong: rgba(0,0,0,0.16);\n        }\n        .dark {\n          --folder-card: #eceef3;\n          --folder-card-accent: #d5d8e0;\n          --folder-card-line: rgba(0,0,0,0.10);\n          --folder-card-line-strong: rgba(0,0,0,0.20);\n        }\n      `}</style>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/custom/blocks/FolderBlock.tsx"
    }
  ],
  "docs": "Motion build of `folder` — same props, markup and install path; install one or the other. Named exports: Folder, SkeletonCard (plus FolderFile / FolderProps types). `files` is { thumb?, name?, progress? }[]; without files, `peek` skeleton cards show. `color` tints body, pocket, icon and cards via OKLCH; `icon` is a lucide component. Hover, tap or focus opens the fan; past five files it pages with arrows or by dragging a card. `interactive` adds drag-and-drop + click-to-browse with `onFiles`, `progress` and `onRemoveFile`. Pulls in `tooltip` for file-name hovers.",
  "categories": [
    "data-display"
  ],
  "type": "registry:component"
}
