Web Exploding Menu
November 2024
TSX
import { useState } from 'react';
import {
AnimatePresence,
MotionValue,
motion,
useMotionValue,
useSpring,
} from 'framer-motion';
import useLongPress from '@/hook/useLongPress';
import { useIsTouchDevice } from '@/hook/useIsTouchDevice';
interface IconProps {
className?: string
}
const HeartIcon = (props: IconProps) => {
return (
<svg className={props.className} xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path fill="black" fillRule="evenodd" d="M5.624 4.424C3.965 5.182 2.75 6.986 2.75 9.137c0 2.197.9 3.891 2.188 5.343c1.063 1.196 2.349 2.188 3.603 3.154q.448.345.885.688c.526.415.995.778 1.448 1.043s.816.385 1.126.385s.674-.12 1.126-.385c.453-.265.922-.628 1.448-1.043q.437-.344.885-.687c1.254-.968 2.54-1.959 3.603-3.155c1.289-1.452 2.188-3.146 2.188-5.343c0-2.15-1.215-3.955-2.874-4.713c-1.612-.737-3.778-.542-5.836 1.597a.75.75 0 0 1-1.08 0C9.402 3.882 7.236 3.687 5.624 4.424M12 4.46C9.688 2.39 7.099 2.1 5 3.059C2.786 4.074 1.25 6.426 1.25 9.138c0 2.665 1.11 4.699 2.567 6.339c1.166 1.313 2.593 2.412 3.854 3.382q.43.33.826.642c.513.404 1.063.834 1.62 1.16s1.193.59 1.883.59s1.326-.265 1.883-.59c.558-.326 1.107-.756 1.62-1.16q.396-.312.826-.642c1.26-.97 2.688-2.07 3.854-3.382c1.457-1.64 2.567-3.674 2.567-6.339c0-2.712-1.535-5.064-3.75-6.077c-2.099-.96-4.688-.67-7 1.399" clipRule="evenodd" />
</svg>
)
}
const PlusIcon = (props: IconProps) => {
return (
<svg className={props.className} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path fill="none" stroke="black" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 12h14m-7-7v14" />
</svg>
)
}
const CopyIcon = (props: IconProps) => {
return (
<svg className={props.className} xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<g fill="none" stroke="black" strokeWidth="1.5">
<path d="M6 11c0-2.828 0-4.243.879-5.121C7.757 5 9.172 5 12 5h3c2.828 0 4.243 0 5.121.879C21 6.757 21 8.172 21 11v5c0 2.828 0 4.243-.879 5.121C19.243 22 17.828 22 15 22h-3c-2.828 0-4.243 0-5.121-.879C6 20.243 6 18.828 6 16z" />
<path d="M6 19a3 3 0 0 1-3-3v-6c0-3.771 0-5.657 1.172-6.828S7.229 2 11 2h4a3 3 0 0 1 3 3" opacity="0.5" />
</g>
</svg>
)
}
const DownloadIcon = (props: IconProps) => {
return (
<svg className={props.className} xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path fill="none" stroke="black" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M4 16.004V17a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3v-1M12 4.5v11m3.5-3.5L12 15.5L8.5 12" />
</svg>
)
}
const MENU_ITEMS = [
{
icon: <HeartIcon className="text-primary-light-12 w-5 h-5" />,
label: 'Like',
onClick: () => console.log('like'),
},
{
icon: <DownloadIcon className="text-primary-light-12 w-5 h-5" />,
label: 'Download',
onClick: () => console.log('download'),
},
{
icon: <CopyIcon className="text-primary-light-12 w-5 h-5" />,
label: 'Copy',
onClick: () => console.log('Copy'),
},
{
icon: <PlusIcon className="text-primary-light-12 w-5 h-5" />,
label: 'Plus',
onClick: () => console.log('add'),
},
];
const mapRange = (
inputLower: number,
inputUpper: number,
outputLower: number,
outputUpper: number
) => {
const INPUT_RANGE = inputUpper - inputLower;
const OUTPUT_RANGE = outputUpper - outputLower;
return (value: number) =>
outputLower + (((value - inputLower) / INPUT_RANGE) * OUTPUT_RANGE || 0);
};
function ExplodingMenuItem({
item,
index,
}: {
item: typeof MENU_ITEMS[0];
index: number;
}) {
const DISTANCE_INCREMENT = 14;
const x = useSpring(useMotionValue(0), { mass: 0.005, stiffness: 100 });
const y = useSpring(useMotionValue(0), { mass: 0.005, stiffness: 100 });
const distance = `${index * DISTANCE_INCREMENT}%`;
const setTransform = (
item: HTMLElement & EventTarget,
event: React.PointerEvent,
x: MotionValue,
y: MotionValue
) => {
const bounds = item.getBoundingClientRect();
const relativeX = event.clientX - bounds.left;
const relativeY = event.clientY - bounds.top;
const xRange = mapRange(0, bounds.width, -1, 1)(relativeX);
const yRange = mapRange(0, bounds.height, -1, 1)(relativeY);
x.set(xRange * 3);
y.set(yRange * 3);
};
return (
<motion.li
key={item.label}
style={{
offsetDistance: distance,
position: 'absolute',
offsetRotate: '0deg',
left: 16,
top: 16,
x,
y,
}}
whileHover={{ scale: 1.15 }}
initial={{
opacity: 0,
scale: 0.5,
offsetPath: `path("M 0 0 m 0 0 a 9.6 9.6 90 1 0 0 0 a 9.6 9.6 90 1 0 0 0")`,
}}
animate={{
opacity: 1,
scale: 1,
offsetPath: `path("M 0 0 m -0 -48 a 48 48 180 1 0 0 96 a 48 48 180 1 0 -0 -96")`,
}}
exit={{
opacity: 0,
scale: 0.5,
offsetPath: `path("M 0 0 m 0 0 a 9.6 9.6 90 1 0 0 0 a 9.6 9.6 90 1 0 0 0")`,
}}
onPointerMove={(event) => {
const item = event.currentTarget;
setTransform(item, event, x, y);
}}
onPointerLeave={() => {
x.set(0);
y.set(0);
}}
transition={{ duration: 0.2, delay: index * 0.02, type: 'easeInOut' }}
>
<motion.button
onClick={item.onClick}
className="flex h-8 w-8 p-2 cursor-move items-center justify-center rounded-full bg-white/90"
>
{item.icon}
</motion.button>
</motion.li>
);
}
export default function ExplodingMenu() {
const [isOpen, setIsOpen] = useState(false);
const mouseXValue = useMotionValue(0);
const mouseYValue = useMotionValue(0);
const isTouchDevice = useIsTouchDevice();
const x = useMotionValue(0);
const y = useMotionValue(0);
const onLongPress = () => {
x.set(mouseXValue.get());
y.set(mouseYValue.get());
setIsOpen(true);
};
const defaultOptions = {
isPreventDefault: true,
delay: 0,
};
const longPressHandlers = useLongPress(onLongPress, () => setIsOpen(false), defaultOptions);
const handleMouseMove = (event: React.MouseEvent) => {
const rect = event.currentTarget.getBoundingClientRect();
mouseXValue.set(event.clientX - rect.left - 18);
mouseYValue.set(event.clientY - rect.top - 18);
};
return (
<div
className="relative flex h-[400px] w-full flex-col items-center justify-center active:cursor-move"
onTouchEnd={longPressHandlers.onTouchEnd}
onMouseUp={longPressHandlers.onMouseUp}
>
{isTouchDevice ? (
<p className="py-6 text-center text-sm">
{/* This component is not made for touch devices. */}
</p>
) : null}
<div
className="relative z-0 select-none h-96 w-72 md:h-96 md:w-96"
onMouseMove={handleMouseMove}
onTouchStart={(e) => longPressHandlers.onTouchStart(e as unknown as MouseEvent)}
onMouseDown={(e) => longPressHandlers.onMouseDown(e as unknown as MouseEvent)}
>
<img
src="https://i.pinimg.com/736x/73/3f/61/733f61f6a03e9c798f19762cb965f455.jpg"
alt="monks doing ambient music"
className="pointer-events-none h-full w-full select-none rounded-[12px] object-cover"
/>
<AnimatePresence>
{isOpen && (
<motion.div
className="absolute left-0 top-0"
style={{
x,
y,
}}
>
<ul className="relative">
<li
className="absolute h-8 w-8 rounded-full border-4 border-neutral-200/50"
style={{
transformOrigin: 'center',
left: 0,
top: 0,
}}
/>
{[...MENU_ITEMS].map((item, index) => {
return (
<ExplodingMenuItem item={item} index={index} key={index} />
);
})}
</ul>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
);
}
//
//
//
//
// @/hook/useIsTouchDevice.tsx
//
//
//
//
// import { useEffect, useState } from "react";
// export function useIsTouchDevice() {
// const [isTouchDevice, setIsTouchDevice] = useState(false);
// useEffect(() => {
// function onResize() {
// setIsTouchDevice(
// "ontouchstart" in window ||
// navigator.maxTouchPoints > 0 ||
// navigator.maxTouchPoints > 0
// );
// }
// window.addEventListener("resize", onResize);
// onResize();
// return () => {
// window.removeEventListener("resize", onResize);
// };
// }, []);
// return isTouchDevice;
// }
// import { useCallback, useRef } from 'react';
//
//
//
//
// @/hook/useLongPress.tsx
//
//
//
//
// export const noop = () => {};
// export function on<T extends Window | Document | HTMLElement | EventTarget>(
// obj: T | null,
// // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
// ...args: Parameters<T['addEventListener']> | [string, Function | null, ...unknown[]]
// ): void {
// if (obj && obj.addEventListener) {
// obj.addEventListener(...(args as Parameters<HTMLElement['addEventListener']>));
// }
// }
// export function off<T extends Window | Document | HTMLElement | EventTarget>(
// obj: T | null,
// // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
// ...args: Parameters<T['removeEventListener']> | [string, Function | null, ...unknown[]]
// ): void {
// if (obj && obj.removeEventListener) {
// obj.removeEventListener(...(args as Parameters<HTMLElement['removeEventListener']>));
// }
// }
// export const isBrowser = typeof window !== 'undefined';
// export const isNavigator = typeof navigator !== 'undefined';
// interface Options {
// isPreventDefault?: boolean;
// delay?: number;
// }
// const isTouchEvent = (ev: Event): ev is TouchEvent => {
// return 'touches' in ev;
// };
// const preventDefault = (ev: Event) => {
// if (!isTouchEvent(ev)) return;
// if (ev.touches.length < 2 && ev.preventDefault) {
// ev.preventDefault();
// }
// };
// const useLongPress = (
// callback: (e: TouchEvent | MouseEvent) => void,
// onCancel?: () => void,
// { isPreventDefault = true, delay = 300 }: Options = {},
// ) => {
// const timeout = useRef<ReturnType<typeof setTimeout>>();
// const target = useRef<EventTarget>();
// const start = useCallback(
// (event: TouchEvent | MouseEvent) => {
// // prevent ghost click on mobile devices
// if (isPreventDefault && event.target) {
// on(event.target, 'touchend', preventDefault, { passive: false });
// target.current = event.target;
// }
// timeout.current = setTimeout(() => callback(event), delay);
// },
// [callback, delay, isPreventDefault]
// );
// const clear = useCallback(() => {
// // eslint-disable-next-line @typescript-eslint/no-unused-expressions
// timeout.current && clearTimeout(timeout.current);
// if (isPreventDefault && target.current) {
// off(target.current, 'touchend', preventDefault);
// }
// onCancel?.();
// }, [isPreventDefault]);
// return {
// onMouseDown: (e: MouseEvent) => start(e),
// onTouchStart: (e: MouseEvent) => start(e),
// onMouseUp: clear,
// onMouseLeave: clear,
// onTouchEnd: clear,
// } as const;
// };
// export default useLongPress;