Motion+

Color picker

An example of creating a circular color picker with dots that react to cursor movement with soft springs using Motion for React.

React

Source code

"use client"

import { usePointerPosition } from "motion-plus/react"
import {
    animate,
    motion,
    SpringOptions,
    useMotionValue,
    useSpring,
    useTransform,
} from "motion/react"
import { useEffect, useLayoutEffect, useRef, useState } from "react"

/**
 * ==============   Utils   ================
 */

function calculateAngle(index: number, totalInRing: number): number {
    return (index / totalInRing) * Math.PI * 2
}

function calculateBasePosition(angle: number, radius: number) {
    return {
        x: Math.cos(angle) * radius,
        y: Math.sin(angle) * radius,
    }
}

function calculateHue(angle: number): number {
    const hueDegrees = (angle * 180) / Math.PI - 90 - 180
    return ((hueDegrees % 360) + 360) % 360
}

interface ColorDotProps {
    ring: number
    index: number
    totalInRing: number
    centerX: number
    centerY: number
    pointerX: ReturnType<typeof usePointerPosition>["x"]
    pointerY: ReturnType<typeof usePointerPosition>["y"]
    pushMagnitude: number
    pushSpring: SpringOptions
    radius: number
    selectedColor: string | null
    setSelectedColor: (color: string | null) => void
}

function ColorDot({
    ring,
    index,
    totalInRing,
    centerX,
    centerY,
    pointerX,
    pointerY,
    pushMagnitude,
    pushSpring,
    radius,
    selectedColor,
    setSelectedColor,
}: ColorDotProps) {
    const baseRadius = ring * 20
    const angle = calculateAngle(index, totalInRing)
    const { x: baseX, y: baseY } = calculateBasePosition(angle, baseRadius)

    let color = "hsl(0, 0%, 100%)"
    let normalizedHue = 0
    if (ring !== 0) {
        normalizedHue = calculateHue(angle)
        color =
            ring === 1
                ? `hsl(${normalizedHue}, 60%, 85%)`
                : `hsl(${normalizedHue}, 90%, 60%)`
    }

    const pushDistance = useTransform(() => {
        if (centerX === 0 || centerY === 0) return 0

        const px = pointerX.get()
        const py = pointerY.get()

        const dx = px - centerX
        const dy = py - centerY
        const distanceFromCenter = Math.sqrt(dx * dx + dy * dy)

        if (distanceFromCenter > radius) return 0

        const dotX = centerX + baseX
        const dotY = centerY + baseY

        const cursorToDotX = dotX - px
        const cursorToDotY = dotY - py
        const cursorToDotDistance = Math.sqrt(
            cursorToDotX * cursorToDotX + cursorToDotY * cursorToDotY
        )

        const minDistance = 80
        if (cursorToDotDistance < minDistance) {
            const pushStrength = 1 - cursorToDotDistance / minDistance
            return pushStrength * pushMagnitude
        }

        return 0
    })

    const pushAngle = useTransform(() => {
        if (centerX === 0 || centerY === 0) return angle

        const px = pointerX.get()
        const py = pointerY.get()

        const dotX = centerX + baseX
        const dotY = centerY + baseY

        const cursorToDotX = dotX - px
        const cursorToDotY = dotY - py

        return Math.atan2(cursorToDotY, cursorToDotX)
    })

    const pushX = useTransform(() => {
        const distance = pushDistance.get()
        const angle = pushAngle.get()
        return Math.cos(angle) * distance
    })

    const pushY = useTransform(() => {
        const distance = pushDistance.get()
        const angle = pushAngle.get()
        return Math.sin(angle) * distance
    })

    const springPushX = useSpring(pushX, pushSpring)
    const springPushY = useSpring(pushY, pushSpring)

    const x = useTransform(() => baseX + springPushX.get())
    const y = useTransform(() => baseY + springPushY.get())

    const dotVariants = {
        default: {
            scale: 1,
        },
        hover: {
            scale: 1.5,
            transition: { duration: 0.13 },
        },
    }

    const ringVariants = {
        default: {
            opacity: 0,
        },
        hover: {
            opacity: 0.4,
            transition: { duration: 0.13 },
        },
    }

    return (
        <motion.div
            className="color-dot"
            style={{
                x,
                y,
                backgroundColor: color,
                willChange: "transform, background-color",
            }}
            variants={dotVariants}
            initial="default"
            whileHover="hover"
            whileTap={{ scale: 1.2 }}
            onTap={() => {
                if (selectedColor === color) {
                    setSelectedColor(null)
                } else {
                    setSelectedColor(color)
                }
            }}
            transition={{
                scale: { type: "spring", damping: 30, stiffness: 200 },
            }}
        >
            <motion.div className="color-dot-ring" variants={ringVariants} />
        </motion.div>
    )
}

