"use client";
import { useCallback, useEffect, useRef, type ReactNode } from "react";
import { cn } from "@/lib/utils";
export interface ApproachProps {
children?: ReactNode;
overlay?: ReactNode;
className?: string;
overlayOpacity?: number;
duration?: number;
skew?: number;
blur?: number;
offsetDistance?: number;
overlayColor?: string;
reducedMotion?: boolean;
}
const EASE = "cubic-bezier(0.32, 0.72, 0, 1)";
function edgeFrom(rect: DOMRect, clientX: number, clientY: number): number {
const { width: w, height: h } = rect;
const x = (clientX - rect.left - w / 2) * (w > h ? h / w : 1);
const y = (clientY - rect.top - h / 2) * (h > w ? w / h : 1);
return (Math.round((Math.atan2(y, x) * 180) / Math.PI / 90) + 4) % 4;
}
const AXIS: Array<[number, number]> = [
[0, -1],
[1, 0],
[0, 1],
[-1, 0],
];
export default function Approach({
children,
overlay,
className,
overlayOpacity = 0.92,
duration = 420,
skew = 4,
blur = 0,
offsetDistance = 100,
overlayColor = "#a855f7",
reducedMotion = false,
}: ApproachProps) {
const rootRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const hovered = useRef(false);
const focused = useRef(false);
const shown = useRef(false);
const edge = useRef(0);
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const filtered = blur > 0;
const transition =
`transform ${duration}ms ${EASE}, opacity ${duration}ms ${EASE}` +
(filtered ? `, filter ${duration}ms ${EASE}` : "");
const clearHide = useCallback(() => {
if (hideTimer.current !== null) {
clearTimeout(hideTimer.current);
hideTimer.current = null;
}
}, []);
const apply = useCallback(
(away: boolean, animate: boolean) => {
const panel = panelRef.current;
if (!panel) return;
const [dx, dy] = AXIS[edge.current] ?? AXIS[0];
const d = away ? offsetDistance : 0;
panel.style.transition = animate ? transition : "none";
panel.style.setProperty("--sg-ap-x", `${dx * d}%`);
panel.style.setProperty("--sg-ap-y", `${dy * d}%`);
panel.style.setProperty("--sg-ap-skew", away ? `${dx * skew}deg` : "0deg");
panel.style.setProperty("--sg-ap-blur", away ? `${blur}px` : "0px");
panel.style.setProperty(
"--sg-ap-opacity",
away ? "0" : `${overlayOpacity}`,
);
if (!animate) void panel.offsetWidth;
},
[offsetDistance, skew, blur, overlayOpacity, transition],
);
const reveal = useCallback(() => {
const panel = panelRef.current;
if (!panel) return;
clearHide();
const wasParked = panel.style.display === "none";
panel.style.display = "";
if (wasParked) apply(true, false);
apply(false, true);
}, [apply, clearHide]);
const dismiss = useCallback(() => {
apply(true, true);
clearHide();
hideTimer.current = setTimeout(() => {
hideTimer.current = null;
if (shown.current) return;
const panel = panelRef.current;
if (panel) panel.style.display = "none";
}, duration + 80);
}, [apply, clearHide, duration]);
const sync = useCallback(() => {
const want = hovered.current || focused.current;
if (want === shown.current) return;
shown.current = want;
if (want) reveal();
else dismiss();
}, [reveal, dismiss]);
const onPointerEnter = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
const el = rootRef.current;
if (!el) return;
hovered.current = true;
edge.current = reducedMotion
? 0
: edgeFrom(el.getBoundingClientRect(), e.clientX, e.clientY);
sync();
},
[sync, reducedMotion],
);
const onPointerLeave = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
const el = rootRef.current;
if (!el) return;
hovered.current = false;
edge.current = reducedMotion
? 0
: edgeFrom(el.getBoundingClientRect(), e.clientX, e.clientY);
sync();
},
[sync, reducedMotion],
);
const onPointerCancel = useCallback(() => {
hovered.current = false;
sync();
}, [sync]);
const onFocus = useCallback(
(e: React.FocusEvent<HTMLDivElement>) => {
if (e.relatedTarget && e.currentTarget.contains(e.relatedTarget)) return;
focused.current = true;
if (!hovered.current) edge.current = 0;
sync();
},
[sync],
);
const onBlur = useCallback(
(e: React.FocusEvent<HTMLDivElement>) => {
if (e.relatedTarget && e.currentTarget.contains(e.relatedTarget)) return;
focused.current = false;
sync();
},
[sync],
);
useEffect(() => {
const onDocumentLeave = () => {
if (!hovered.current) return;
hovered.current = false;
sync();
};
const root = document.documentElement;
root.addEventListener("pointerleave", onDocumentLeave);
return () => root.removeEventListener("pointerleave", onDocumentLeave);
}, [sync]);
useEffect(() => {
apply(!shown.current, shown.current);
}, [apply]);
useEffect(() => {
const panel = panelRef.current;
if (panel && !shown.current) panel.style.display = "none";
}, []);
useEffect(() => clearHide, [clearHide]);
return (
<div
ref={rootRef}
onPointerEnter={onPointerEnter}
onPointerLeave={onPointerLeave}
onPointerCancel={onPointerCancel}
onFocus={onFocus}
onBlur={onBlur}
className={cn(
"relative isolate overflow-hidden select-none [contain:paint]",
className,
)}
>
{children}
<div
ref={panelRef}
aria-hidden
className="pointer-events-none absolute inset-0 z-10 flex flex-col justify-end p-5"
style={
{
"--sg-ap-x": "0%",
"--sg-ap-y": "-100%",
"--sg-ap-skew": "0deg",
"--sg-ap-blur": "0px",
"--sg-ap-opacity": "0",
background: overlayColor,
opacity: "var(--sg-ap-opacity)",
transform: reducedMotion
? undefined
: "translate(var(--sg-ap-x), var(--sg-ap-y)) skewX(var(--sg-ap-skew))",
filter:
reducedMotion || !filtered
? undefined
: "blur(var(--sg-ap-blur))",
transition,
} as React.CSSProperties
}
>
{overlay}
</div>
</div>
);
}