{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion"],"description":"A FolderReveal component for SmoothUI.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport type { KeyboardEvent, ReactNode } from \"react\";\nimport { useCallback, useEffect, useId, useRef, useState } from \"react\";\n\nexport type FolderRevealTrigger = \"hover\" | \"click\";\nexport type FolderRevealColor = \"manila\" | \"brand\" | \"blue\" | \"green\" | \"slate\";\n\nexport interface FolderRevealItem {\n  /** Card surface colour. Defaults to the theme background. */\n  color?: string;\n  content: ReactNode;\n  id: string;\n}\n\nexport interface FolderRevealProps {\n  className?: string;\n  /** Folder stock: `manila` (default) or a theme-tinted variant. */\n  color?: FolderRevealColor;\n  items: FolderRevealItem[];\n  label?: string;\n  onOpenChange?: (open: boolean) => void;\n  open?: boolean;\n  /** Degrees of rotation applied per card as the fan spreads. */\n  rotation?: number;\n  /** Folder width in px. Height follows the folder aspect ratio. */\n  size?: number;\n  /** Horizontal spread (px) between fanned cards. */\n  spread?: number;\n  trigger?: FolderRevealTrigger;\n}\n\nconst DEFAULT_SPREAD = 62;\nconst DEFAULT_ROTATION = 7;\nconst DEFAULT_SIZE = 260;\n\n/** Real folders are wider than tall — this ratio is what sells the silhouette. */\nconst ASPECT = 0.78;\nconst FLAP_TOP_RATIO = 0.44;\nconst CARD_WIDTH_RATIO = 0.6;\nconst CARD_HEIGHT_RATIO = 0.5;\nconst CARD_BOTTOM_RATIO = 0.16;\nconst FAN_LIFT_RATIO = 0.36;\nconst CLOSED_SCALE = 0.97;\nconst FLAP_OPEN_ROTATE_X = -32;\nconst STAGGER_STEP = 0.035;\nconst CLOSE_STAGGER_STEP = 0.018;\nconst PERSPECTIVE = 1000;\nconst SHADOW_CLOSED_OPACITY = 0.28;\nconst SHADOW_OPEN_OPACITY = 0.55;\nconst SHADOW_CLOSED_SCALE = 0.88;\nconst SHADOW_OPEN_SCALE = 1.06;\n\n/**\n * Back panel silhouette drawn as one path so the raised tab keeps crisp,\n * correctly-rounded joins at any size. viewBox is 320×250 (= ASPECT).\n * The tab on the top-left is the single detail that makes a shape read\n * as \"folder\" rather than \"rounded rectangle\".\n */\nconst BACK_PANEL_PATH =\n  \"M 0 14 Q 0 0 14 0 L 108 0 Q 118 0 124 8 L 137 25 Q 143 33 153 33 L 306 33 Q 320 33 320 47 L 320 236 Q 320 250 306 250 L 14 250 Q 0 250 0 236 Z\";\n\ninterface FolderSkin {\n  back: string;\n  backDeep: string;\n  edge: string;\n  flap: string;\n  flapDeep: string;\n  label: string;\n}\n\n/**\n * Stock colours in oklch so each variant keeps one hue across its own ramp\n * and the front flap sits a consistent step lighter than the back panel —\n * that lightness gap is what separates the two planes.\n */\nconst FOLDER_SKINS: Record<FolderRevealColor, FolderSkin> = {\n  blue: {\n    back: \"oklch(0.56 0.13 250)\",\n    backDeep: \"oklch(0.49 0.14 250)\",\n    edge: \"oklch(0.84 0.07 252)\",\n    flap: \"oklch(0.64 0.14 251)\",\n    flapDeep: \"oklch(0.57 0.15 251)\",\n    label: \"oklch(0.98 0.01 250)\",\n  },\n  brand: {\n    back: \"oklch(0.63 0.17 352)\",\n    backDeep: \"oklch(0.56 0.18 352)\",\n    edge: \"oklch(0.87 0.08 352)\",\n    flap: \"oklch(0.72 0.17 352)\",\n    flapDeep: \"oklch(0.65 0.18 352)\",\n    label: \"oklch(0.99 0.01 352)\",\n  },\n  green: {\n    back: \"oklch(0.6 0.11 155)\",\n    backDeep: \"oklch(0.53 0.12 155)\",\n    edge: \"oklch(0.86 0.06 157)\",\n    flap: \"oklch(0.68 0.12 156)\",\n    flapDeep: \"oklch(0.61 0.13 156)\",\n    label: \"oklch(0.99 0.01 155)\",\n  },\n  manila: {\n    back: \"oklch(0.8 0.075 82)\",\n    backDeep: \"oklch(0.73 0.08 79)\",\n    edge: \"oklch(0.96 0.035 88)\",\n    flap: \"oklch(0.87 0.068 85)\",\n    flapDeep: \"oklch(0.8 0.074 83)\",\n    label: \"oklch(0.4 0.05 68)\",\n  },\n  slate: {\n    back: \"oklch(0.6 0.018 262)\",\n    backDeep: \"oklch(0.53 0.02 262)\",\n    edge: \"oklch(0.87 0.01 262)\",\n    flap: \"oklch(0.68 0.016 262)\",\n    flapDeep: \"oklch(0.61 0.018 262)\",\n    label: \"oklch(0.99 0 0)\",\n  },\n};\n\nconst OPEN_TRANSITION = {\n  bounce: 0.1,\n  duration: 0.25,\n  type: \"spring\" as const,\n};\n\nconst FolderReveal = ({\n  className,\n  color = \"manila\",\n  items,\n  label = \"Folder\",\n  onOpenChange,\n  open: openProp,\n  rotation = DEFAULT_ROTATION,\n  size = DEFAULT_SIZE,\n  spread = DEFAULT_SPREAD,\n  trigger = \"hover\",\n}: FolderRevealProps) => {\n  const shouldReduceMotion = useReducedMotion();\n  const [internalOpen, setInternalOpen] = useState(false);\n  const isControlled = openProp !== undefined;\n  const open = isControlled ? openProp : internalOpen;\n  const isHoverCapableRef = useRef(false);\n  const gradientId = useId();\n\n  useEffect(() => {\n    const mediaQuery = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n    isHoverCapableRef.current = mediaQuery.matches;\n\n    const handleChange = (event: MediaQueryListEvent) => {\n      isHoverCapableRef.current = event.matches;\n    };\n\n    mediaQuery.addEventListener(\"change\", handleChange);\n    return () => mediaQuery.removeEventListener(\"change\", handleChange);\n  }, []);\n\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (!isControlled) {\n        setInternalOpen(next);\n      }\n      onOpenChange?.(next);\n    },\n    [isControlled, onOpenChange]\n  );\n\n  const handleMouseEnter = () => {\n    if (trigger === \"hover\" && isHoverCapableRef.current) {\n      setOpen(true);\n    }\n  };\n\n  const handleMouseLeave = () => {\n    if (trigger === \"hover\" && isHoverCapableRef.current) {\n      setOpen(false);\n    }\n  };\n\n  const handleFocus = () => {\n    if (trigger === \"hover\") {\n      setOpen(true);\n    }\n  };\n\n  const handleBlur = () => {\n    if (trigger === \"hover\") {\n      setOpen(false);\n    }\n  };\n\n  const handleClick = () => {\n    if (trigger === \"click\") {\n      setOpen(!open);\n    }\n  };\n\n  const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {\n    if (trigger === \"hover\" && (event.key === \"Enter\" || event.key === \" \")) {\n      event.preventDefault();\n      setOpen(!open);\n    }\n  };\n\n  const height = size * ASPECT;\n  const flapHeight = height * (1 - FLAP_TOP_RATIO);\n  const cardWidth = size * CARD_WIDTH_RATIO;\n  const cardHeight = height * CARD_HEIGHT_RATIO;\n  const skin = FOLDER_SKINS[color];\n\n  const itemCount = items.length;\n  const center = (itemCount - 1) / 2;\n  const topCardId = items.at(-1)?.id;\n\n  return (\n    // biome-ignore lint/a11y/noStaticElementInteractions: hover zone wraps the real button trigger so the whole folder is hoverable\n    <div\n      className={cn(\"relative select-none\", className)}\n      onMouseEnter={handleMouseEnter}\n      onMouseLeave={handleMouseLeave}\n      style={{\n        height,\n        perspective: shouldReduceMotion ? undefined : PERSPECTIVE,\n        width: size,\n      }}\n    >\n      {/* Contact shadow — grows and softens as the folder opens. */}\n      <motion.div\n        animate={{\n          opacity: open ? SHADOW_OPEN_OPACITY : SHADOW_CLOSED_OPACITY,\n          scaleX: open ? SHADOW_OPEN_SCALE : SHADOW_CLOSED_SCALE,\n        }}\n        aria-hidden=\"true\"\n        className=\"absolute -bottom-4 left-1/2 h-6 w-[88%] -translate-x-1/2 rounded-[50%] bg-foreground/45 blur-lg\"\n        initial={false}\n        transition={shouldReduceMotion ? { duration: 0 } : OPEN_TRANSITION}\n      />\n\n      {/* Back panel + tab. */}\n      <svg\n        aria-hidden=\"true\"\n        className=\"absolute inset-0 h-full w-full drop-shadow-[0_1px_1px_rgba(0,0,0,0.12)]\"\n        fill=\"none\"\n        viewBox=\"0 0 320 250\"\n        xmlns=\"http://www.w3.org/2000/svg\"\n      >\n        <title>Folder</title>\n        <defs>\n          <linearGradient id={gradientId} x1=\"0\" x2=\"0\" y1=\"0\" y2=\"1\">\n            <stop offset=\"0%\" stopColor={skin.back} />\n            <stop offset=\"100%\" stopColor={skin.backDeep} />\n          </linearGradient>\n        </defs>\n        <path d={BACK_PANEL_PATH} fill={`url(#${gradientId})`} />\n      </svg>\n\n      {/* Tab label, printed on the raised tab like a real folder. */}\n      <span\n        aria-hidden=\"true\"\n        className=\"absolute top-0 left-0 flex items-center truncate px-3 font-semibold text-[11px] tracking-[0.01em]\"\n        style={{\n          color: skin.label,\n          height: height * 0.132,\n          maxWidth: size * 0.34,\n        }}\n      >\n        {label}\n      </span>\n\n      {/* Well — the inner shadow the papers sit in. */}\n      <div\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-x-0 bottom-0 rounded-b-[14px]\"\n        style={{\n          boxShadow: \"inset 0 14px 16px -14px rgba(0,0,0,0.45)\",\n          top: height * 0.132,\n        }}\n      />\n\n      {/* Cards. They peek above the flap when closed and fan when open. */}\n      <div\n        aria-hidden={!open}\n        className=\"pointer-events-none absolute inset-x-0 flex justify-center\"\n        style={{ bottom: height * CARD_BOTTOM_RATIO, zIndex: 10 }}\n      >\n        {items.map((item, index) => {\n          const offset = index - center;\n          const isTopCard = item.id === topCardId;\n\n          return (\n            <motion.div\n              animate={\n                open\n                  ? {\n                      opacity: 1,\n                      rotate: shouldReduceMotion ? 0 : offset * rotation,\n                      scale: 1,\n                      x: shouldReduceMotion ? 0 : offset * spread,\n                      y: shouldReduceMotion ? 0 : -height * FAN_LIFT_RATIO,\n                    }\n                  : {\n                      opacity: isTopCard || shouldReduceMotion ? 1 : 0,\n                      rotate: 0,\n                      scale: CLOSED_SCALE,\n                      x: 0,\n                      y: 0,\n                    }\n              }\n              className=\"absolute bottom-0 origin-bottom overflow-hidden rounded-[10px] bg-background shadow-[0_1px_2px_rgba(0,0,0,0.08),0_8px_18px_-8px_rgba(0,0,0,0.28)] ring-1 ring-foreground/10\"\n              initial={false}\n              key={item.id}\n              style={{\n                backgroundColor: item.color,\n                height: cardHeight,\n                width: cardWidth,\n                zIndex: index,\n              }}\n              transition={\n                shouldReduceMotion\n                  ? { duration: 0 }\n                  : {\n                      ...OPEN_TRANSITION,\n                      delay: open\n                        ? index * STAGGER_STEP\n                        : (itemCount - 1 - index) * CLOSE_STAGGER_STEP,\n                    }\n              }\n            >\n              {item.content}\n            </motion.div>\n          );\n        })}\n      </div>\n\n      {/* Front flap — overlaps the cards so the illusion holds. */}\n      <motion.button\n        animate={\n          shouldReduceMotion\n            ? { rotateX: 0 }\n            : { rotateX: open ? FLAP_OPEN_ROTATE_X : 0 }\n        }\n        aria-expanded={open}\n        aria-label={label}\n        className=\"absolute inset-x-0 bottom-0 cursor-pointer rounded-t-[8px] rounded-b-[14px] outline-none ring-offset-2 ring-offset-background focus-visible:ring-2 focus-visible:ring-ring\"\n        initial={false}\n        onBlur={handleBlur}\n        onClick={handleClick}\n        onFocus={handleFocus}\n        onKeyDown={handleKeyDown}\n        style={{\n          backgroundImage: `linear-gradient(to bottom, ${skin.flap}, ${skin.flapDeep})`,\n          boxShadow: `inset 0 1px 0 ${skin.edge}, 0 -2px 6px -2px rgba(0,0,0,0.18), 0 10px 22px -12px rgba(0,0,0,0.4)`,\n          height: flapHeight,\n          transformOrigin: \"bottom\",\n          transformStyle: \"preserve-3d\",\n          zIndex: 20,\n        }}\n        transition={shouldReduceMotion ? { duration: 0 } : OPEN_TRANSITION}\n        type=\"button\"\n      />\n    </div>\n  );\n};\n\nexport default FolderReveal;\n","path":"index.tsx","target":"components/smoothui/folder-reveal/index.tsx","type":"registry:ui"}],"name":"folder-reveal","registryDependencies":[],"title":"Folder Reveal","type":"registry:ui"}