interface GradientCircleProps {
    index: number
    totalInRing: number
    centerX: number
    centerY: number
    pointerX: ReturnType<typeof usePointerPosition>["x"]
    pointerY: ReturnType<typeof usePointerPosition>["y"]
    containerRadius: number
}

function GradientCircle({
    index,
    totalInRing,
    centerX,
    centerY,
    pointerX,
    pointerY,
    containerRadius,
}: GradientCircleProps) {
    const angle = calculateAngle(index, totalInRing)
    const baseRadius = containerRadius - 40
    const { x: baseX, y: baseY } = calculateBasePosition(angle, baseRadius)
    const normalizedHue = calculateHue(angle)

    const gradient = `radial-gradient(circle, hsla(${normalizedHue}, 90%, 60%, 1) 0%, hsla(${normalizedHue}, 90%, 60%, 0) 66%)`

    const proximity = useTransform(() => {
        if (centerX === 0 || centerY === 0) return 0

        const px = pointerX.get()
        const py = pointerY.get()

        const gradientX = centerX + baseX
        const gradientY = centerY + baseY

        const dx = px - gradientX
        const dy = py - gradientY
        const distance = Math.sqrt(dx * dx + dy * dy)

        const maxDistance = 100
        const proximityValue = Math.max(0, 1 - distance / maxDistance)

        return proximityValue
    })

    const { opacity, scale } = useTransform(proximity, [0, 1], {
        opacity: [0.15, 0.35],
        scale: [1, 1.2],
    })

    const springOpacity = useSpring(opacity, {
        damping: 30,
        stiffness: 100,
    })
    const springScale = useSpring(scale, {
        damping: 30,
        stiffness: 100,
    })

    return (
        <motion.div
            className="gradient-circle"
            style={{
                x: baseX,
                y: baseY,
                opacity: springOpacity,
                scale: springScale,
                background: gradient,
                willChange: "transform, opacity",
            }}
        />
    )
}

