{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"direction-aware-hover","type":"registry:ui","title":"DirectionAwareHover","description":"Image card with overlay that slides in from the mouse entry direction","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["cards"],"registryDependencies":["https://fancy-ui.rama.app/r/internals.json","https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/internals-motion.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/direction-aware-hover/DirectionAwareHover.tsx","content":"\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport type { MouseEvent as ReactMouseEvent, ReactNode, TouchEvent as ReactTouchEvent } from \"react\";\n\nimport { cn } from \"../../utils\";\nimport { linear } from \"../../internals/motion/easing\";\nimport { prefersReducedMotion } from \"../../internals/motion/anchored\";\nimport { usePresence } from \"../../internals/motion/presence\";\nimport type { TransitionSpec } from \"../../internals/motion/transitions\";\n\nimport \"./direction-aware-hover.css\";\n\ntype Direction = \"top\" | \"bottom\" | \"left\" | \"right\";\n\nexport interface DirectionAwareHoverProps {\n\timageUrl: string;\n\timageAlt?: string;\n\tchildrenClass?: string;\n\timageClass?: string;\n\tclassName?: string;\n\tchildren?: ReactNode;\n}\n\n/** The source's stock fade transition with `{ duration: 300 }`: capture the\n *  element's computed opacity at leg start, then run `opacity: t * o` under\n *  linear easing. Bidirectional — the same spec drives both legs. */\nfunction fade300(node: Element): TransitionSpec {\n\tconst o = +getComputedStyle(node).opacity;\n\treturn {\n\t\tdelay: 0,\n\t\t// JS-timed fade collapses to 0 under reduced motion, read live at leg start.\n\t\tduration: prefersReducedMotion() ? 0 : 300,\n\t\teasing: linear,\n\t\tcss: (t) => `opacity: ${t * o}`,\n\t};\n}\n\nfunction getDirection(\n\tev: { clientX: number; clientY: number },\n\tobj: HTMLElement\n): number {\n\tconst { width: w, height: h, left, top } = obj.getBoundingClientRect();\n\tconst x = ev.clientX - left - (w / 2) * (w > h ? h / w : 1);\n\tconst y = ev.clientY - top - (h / 2) * (h > w ? w / h : 1);\n\tconst d = Math.round(Math.atan2(y, x) / 1.57079633 + 5) % 4;\n\treturn d;\n}\n\nfunction mapDirection(d: number): Direction {\n\tswitch (d) {\n\t\tcase 0:\n\t\t\treturn \"top\";\n\t\tcase 1:\n\t\t\treturn \"right\";\n\t\tcase 2:\n\t\t\treturn \"bottom\";\n\t\tcase 3:\n\t\t\treturn \"left\";\n\t\tdefault:\n\t\t\treturn \"left\";\n\t}\n}\n\nexport function DirectionAwareHover({\n\timageUrl,\n\timageAlt = \"image\",\n\tchildrenClass: childrenClassProp = \"\",\n\timageClass: imageClassProp = \"\",\n\tclassName = \"\",\n\tchildren,\n}: DirectionAwareHoverProps) {\n\tconst divRef = useRef<HTMLDivElement>(null);\n\tconst [direction, setDirection] = useState<Direction | null>(null);\n\tconst [isTouched, setIsTouched] = useState(false);\n\t/** Read only in event handlers and written from a resize listener — no\n\t *  markup depends on it, so it lives in a ref, not state. */\n\tconst isMobileRef = useRef(false);\n\tconst touchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n\t/** The last non-null direction. The source's transition-aware conditional\n\t *  block goes inert while it outros, freezing the overlay's class at its\n\t *  last hovered value for the whole fade-out; React keeps re-rendering the\n\t *  exiting node, so the frozen value is reproduced explicitly. */\n\tconst lastDirectionRef = useRef<Direction | null>(null);\n\tif (direction !== null) lastDirectionRef.current = direction;\n\tconst overlayDirection = direction ?? lastDirectionRef.current;\n\n\tconst overlayPresence = usePresence(direction !== null);\n\tconst overlayRef = overlayPresence.register(fade300);\n\tconst contentPresence = usePresence(direction !== null || isTouched);\n\tconst contentRef = contentPresence.register(fade300);\n\n\tuseEffect(() => {\n\t\tconst detectMobile = () => {\n\t\t\tisMobileRef.current =\n\t\t\t\twindow.matchMedia(\"(max-width: 768px)\").matches || \"ontouchstart\" in window;\n\t\t};\n\n\t\tdetectMobile();\n\t\twindow.addEventListener(\"resize\", detectMobile);\n\n\t\treturn () => {\n\t\t\twindow.removeEventListener(\"resize\", detectMobile);\n\t\t\tif (touchTimerRef.current) {\n\t\t\t\tclearTimeout(touchTimerRef.current);\n\t\t\t}\n\t\t};\n\t}, []);\n\n\tfunction handleMouseEnter(event: ReactMouseEvent<HTMLDivElement>) {\n\t\tif (isMobileRef.current) return;\n\t\tif (!divRef.current) return;\n\n\t\tconst fetchedDirection = getDirection(event, divRef.current);\n\t\tsetDirection(mapDirection(fetchedDirection));\n\t}\n\n\tfunction handleMouseLeave() {\n\t\tif (isMobileRef.current) return;\n\t\tsetDirection(null);\n\t}\n\n\tfunction handleTouchEnd() {\n\t\tif (touchTimerRef.current) {\n\t\t\tclearTimeout(touchTimerRef.current);\n\t\t\ttouchTimerRef.current = null;\n\t\t}\n\n\t\tsetTimeout(() => {\n\t\t\tsetDirection(null);\n\t\t\tsetIsTouched(false);\n\t\t}, 300);\n\t}\n\n\tfunction handleTouchStart(event: ReactTouchEvent<HTMLDivElement>) {\n\t\tif (!isMobileRef.current) return;\n\n\t\tsetIsTouched(true);\n\n\t\tif (!divRef.current) return;\n\t\t// A touchstart always carries at least one touch; the assertion only\n\t\t// silences the indexed-access check.\n\t\tconst touch = event.touches[0]!;\n\t\tconst fetchedDirection = getDirection(\n\t\t\t{ clientX: touch.clientX, clientY: touch.clientY },\n\t\t\tdivRef.current\n\t\t);\n\t\tsetDirection(mapDirection(fetchedDirection));\n\n\t\t// Auto-hide after 3 seconds on mobile\n\t\tif (touchTimerRef.current) clearTimeout(touchTimerRef.current);\n\t\ttouchTimerRef.current = setTimeout(() => {\n\t\t\thandleTouchEnd();\n\t\t}, 3000);\n\t}\n\n\tconst containerClass = cn(\n\t\t// Port-added anchor for the colocated CSS — see direction-aware-hover.css.\n\t\t\"direction-aware-hover\",\n\t\t\"group/card relative overflow-hidden rounded-lg bg-transparent motion-safe:transition-all motion-safe:duration-300\",\n\t\t\"h-48 w-48\",\n\t\t\"sm:h-64 sm:w-64\",\n\t\t\"md:h-80 md:w-80\",\n\t\t\"lg:h-96 lg:w-96\",\n\t\t\"xl:h-[28rem] xl:w-[28rem]\",\n\t\t\"touch-manipulation\",\n\t\t\"active:scale-[0.98]\",\n\t\t\"md:active:scale-100\",\n\t\tclassName\n\t);\n\n\tconst imageClass = cn(\n\t\t\"h-full w-full object-cover motion-safe:transition-transform motion-safe:duration-300\",\n\t\t\"scale-125\",\n\t\t\"sm:scale-[1.35]\",\n\t\t\"md:scale-150\",\n\t\timageClassProp\n\t);\n\n\tconst childrenClass = cn(\n\t\t\"absolute z-40 text-white motion-safe:transition-opacity motion-safe:duration-300\",\n\t\t\"bottom-2 left-2 text-sm\",\n\t\t\"sm:bottom-3 sm:left-3 sm:text-base\",\n\t\t\"md:bottom-4 md:left-4 md:text-lg\",\n\t\tchildrenClassProp\n\t);\n\n\tconst overlayClass = (() => {\n\t\tconst baseClasses = \"absolute inset-0 z-10 motion-safe:transition-all motion-safe:duration-300\";\n\t\tconst backgroundClasses = \"bg-black/40 dark:bg-black/60\";\n\n\t\tlet transformClasses = \"\";\n\t\tswitch (overlayDirection) {\n\t\t\tcase \"top\":\n\t\t\t\ttransformClasses = \"-translate-y-full group-hover/card:translate-y-0\";\n\t\t\t\tbreak;\n\t\t\tcase \"bottom\":\n\t\t\t\ttransformClasses = \"translate-y-full group-hover/card:translate-y-0\";\n\t\t\t\tbreak;\n\t\t\tcase \"left\":\n\t\t\t\ttransformClasses = \"-translate-x-full group-hover/card:translate-x-0\";\n\t\t\t\tbreak;\n\t\t\tcase \"right\":\n\t\t\t\ttransformClasses = \"translate-x-full group-hover/card:translate-x-0\";\n\t\t\t\tbreak;\n\t\t\tdefault:\n\t\t\t\ttransformClasses = \"\";\n\t\t}\n\n\t\treturn cn(baseClasses, backgroundClasses, transformClasses);\n\t})();\n\n\tconst imageContainerClass = cn(\n\t\t\"relative size-full bg-gray-50 motion-safe:transition-transform motion-safe:duration-300 dark:bg-black\",\n\t\t{\n\t\t\t\"translate-y-2 md:translate-y-5\": direction === \"top\",\n\t\t\t\"-translate-y-2 md:-translate-y-5\": direction === \"bottom\",\n\t\t\t\"translate-x-2 md:translate-x-5\": direction === \"left\",\n\t\t\t\"-translate-x-2 md:-translate-x-5\": direction === \"right\",\n\t\t}\n\t);\n\n\treturn (\n\t\t<div\n\t\t\tref={divRef}\n\t\t\tclassName={containerClass}\n\t\t\tonMouseEnter={handleMouseEnter}\n\t\t\tonMouseLeave={handleMouseLeave}\n\t\t\tonTouchStart={handleTouchStart}\n\t\t\tonTouchEnd={handleTouchEnd}\n\t\t\trole=\"figure\"\n\t\t>\n\t\t\t<div className=\"relative size-full overflow-hidden\">\n\t\t\t\t{overlayPresence.mounted && <div ref={overlayRef} className={overlayClass}></div>}\n\n\t\t\t\t<div className={imageContainerClass}>\n\t\t\t\t\t<img src={imageUrl} alt={imageAlt} className={imageClass} width=\"1000\" height=\"1000\" />\n\t\t\t\t</div>\n\n\t\t\t\t{contentPresence.mounted && (\n\t\t\t\t\t<div ref={contentRef} className={childrenClass}>\n\t\t\t\t\t\t{children}\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n","type":"registry:ui","target":"@ui/fancy/components/direction-aware-hover/DirectionAwareHover.tsx"},{"path":"react/src/components/direction-aware-hover/direction-aware-hover.css","content":"/* `.direction-aware-hover` is a port-added anchor (PORTING.md styling rule 2):\n   the source's two rules were `:global()` inside a `<style>` block that only\n   reaches a page which imports the component, because the Svelte package ships\n   raw `.svelte` sources and no aggregated stylesheet. This package aggregates\n   every component `.css` into the single `dist/styles.css` that README tells\n   consumers to import once, so the same two rules would apply to every app that\n   imports the package — including apps that never render this component. Both\n   are therefore anchored on the component's own root class so their reach\n   matches the Svelte side's: the component and what it contains.\n\n   The first rule anchors here rather than on `.group\\/card` because that name\n   is Tailwind's shared named-group vocabulary, not this component's identity —\n   any consumer element carrying `group/card` would otherwise be forced to a\n   44px minimum box. The root element carries both classes, so the element the\n   source targeted is still the element targeted. */\n\n/* Enhanced mobile touch targets */\n@media (max-width: 768px) {\n\t.direction-aware-hover {\n\t\tmin-height: 44px;\n\t\tmin-width: 44px;\n\t}\n}\n","type":"registry:ui","target":"@ui/fancy/components/direction-aware-hover/direction-aware-hover.css"},{"path":"react/src/components/direction-aware-hover/index.ts","content":"\"use client\";\n\nexport { DirectionAwareHover } from \"./DirectionAwareHover\";\nexport type { DirectionAwareHoverProps } from \"./DirectionAwareHover\";\n","type":"registry:ui","target":"@ui/fancy/components/direction-aware-hover/index.ts"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/direction-aware-hover?framework=react"}}