Draggable Avatar

PreviousNext

It displays a circular, draggable avatar with customizable size and style.

Docs
scrollxuicomponent

Preview

Loading preview…
components/ui/draggable-avatar.tsx
"use client"
import React, { useState, useRef, useEffect } from 'react';
import { motion, PanInfo } from 'framer-motion';

interface DraggableAvatarProps {
  image?: string;
  borderColor?: string;
  range?: number;
  size?: number;
}

export default function DraggableAvatar({
  image = 'https://cdn.pixabay.com/photo/2023/06/26/04/38/ai-generated-8088680_1280.jpg',
  borderColor = '#60A5FA',
  range = 300,
  size = 100
}: DraggableAvatarProps) {
  const [isHolding, setIsHolding] = useState(false);
  const [constraints, setConstraints] = useState({ left: -range, right: range, top: -range, bottom: range });
  const containerRef = useRef<HTMLDivElement>(null);
  const motionRef = useRef<HTMLDivElement>(null);
  const lineLength = 45;
  const lineLengthHolding = 30;
  const markerSize = 12;

  useEffect(() => {
    const updateConstraints = () => {
      if (containerRef.current) {
        const rect = containerRef.current.getBoundingClientRect();
        const maxX = Math.min(range, window.innerWidth - rect.left - size);
        const maxY = Math.min(range, window.innerHeight - rect.top - size - lineLength - markerSize);
        const minX = Math.max(-range, -rect.left);
        const minY = Math.max(-range, -rect.top);
        
        setConstraints({
          left: minX,
          right: maxX,
          top: minY,
          bottom: maxY
        });
      }
    };

    updateConstraints();
    window.addEventListener('resize', updateConstraints);
    return () => window.removeEventListener('resize', updateConstraints);
  }, [range, size, lineLength, markerSize]);

  const handleDragEnd = (_event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
    setIsHolding(false);
    const distance = Math.sqrt(info.offset.x ** 2 + info.offset.y ** 2);
    
    if (distance > range && motionRef.current) {
      const angle = Math.atan2(info.offset.y, info.offset.x);
      const newX = Math.cos(angle) * range;
      const newY = Math.sin(angle) * range;
      
      motionRef.current.style.transform = `translate(${newX}px, ${newY}px)`;
    }
  };

  const currentLineLength = isHolding ? lineLengthHolding : lineLength;

  return (
    <div ref={containerRef} className="relative">
      <motion.div
        ref={motionRef}
        drag
        dragElastic={0}
        dragMomentum={false}
        dragConstraints={constraints}
        onPointerDown={() => setIsHolding(true)}
        onPointerUp={() => setIsHolding(false)}
        onDragEnd={handleDragEnd}
        transition={{ type: 'spring', stiffness: 400, damping: 35 }}
        className="cursor-grab active:cursor-grabbing relative"
        style={{ width: size, height: size }}
        whileTap={{ scale: 1.05 }}
      >
        <motion.div 
          className="relative w-full h-full rounded-full overflow-hidden"
          style={{ 
            border: `3px solid ${borderColor}`,
          }}
          animate={{
            boxShadow: isHolding 
              ? '0 20px 50px rgba(0,0,0,0.25), 0 10px 20px rgba(0,0,0,0.15)'
              : '0 8px 30px rgba(0,0,0,0.12)'
          }}
          transition={{ duration: 0.2 }}
        >
          <img 
            src={image} 
            alt="Avatar" 
            className="w-full h-full object-cover"
            draggable={false}
          />
        </motion.div>
        
        <motion.span
          className="absolute left-1/2"
          style={{ 
            bottom: `-${currentLineLength + markerSize + 4}px`,
            transform: 'translateX(-50%)'
          }}
          animate={{
            bottom: `-${currentLineLength + markerSize + 4}px`
          }}
          transition={{ duration: 0.2 }}
        >
          <motion.span
            className="block"
            style={{
              width: '2px',
              backgroundColor: borderColor,
              marginLeft: `${(markerSize - 2) / 2}px`,
            }}
            animate={{
              height: `${currentLineLength}px`
            }}
            transition={{ duration: 0.2 }}
          />
          <span
            className="block rounded-full"
            style={{ 
              width: `${markerSize}px`, 
              height: `${markerSize}px`,
              backgroundColor: borderColor,
            }}
          />
        </motion.span>
      </motion.div>
    </div>
  );
}

Installation

npx shadcn@latest add @scrollxui/draggable-avatar

Usage

import { DraggableAvatar } from "@/components/draggable-avatar"
<DraggableAvatar />