{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"folder-hover","type":"registry:component","title":"Folder Hover Tabs","description":"Service folders that lift and fan three photos open on hover.","author":"DiMaac (HankTheTank) <https://dimaac.com>","dependencies":["gsap"],"files":[{"path":"registry/dimaac/folder-hover/folder-hover.tsx","type":"registry:component","target":"components/dimaac/folder-hover.tsx","content":"\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { gsap } from \"gsap\";\n\n// Service folders stacked along the bottom of the page. Point at one and its tab lifts, the others\n// fade back, and three photos slide out from behind the front and fan open above the tab.\n// Mouse: hover. Keyboard: Tab onto a folder (Escape closes it). Touch: tap a folder, tap it again to close.\n\nexport type FolderItem = {\n  /** The name on the tab, e.g. \"Branding\". */\n  title: string;\n  /** The small number or word above the title, e.g. \"01\". */\n  label?: string;\n  /** The photos inside, left to right. The first three are used. */\n  images: string[];\n  /** Alt text for each photo, in the same order. */\n  alts?: string[];\n  /** This folder's colour. Used unless the `color` prop paints every folder the same. */\n  color?: string;\n};\n\nexport type FolderHoverProps = {\n  /** The folders, in reading order. Five fill two rows nicely (2 then 3). */\n  folders: FolderItem[];\n  /** The widest tilt of the photos when a folder opens, in degrees. The left photo leans left, the right one leans right. */\n  fan?: number;\n  /** How far the open folder's front and tab rise, in pixels. */\n  lift?: number;\n  /** How long the folder takes to open, in seconds. */\n  duration?: number;\n  /** The GSAP ease of the photos sliding out, e.g. \"back.out(1.4)\" or \"power3.out\". */\n  ease?: string;\n  /** The colour used when `colorMode` is \"single\". */\n  color?: string;\n  /** \"own\" gives each folder its own colour, \"single\" paints them all with `color`. */\n  colorMode?: \"own\" | \"single\";\n  /** The colour of the page behind the folders. The folders you are not pointing at fade toward it. Any CSS colour or var(). */\n  paper?: string;\n  /** The colour of text on the page. The faded folders print their titles in a soft mix of it. Any CSS colour or var(). */\n  ink?: string;\n  /** The most folders in one row. Narrow boxes use fewer, so the folders never get too thin. */\n  perRow?: number;\n  /** The height of one folder in pixels (the tab sits on top of it). Narrow boxes shrink it a quarter. */\n  folderHeight?: number;\n  /** The index of a folder that starts open, or null for none. It closes as soon as the pointer leaves it. */\n  defaultOpen?: number | null;\n  /** Extra classes for the outer box. Give it a size (it fills it). */\n  className?: string;\n};\n\n// Used when a folder has no colour of its own\nconst FALLBACK_COLOURS = [\"#f5e04a\", \"#d4d4d4\", \"#e8e4dc\", \"#9c9c9c\", \"#ffcc4d\"];\n// The text printed on a folder. The folders are always light-ish paper, so this stays dark in both themes.\nconst FOLDER_INK = \"#1a1a1a\";\n// A folder never gets thinner than this, so titles keep fitting\nconst MIN_FOLDER_WIDTH = 170;\n\n// Where the three photos sit (-1 left, 0 middle, 1 right of the centre of the photo box) and how far\n// each may tilt, as a share of `fan`. The middle photo is the only one that can lean either way.\nconst SLOTS = [\n  { side: -1, tilt: [-1, -0.5] },\n  { side: 0, tilt: [-0.5, 0.5] },\n  { side: 1, tilt: [0.5, 1] },\n] as const;\n\n/** Splits the folders into rows, filling the bottom row first: 5 folders at 3 per row become 2 + 3. */\nfunction toRows(count: number, perRow: number): number[][] {\n  const rows: number[][] = [];\n  for (let end = count; end > 0; end -= perRow) {\n    const start = Math.max(0, end - perRow);\n    rows.unshift(Array.from({ length: end - start }, (_, i) => start + i));\n  }\n  return rows;\n}\n\nexport function FolderHover({\n  folders,\n  fan = 18,\n  lift = 24,\n  duration = 0.6,\n  ease = \"back.out(1.4)\",\n  color = \"#f5e04a\",\n  colorMode = \"own\",\n  paper = \"#ece8e1\",\n  ink = \"#1a1a1a\",\n  perRow = 3,\n  folderHeight = 200,\n  defaultOpen = null,\n  className = \"\",\n}: FolderHoverProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const pointerType = useRef(\"mouse\");\n\n  // The folder that is open right now. React state, so the colours and aria-expanded follow it.\n  const [open, setOpen] = useState<number | null>(defaultOpen);\n  // The folder that has keyboard focus (it gets a ring on its front, because the button itself stays put while the front lifts)\n  const [focused, setFocused] = useState<number | null>(null);\n  // How many folders fit in a row at the current width (measured below)\n  const [columns, setColumns] = useState(perRow);\n\n  useEffect(() => {\n    const root = rootRef.current;\n    if (!root) return;\n    const measure = () => {\n      const fit = Math.floor(root.clientWidth / MIN_FOLDER_WIDTH);\n      setColumns(Math.max(1, Math.min(perRow, fit)));\n    };\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(root);\n    return () => observer.disconnect();\n  }, [perRow]);\n\n  // Everything is sized from the folder height, so a narrow box just uses a smaller one\n  const height = columns >= 3 ? folderHeight : Math.round(folderHeight * 0.75);\n  const tabHeight = Math.round(height * 0.24);\n  const photoWidth = Math.round(height * 0.6);\n  const photoHeight = Math.round(height * 0.88);\n  const restTop = Math.round(height * 0.06);\n  // Open: the photos end up with about 60% of their height above the lifted tab\n  const openTop = -(tabHeight + lift) - photoHeight * 0.62;\n  const travel = openTop - restTop;\n\n  // Opens the folder that is `open` and closes the rest. It runs again whenever the open folder or a\n  // setting changes, and every folder gets fresh tweens each time (`overwrite: true` removes the old\n  // ones, even those still waiting out their delay), so quick hovering from folder to folder never\n  // leaves a photo stranded. A plain effect instead of useGSAP: useGSAP would either revert the\n  // photos to the start on every change or keep every old tween around.\n  useEffect(() => {\n    const root = rootRef.current;\n    if (!root) return;\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    // With reduced motion the folder still opens, it just does it in one step\n    const time = reduced ? 0 : duration;\n    // Found by data attributes, so a folder that moved to another row is still found\n    const panels = Array.from(root.querySelectorAll<HTMLElement>(\"[data-panel]\"));\n    const photos = Array.from(root.querySelectorAll<HTMLElement>(\"[data-photo]\"));\n\n    root.querySelectorAll<HTMLElement>(\"[data-folder]\").forEach((folderElement) => {\n      const isOpen = Number(folderElement.dataset.folder) === open;\n      const panel = folderElement.querySelector<HTMLElement>(\"[data-panel]\");\n      if (panel) {\n        gsap.to(panel, { y: isOpen ? -lift : 0, duration: time * (isOpen ? 0.7 : 0.6), ease: \"power3.out\", overwrite: true });\n      }\n      folderElement.querySelectorAll<HTMLElement>(\"[data-photo]\").forEach((photo, slot) => {\n        const [low, high] = SLOTS[slot]!.tilt;\n        gsap.to(photo, {\n          y: isOpen ? travel : 0,\n          rotation: isOpen ? gsap.utils.random(low * fan, high * fan) : 0,\n          duration: isOpen ? time : time * 0.7,\n          // Slide out with a little overshoot, tuck back without one\n          ease: isOpen ? ease : \"power2.inOut\",\n          // The photos leave in order and return in order\n          delay: time * (isOpen ? 0.06 : 0.04) * slot,\n          overwrite: true,\n        });\n        // Closed photos are hidden outright. Their shadows would otherwise leak out of a narrow folder\n        // onto its neighbour. Fading is quick and shares the photo's delay, so it never shows mid-rise.\n        gsap.to(photo, {\n          autoAlpha: isOpen ? 1 : 0,\n          duration: time === 0 ? 0 : 0.12,\n          delay: isOpen ? time * 0.06 * slot : time * (0.04 * slot + 0.6),\n          ease: \"none\",\n          overwrite: \"auto\",\n        });\n      });\n    });\n\n    return () => {\n      gsap.killTweensOf([...panels, ...photos]);\n    };\n  }, [open, fan, lift, duration, ease, travel, folders.length, columns]);\n\n  const openFolder = (index: number) => setOpen(index);\n  const closeFolder = (index: number) => setOpen((current) => (current === index ? null : current));\n\n  const rows = toRows(folders.length, columns);\n\n  return (\n    <div\n      ref={rootRef}\n      // The side gutter leaves room for the photos an edge folder fans out\n      className={`relative flex h-full w-full flex-col justify-end overflow-hidden px-7 md:px-12 ${className}`}\n      style={{ containerType: \"inline-size\" }}\n      onPointerDown={(event) => {\n        pointerType.current = event.pointerType;\n      }}\n      onClick={(event) => {\n        // A tap on the empty page closes the open folder on touch screens\n        if (event.target === event.currentTarget && pointerType.current === \"touch\") setOpen(null);\n      }}\n    >\n      {rows.map((row, rowIndex) => (\n        <div key={rowIndex} className=\"flex w-full\">\n          {row.map((index) => {\n            const folder = folders[index]!;\n            const base = colorMode === \"single\" ? color : (folder.color ?? FALLBACK_COLOURS[index % FALLBACK_COLOURS.length]!);\n            const isOpen = open === index;\n            // While another folder is open this one fades toward the page colour\n            const faded = open !== null && !isOpen;\n            const fill = faded ? `color-mix(in srgb, ${base} 18%, ${paper})` : base;\n            const textColor = faded ? `color-mix(in srgb, ${ink} 38%, color-mix(in srgb, ${base} 18%, ${paper}))` : FOLDER_INK;\n            const slide = `background-color ${Math.max(duration * 0.5, 0.15)}s ease, color ${Math.max(duration * 0.5, 0.15)}s ease`;\n            const alts = folder.alts ?? [];\n\n            return (\n              <div key={index} data-folder={index} className=\"relative min-w-0 flex-1\" style={{ height }}>\n                {/* The photos sit behind the front of the folder until it opens. `isolate` keeps their z-index inside this box, so the front always covers them. */}\n                <div aria-hidden={!isOpen} className=\"pointer-events-none absolute top-0 isolate h-full\" style={{ left: 10, width: \"min(22rem, calc(100% - 20px))\" }}>\n                  {SLOTS.map((slot, photoIndex) => {\n                    const src = folder.images[photoIndex];\n                    if (!src) return null;\n                    return (\n                      <div\n                        key={photoIndex}\n                        data-photo={photoIndex}\n                        className=\"invisible absolute overflow-hidden rounded-[3px] border-[3px] border-[#f4f1ea] bg-[#f4f1ea] shadow-[0_14px_28px_-10px_rgba(0,0,0,0.45)]\"\n                        style={{\n                          // 24% of the box to each side, but never so far that a photo sticks out of a narrow folder\n                          left: `calc(50% + ${slot.side} * min(24%, 50% - ${photoWidth / 2 + 14}px))`,\n                          top: restTop,\n                          width: photoWidth,\n                          height: photoHeight,\n                          marginLeft: -photoWidth / 2,\n                          transformOrigin: \"50% 100%\",\n                          // The middle photo sits on top of the other two\n                          zIndex: photoIndex === 1 ? 2 : 1,\n                          willChange: \"transform\",\n                        }}\n                      >\n                        <img src={src} alt={alts[photoIndex] ?? \"\"} className=\"block h-full w-full object-cover\" draggable={false} decoding=\"async\" />\n                      </div>\n                    );\n                  })}\n                </div>\n\n                {/* The front of the folder and its tab (the tab overlaps the front by a pixel so no seam shows the photos). It reaches `lift` pixels below the folder so nothing shows underneath when it rises. */}\n                <div\n                  data-panel=\"\"\n                  aria-hidden=\"true\"\n                  className=\"pointer-events-none absolute inset-x-0 top-0\"\n                  style={{ bottom: -lift, backgroundColor: fill, color: textColor, transition: slide, willChange: \"transform\", boxShadow: focused === index ? `inset 0 0 0 3px ${FOLDER_INK}` : \"none\" }}\n                >\n                  <span\n                    className=\"absolute left-0\"\n                    style={{ top: -tabHeight, height: tabHeight + 1, width: \"min(360px, 100%)\", backgroundColor: \"inherit\", clipPath: \"polygon(0 0, 78% 0, 100% 100%, 0 100%)\" }}\n                  />\n                  <span className=\"absolute text-[11px] font-semibold tracking-[0.06em]\" style={{ left: 16, top: -tabHeight * 0.58 }}>\n                    {folder.label ?? String(index + 1).padStart(2, \"0\")}\n                  </span>\n                  <div className=\"font-semibold tracking-[-0.01em]\" style={{ padding: \"12px 16px\", fontSize: \"clamp(1.25rem, 2.6cqw, 2.25rem)\", lineHeight: 1.1 }}>\n                    {folder.title}\n                  </div>\n                </div>\n\n                {/* The real control: it covers the tab and the front, so pointing at the tab counts too */}\n                <button\n                  type=\"button\"\n                  aria-expanded={isOpen}\n                  className=\"absolute right-0 bottom-0 left-0 cursor-pointer border-0 bg-transparent p-0 outline-none\"\n                  style={{ top: -(tabHeight + lift + 4) }}\n                  onPointerEnter={(event) => {\n                    if (event.pointerType !== \"touch\") openFolder(index);\n                  }}\n                  onPointerLeave={(event) => {\n                    if (event.pointerType !== \"touch\") closeFolder(index);\n                  }}\n                  onClick={() => {\n                    // On touch there is no hover, so a tap opens it and another tap closes it\n                    if (pointerType.current === \"touch\") setOpen((current) => (current === index ? null : index));\n                  }}\n                  onFocus={(event) => {\n                    // Only keyboard focus opens it (a mouse click focuses too, and hover already handles that)\n                    if (!event.currentTarget.matches(\":focus-visible\")) return;\n                    setFocused(index);\n                    openFolder(index);\n                  }}\n                  onBlur={() => {\n                    setFocused((current) => (current === index ? null : current));\n                    closeFolder(index);\n                  }}\n                  onKeyDown={(event) => {\n                    if (event.key === \"Escape\") closeFolder(index);\n                  }}\n                >\n                  <span className=\"sr-only\">{folder.title}</span>\n                </button>\n              </div>\n            );\n          })}\n        </div>\n      ))}\n    </div>\n  );\n}\n"}],"categories":["cards","gsap","hover","folders","images","services"],"docs":"import { FolderHover } from \"@/components/dimaac/folder-hover\". Give its parent a size: it fills it. Docs, playground and props: https://ui.dimaac.com/components/folder-hover","meta":{"tier":"free","preview":"https://ui.dimaac.com/components/folder-hover","credits":[{"by":"Dona Mara","url":"https://www.lummi.ai/photo/elegant-perfume-bottle-j5t5p","what":"Photo: Elegant perfume bottle","source":"Lummi","licence":"Lummi License"},{"by":"Amino","url":"https://www.lummi.ai/photo/elegant-perfume-bottles-vnjg1","what":"Photo: Elegant perfume bottles","source":"Lummi","licence":"Lummi License"},{"by":"Clemara Studio","url":"https://www.lummi.ai/photo/minimalist-skincare-tube-tmejb","what":"Photo: Minimalist skincare tube","source":"Lummi","licence":"Lummi License"},{"by":"Kuba","url":"https://www.lummi.ai/photo/abstract-eye-close-up-uamae","what":"Photo: Abstract eye close-up","source":"Lummi","licence":"Lummi License"},{"by":"Axel Photography","url":"https://www.lummi.ai/photo/modern-fashion-pose-zsdav","what":"Photo: Modern fashion pose","source":"Lummi","licence":"Lummi License"},{"by":"Nika","url":"https://www.lummi.ai/photo/serene-gradient-landscape-80b2l","what":"Photo: Serene gradient landscape","source":"Lummi","licence":"Lummi License"},{"by":"rena","url":"https://www.lummi.ai/photo/modern-architectural-design-aozka","what":"Photo: Modern architectural design","source":"Lummi","licence":"Lummi License"},{"by":"Viri Gutiérrez","url":"https://www.lummi.ai/photo/contemplative-silhouette-against-modern-minimalist-architecture-1-haa","what":"Photo: Contemplative silhouette against minimalist architecture","source":"Lummi","licence":"Lummi License"},{"by":"Jupiter et Passagère®","url":"https://www.lummi.ai/photo/modern-minimalist-building-kumrj","what":"Photo: Modern minimalist building","source":"Lummi","licence":"Lummi License"},{"by":"SHIHO","url":"https://www.lummi.ai/photo/vibrant-cylindrical-arrangement-pj9pp","what":"Photo: Vibrant cylindrical arrangement","source":"Lummi","licence":"Lummi License"},{"by":"SHIHO","url":"https://www.lummi.ai/photo/mystery-portrait-cffyo","what":"Photo: Mystery portrait","source":"Lummi","licence":"Lummi License"},{"by":"Bernardo Lickfold","url":"https://www.lummi.ai/photo/portrait-through-orange-frame-kld5i","what":"Photo: Portrait through orange frame","source":"Lummi","licence":"Lummi License"},{"by":"Ricardo Matos","url":"https://www.lummi.ai/photo/surreal-3d-planet-scene-ofhhw","what":"Photo: Surreal 3D planet scene","source":"Lummi","licence":"Lummi License"},{"by":"Dona Mara","url":"https://www.lummi.ai/photo/abstract-sculptural-objects-flp-3","what":"Photo: Abstract sculptural objects","source":"Lummi","licence":"Lummi License"},{"by":"Nika","url":"https://www.lummi.ai/photo/intricate-natural-texture-buk3_","what":"Photo: Intricate natural texture","source":"Lummi","licence":"Lummi License"}]}}