dqnamoexperiments / magnetic-drop-zone

Magnetic Drop Zone

A file drop zone that notices an incoming drag, pulls gently toward the pointer, and settles into a clear ready state after the file lands.

"use client";import {  CheckCircleIcon,  FileArrowUpIcon,  FileIcon,  FilePdfIcon,  FileZipIcon,  ImageSquareIcon,  UploadSimpleIcon,  XIcon,} from "@phosphor-icons/react/dist/ssr";import {  motion,  useMotionTemplate,  useMotionValue,  useReducedMotion,  useSpring,} from "motion/react";import {  type ChangeEvent,  type DragEvent,  useCallback,  useEffect,  useRef,  useState,} from "react";import { cn } from "@/helpers/classname-helper";const MAGNET_DISTANCE = 180;const MAX_MAGNET_OFFSET = 10;const MAGNET_SPRING = { damping: 24, mass: 0.65, stiffness: 280 };type MagneticDropZoneProps = {  accept?: string;  className?: string;  maxSize?: number;  multiple?: boolean;  onFilesChange?: (files: File[]) => void;};function formatBytes(bytes: number) {  if (bytes === 0) {    return "0 B";  }  const units = ["B", "KB", "MB", "GB"];  const unitIndex = Math.min(    Math.floor(Math.log(bytes) / Math.log(1024)),    units.length - 1,  );  const value = bytes / 1024 ** unitIndex;  return `${value >= 10 || unitIndex === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}`;}function getFileIcon(file: File) {  if (file.type.startsWith("image/")) {    return ImageSquareIcon;  }  if (file.type === "application/pdf") {    return FilePdfIcon;  }  if (file.type.includes("zip") || file.name.toLowerCase().endsWith(".zip")) {    return FileZipIcon;  }  return FileIcon;}function isFileDrag(dataTransfer: DataTransfer | null) {  return dataTransfer    ? Array.from(dataTransfer.types).includes("Files")    : false;}function acceptsFile(file: File, accept: string) {  if (!accept.trim()) {    return true;  }  const fileName = file.name.toLowerCase();  const fileType = file.type.toLowerCase();  return accept    .split(",")    .map((rule) => rule.trim().toLowerCase())    .some((rule) => {      if (rule.startsWith(".")) {        return fileName.endsWith(rule);      }      if (rule.endsWith("/*")) {        return fileType.startsWith(rule.slice(0, -1));      }      return fileType === rule;    });}export function MagneticDropZone({  accept = "image/*,.pdf,.zip",  className,  maxSize = 20 * 1024 * 1024,  multiple = false,  onFilesChange,}: MagneticDropZoneProps) {  const inputRef = useRef<HTMLInputElement>(null);  const zoneRef = useRef<HTMLDivElement>(null);  const [files, setFiles] = useState<File[]>([]);  const [isNear, setIsNear] = useState(false);  const [isOver, setIsOver] = useState(false);  const [error, setError] = useState<string | null>(null);  const shouldReduceMotion = useReducedMotion();  const magnetX = useMotionValue(0);  const magnetY = useMotionValue(0);  const magnetScale = useMotionValue(1);  const glowX = useMotionValue(0);  const glowY = useMotionValue(0);  const springX = useSpring(magnetX, MAGNET_SPRING);  const springY = useSpring(magnetY, MAGNET_SPRING);  const springScale = useSpring(magnetScale, MAGNET_SPRING);  const springGlowX = useSpring(glowX, MAGNET_SPRING);  const springGlowY = useSpring(glowY, MAGNET_SPRING);  const transform = useMotionTemplate`translate3d(${springX}px, ${springY}px, 0) scale(${springScale})`;  const glowTransform = useMotionTemplate`translate3d(${springGlowX}px, ${springGlowY}px, 0)`;  const resetMagnet = useCallback(() => {    magnetX.set(0);    magnetY.set(0);    magnetScale.set(1);    glowX.set(0);    glowY.set(0);    setIsNear(false);    setIsOver(false);  }, [glowX, glowY, magnetScale, magnetX, magnetY]);  useEffect(() => {    function handleWindowDragOver(event: globalThis.DragEvent) {      if (!isFileDrag(event.dataTransfer)) {        return;      }      const zone = zoneRef.current;      if (!zone) {        return;      }      const rect = zone.getBoundingClientRect();      const centerX = rect.left + rect.width / 2;      const centerY = rect.top + rect.height / 2;      const distanceFromEdgeX = Math.max(        Math.abs(event.clientX - centerX) - rect.width / 2,        0,      );      const distanceFromEdgeY = Math.max(        Math.abs(event.clientY - centerY) - rect.height / 2,        0,      );      const distance = Math.hypot(distanceFromEdgeX, distanceFromEdgeY);      const pointerIsOver =        event.clientX >= rect.left &&        event.clientX <= rect.right &&        event.clientY >= rect.top &&        event.clientY <= rect.bottom;      const proximity = Math.max(0, 1 - distance / MAGNET_DISTANCE);      if (proximity === 0) {        resetMagnet();        return;      }      const deltaX = event.clientX - centerX;      const deltaY = event.clientY - centerY;      const pointerDistance = Math.max(Math.hypot(deltaX, deltaY), 1);      const offset = pointerIsOver ? 0 : proximity * MAX_MAGNET_OFFSET;      magnetX.set(shouldReduceMotion ? 0 : (deltaX / pointerDistance) * offset);      magnetY.set(shouldReduceMotion ? 0 : (deltaY / pointerDistance) * offset);      magnetScale.set(shouldReduceMotion ? 1 : pointerIsOver ? 1.025 : 1.01);      glowX.set(shouldReduceMotion ? 0 : deltaX);      glowY.set(shouldReduceMotion ? 0 : deltaY);      setIsNear(true);      setIsOver(pointerIsOver);    }    function handleDragEnd() {      resetMagnet();    }    window.addEventListener("dragover", handleWindowDragOver);    window.addEventListener("dragend", handleDragEnd);    window.addEventListener("drop", handleDragEnd);    return () => {      window.removeEventListener("dragover", handleWindowDragOver);      window.removeEventListener("dragend", handleDragEnd);      window.removeEventListener("drop", handleDragEnd);    };  }, [    glowX,    glowY,    magnetScale,    magnetX,    magnetY,    resetMagnet,    shouldReduceMotion,  ]);  const acceptFiles = useCallback(    (incomingFiles: File[]) => {      const nextFiles = multiple ? incomingFiles : incomingFiles.slice(0, 1);      const unsupportedFile = nextFiles.find(        (file) => !acceptsFile(file, accept),      );      const oversizedFile = nextFiles.find((file) => file.size > maxSize);      if (unsupportedFile) {        setFiles([]);        setError(`${unsupportedFile.name} is not a supported file type.`);        onFilesChange?.([]);        return;      }      if (oversizedFile) {        setFiles([]);        setError(          `${oversizedFile.name} is larger than ${formatBytes(maxSize)}.`,        );        onFilesChange?.([]);        return;      }      if (nextFiles.length === 0) {        return;      }      setFiles(nextFiles);      setError(null);      onFilesChange?.(nextFiles);    },    [accept, maxSize, multiple, onFilesChange],  );  function handleDrop(event: DragEvent<HTMLDivElement>) {    event.preventDefault();    event.stopPropagation();    acceptFiles(Array.from(event.dataTransfer.files));    resetMagnet();  }  function handleDragOver(event: DragEvent<HTMLDivElement>) {    if (!isFileDrag(event.dataTransfer)) {      return;    }    event.preventDefault();    event.dataTransfer.dropEffect = "copy";  }  function handleInputChange(event: ChangeEvent<HTMLInputElement>) {    acceptFiles(Array.from(event.target.files ?? []));    event.target.value = "";  }  function openFilePicker() {    inputRef.current?.click();  }  function clearFiles() {    setFiles([]);    setError(null);    onFilesChange?.([]);  }  const firstFile = files[0];  const FileTypeIcon = firstFile ? getFileIcon(firstFile) : FileIcon;  const title = isOver    ? "Let go to add it"    : isNear      ? "Bring it closer"      : "Drop a file here";  return (    <motion.div      className={cn(        "relative flex min-h-64 w-full max-w-sm flex-col items-center justify-center overflow-hidden rounded-2xl border px-6 py-8 text-center small-shadow outline-none transition-[border-color,background-color] duration-200 [transition-timing-function:cubic-bezier(0.23,1,0.32,1)] dark:shadow-none",        isOver          ? "border-blue-8 bg-blue-3/70"          : isNear            ? "border-blue-7 bg-blue-2/70"            : "border-grayscale-4 bg-grayscale-2/70 dark:border-grayscale-5 dark:bg-grayscale-3",        className,      )}      onDragOver={handleDragOver}      onDrop={handleDrop}      ref={zoneRef}      style={{        transform: shouldReduceMotion ? undefined : transform,      }}    >      <input        accept={accept}        className="hidden"        multiple={multiple}        onChange={handleInputChange}        ref={inputRef}        type="file"      />      <motion.div        aria-hidden="true"        className={cn(          "pointer-events-none absolute top-1/2 left-1/2 -mt-20 -ml-20 size-40 rounded-full bg-blue-5/30 blur-3xl transition-opacity duration-200",          isNear ? "opacity-100" : "opacity-0",        )}        style={{ transform: glowTransform }}      />      {firstFile ? (        <div className="relative flex w-full flex-col items-center">          <div className="flex size-12 items-center justify-center rounded-xl border border-blue-6 bg-blue-3 text-blue-11 shadow-sm dark:bg-blue-4">            <FileTypeIcon aria-hidden="true" size={23} weight="fill" />          </div>          <div className="mt-4 flex items-center gap-1.5">            <CheckCircleIcon              aria-hidden="true"              className="text-green-9"              size={15}              weight="fill"            />            <p className="font-medium text-grayscale-12 text-sm">              {files.length === 1                ? "Ready to upload"                : `${files.length} files ready`}            </p>          </div>          <p className="mt-1 max-w-full truncate text-grayscale-11 text-sm">            {firstFile.name}            {files.length > 1 ? ` +${files.length - 1}` : ""}          </p>          <p className="mt-0.5 font-mono text-[10px] text-grayscale-9 uppercase">            {formatBytes(firstFile.size)}          </p>          <div className="mt-5 flex items-center gap-2">            <button              className="flex h-8 cursor-pointer items-center gap-1.5 rounded-lg border border-grayscale-4 bg-grayscale-1 px-3 font-medium text-grayscale-11 text-xs transition-[background-color,border-color,transform] duration-150 active:scale-[0.97] dark:border-grayscale-6 dark:bg-grayscale-3 dark:hover:bg-grayscale-4"              onClick={openFilePicker}              type="button"            >              <UploadSimpleIcon aria-hidden="true" size={14} weight="bold" />              Replace            </button>            <button              aria-label={`Remove ${firstFile.name}`}              className="flex h-8 cursor-pointer items-center gap-1.5 rounded-lg border border-grayscale-4 bg-grayscale-1 px-3 font-medium text-grayscale-11 text-xs transition-[background-color,border-color,transform] duration-150 active:scale-[0.97] dark:border-grayscale-6 dark:bg-grayscale-3 dark:hover:bg-grayscale-4"              onClick={clearFiles}              type="button"            >              <XIcon aria-hidden="true" size={14} weight="bold" />              Remove            </button>          </div>        </div>      ) : (        <button          className="relative flex cursor-pointer flex-col items-center rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-blue-8 focus-visible:ring-offset-4 focus-visible:ring-offset-grayscale-2"          onClick={openFilePicker}          type="button"        >          <div            className={cn(              "flex size-12 items-center justify-center rounded-xl border border-grayscale-4 bg-grayscale-1 text-grayscale-11 shadow-sm transition-[border-color,background-color] duration-200 dark:border-grayscale-6 dark:bg-grayscale-3",              isOver && "dark:border-blue-7 dark:bg-blue-4",            )}          >            <FileArrowUpIcon              aria-hidden="true"              className={cn(                "transition-colors duration-200 dark:text-grayscale-12",                isNear ? "text-blue-11" : "text-grayscale-11",              )}              size={23}              weight="fill"            />          </div>          <p className="mt-4 font-medium text-grayscale-12 text-sm">{title}</p>          <p className="mt-1 text-grayscale-10 text-xs leading-5">            or click to browse · up to {formatBytes(maxSize)}          </p>        </button>      )}      {error ? (        <p          aria-live="polite"          className="relative mt-4 max-w-60 text-red-10 text-xs leading-5"        >          {error}        </p>      ) : null}    </motion.div>  );}

Want to work with me?

I run a lil design engineering studio in London where we do fractional design engineering for startups.

THEINTERFACECOMPANYOF LONDON