export default function ColorPicker({
    pushMagnitude = 5,
    pushSpring = {
        damping: 30,
        stiffness: 100,
    },
}: {
    pushMagnitude?: number
    pushSpring?: SpringOptions
}) {
    const containerRef = useRef<HTMLDivElement>(null)
    const [{ centerX, centerY, radius }, setContainerDimensions] = useState({
        centerX: 0,
        centerY: 0,
        radius: 200,
    })

    const pointer = usePointerPosition()
    const [selectedColor, setSelectedColor] = useState<string | null>(null)

    useLayoutEffect(() => {
        if (containerRef.current) {
            const rect = containerRef.current.getBoundingClientRect()
            setContainerDimensions({
                centerX: rect.left + rect.width / 2,
                centerY: rect.top + rect.height / 2,
                radius: rect.width / 2,
            })
        }
    }, [])

    const rings = [{ count: 1 }, { count: 6 }, { count: 12 }]

    const dots: Array<{
        ring: number
        index: number
        totalInRing: number
    }> = []

    rings.forEach((ring, ringIndex) => {
        for (let i = 0; i < ring.count; i++) {
            dots.push({
                ring: ringIndex,
                index: i,
                totalInRing: ring.count,
            })
        }
    })

    const originalStopValues: string[] = []
    for (let i = 0; i <= 360; i += 30) {
        originalStopValues.push(`hsl(${i}, 90%, 60%)`)
    }

    const stopMotionValues = originalStopValues.map(
        // eslint-disable-next-line react-hooks/rules-of-hooks
        (value: string) => useMotionValue(value)
    )

    useEffect(() => {
        if (selectedColor !== null) {
            for (const stopValue of stopMotionValues) {
                animate(stopValue, selectedColor, {
                    duration: 0.2,
                })
            }
        } else {
            for (let i = 0; i < stopMotionValues.length; i++) {
                animate(stopMotionValues[i], originalStopValues[i], {
                    duration: 0.2,
                })
            }
        }
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [selectedColor])
    console.log(selectedColor)
    const gradientBackground = useTransform(() => {
        let stops = ""
        for (let i = 0; i < stopMotionValues.length; i++) {
            stops += stopMotionValues[i].get()
            if (i < stopMotionValues.length - 1) {
                stops += ", "
            }
        }
        return `conic-gradient(from 0deg, ${stops})`
    })

    const gradientScale = useMotionValue(1)

    useEffect(() => {
        if (selectedColor !== null) {
            animate(gradientScale, 1.1, {
                type: "spring",
                visualDuration: 0.2,
                bounce: 0.8,
                velocity: 2,
            })
        } else {
            animate(gradientScale, 1, {
                type: "spring",
                visualDuration: 0.2,
                bounce: 0,
            })
        }
    }, [selectedColor, gradientScale])

    return (
        <div className="gradient-wrapper">
            <div className="background">
                <motion.div
                    className="gradient-background"
                    style={{
                        background: gradientBackground,
                        scale: gradientScale,
                    }}
                />
                <motion.div
                    className="solid-background"
                    animate={{
                        scale: selectedColor !== null ? 0.9 : 0.98,
                    }}
                    transition={{
                        type: "spring",
                        visualDuration: 0.2,
                        bounce: 0.2,
                    }}
                />
            </div>
            <div ref={containerRef} className="picker-background">
                {Array.from({ length: 6 }).map((_, index) => (
                    <GradientCircle
                        key={`gradient-${index}`}
                        index={index}
                        totalInRing={6}
                        centerX={centerX}
                        centerY={centerY}
                        pointerX={pointer.x}
                        pointerY={pointer.y}
                        containerRadius={radius}
                    />
                ))}
                {dots
                    .slice()
                    .reverse()
                    .map((dot) => (
                        <ColorDot
                            key={`${dot.ring}-${dot.index}`}
                            ring={dot.ring}
                            index={dot.index}
                            totalInRing={dot.totalInRing}
                            centerX={centerX}
                            centerY={centerY}
                            pointerX={pointer.x}
                            pointerY={pointer.y}
                            radius={radius}
                            pushMagnitude={pushMagnitude}
                            pushSpring={pushSpring}
                            selectedColor={selectedColor}
                            setSelectedColor={setSelectedColor}
                        />
                    ))}
            </div>
            <StyleSheet />
        </div>
    )
}

/**
 * ==============   Styles   ================
 */

function StyleSheet() {
    return (
        <style>
            {`
        .gradient-wrapper {
            position: relative;
            width: 140px;
            height: 140px;
            display: flex;
            align-items: center;
            justify-content: center;
        }

        .background {
            position: absolute;
            inset: 0;
            width: 100%;
            height: 100%;
        }

        .gradient-background {
            position: absolute;
            inset: 0;
            width: 100%;
            height: 100%;
            border-radius: 50%;
            z-index: 0;
        }

        .solid-background {
            position: absolute;
            inset: 0;
            width: 100%;
            height: 100%;
            background-color: var(--layer);
            border-radius: 50%;
            z-index: 1;
        }

        .picker-background {
            position: relative;
            width: calc(100% - 5px);
            height: calc(100% - 5px);
            border-radius: 50%;
            overflow: visible;
            z-index: 2;
        }

        .color-dot {
            position: absolute;
            top: 50%;
            left: 50%;
            width: 32px;
            height: 32px;
            border-radius: 50%;
            translate: -50% -50%;
            cursor: pointer;
        }

        .color-dot-ring {
            position: absolute;
            inset: 0;
            border: 2px solid white;
            border-radius: 50%;
            mix-blend-mode: overlay;
            pointer-events: none;
        }

        .gradient-circle {
            position: absolute;
            top: 50%;
            left: 50%;
            width: 150px;
            height: 150px;
            border-radius: 50%;
            translate: -50% -50%;
            pointer-events: none;
            mix-blend-mode: color-burn;
        }
      `}
        </style>
    )
}

Related examples

Latest in React

Motion+

Unlock all 400+ examples

  • Source code for every Plus example.
  • Provide examples direct to your agent via Motion's MCP.
  • Lifetime access to new examples and APIs.