Motion+

Color picker

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

Vue

Source code

<script setup lang="tsx">
/** @jsxImportSource vue */
import { ref, reactive, computed, watch, PropType, defineComponent } from 'vue'
import { useSpring, useTransform, motion, MotionValue, useMotionValue, animate } from 'motion-v'
import { usePointerPosition } from 'motion-plus-vue'

/**
 * ==============   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
}

// ===============================
// Child components (GradientCircle and ColorDot)
// ===============================

/* ColorDot */
const ColorDot = defineComponent({
  props: {
    ring: Number,
    index: Number,
    totalInRing: Number,
    centerX: Number,
    centerY: Number,
    pointerX: Object as PropType<MotionValue<number>>,
    pointerY: Object as PropType<MotionValue<number>>,
    pushMagnitude: Number,
    pushSpring: Object,
    radius: Number,
    selectedColor: String,
    setSelectedColor: Function
  },
  setup(props) {
    const baseRadius = computed(() => props.ring! * 20)
    const angle = computed(() => calculateAngle(props.index!, props.totalInRing!))
    const basePos = computed(() => calculateBasePosition(angle.value, baseRadius.value))

    const normalizedHue = computed(() => calculateHue(angle.value))
    const color = computed(() => {
      if (props.ring === 0) return "hsl(0, 0%, 100%)"
      if (props.ring === 1)
        return `hsl(${normalizedHue.value}, 60%, 85%)`
      return `hsl(${normalizedHue.value}, 90%, 60%)`
    })

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

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

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

      if (distanceFromCenter > props.radius!) return 0

      const dotX = props.centerX! + basePos.value.x
      const dotY = props.centerY! + basePos.value.y

      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 * props.pushMagnitude!
      }

      return 0
    })

    const pushAngle = useTransform(() => {
      if (!props.centerX || !props.centerY) return angle.value
      const px = props.pointerX!.get()
      const py = props.pointerY!.get()
      const dotX = props.centerX! + basePos.value.x
      const dotY = props.centerY! + basePos.value.y
      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, props.pushSpring)
    const springPushY = useSpring(pushY, props.pushSpring)

    const x = useTransform(() => basePos.value.x + springPushX.get())
    const y = useTransform(() => basePos.value.y + 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
        class="color-dot"
        style={{
          x: x,
          y,
          backgroundColor: color.value,
          willChange: "transform, background-color",
        }}
        variants={dotVariants}
        initial="default"
        whileHover="hover"
        whilePress={{ scale: 1.2 }}
        onPress={() => {
          if (selectedColor === color) {
            props.setSelectedColor?.(null)
          } else {
            props.setSelectedColor?.(color.value)
          }
        }}
        transition={{
          scale: { type: "spring", damping: 30, stiffness: 200 },
        }}

      >
        <motion.div class="color-dot-ring" variants={ringVariants} />
      </motion.div>
    )
  }
})

/* GradientCircle */
const GradientCircle = defineComponent({
  props: {
    index: Number,
    totalInRing: Number,
    centerX: Number,
    centerY: Number,
    pointerX: { type: Object as PropType<MotionValue<number>> },
    pointerY: { type: Object as PropType<MotionValue<number>> },
    containerRadius: Number
  },
  setup(props) {
    const angle = computed(() => calculateAngle(props.index!, props.totalInRing!))
    const baseRadius = computed(() => props.containerRadius! - 40)
    const basePos = computed(() => calculateBasePosition(angle.value, baseRadius.value))
    const normalizedHue = computed(() => calculateHue(angle.value))
    const gradient = computed(() =>
      `radial-gradient(circle, hsla(${normalizedHue.value}, 90%, 60%, 1) 0%, hsla(${normalizedHue.value}, 90%, 60%, 0) 66%)`
    )

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

      const px = props.pointerX?.get()!
      const py = props.pointerY?.get()!

      const gradientX = props.centerX! + basePos.value.x
      const gradientY = props.centerY! + basePos.value.y

      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 = useTransform(proximity, [0, 1], [0.15, 0.35])
    const scale = useTransform(proximity, [0, 1], [1, 1.2])

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


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

const containerRef = ref<HTMLDivElement>()
const containerDimensions = reactive({
  centerX: 0,
  centerY: 0,
  radius: 200,
})
const { x: pointerX, y: pointerY } = usePointerPosition()
const selectedColor = ref<string | null>(null)

watch(containerRef, () => {
  if (containerRef.value) {
    const rect = containerRef.value.getBoundingClientRect()
    containerDimensions.centerX = rect.left + rect.width / 2
    containerDimensions.centerY = rect.top + rect.height / 2
    containerDimensions.radius = rect.width / 2
  }
}, {
  flush: 'pre'
})

const rings = [{ count: 1 }, { count: 6 }, { count: 12 }]
const dots = computed(() => {
  const arr: Array<{
    ring: number
    index: number
    totalInRing: number
  }> = []
  rings.forEach((ring, ringIndex) => {
    for (let i = 0; i < ring.count; i++) {
      arr.push({
        ring: ringIndex,
        index: i,
        totalInRing: ring.count,
      })
    }
  })
  return arr
})

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

watch(selectedColor, () => {
  if (selectedColor.value !== null) {
    for (const stopValue of stopMotionValues) {
      animate(stopValue, selectedColor.value, {
        duration: 0.2,
      })
    }
  } else {
    for (let i = 0; i < stopMotionValues.length; i++) {
      animate(stopMotionValues[i], originalStopValues[i], {
        duration: 0.2,
      })
    }
  }
}, {
  immediate: true,
  flush: 'post'
})

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)

watch([selectedColor, gradientScale], () => {
  if (selectedColor.value !== 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,
    })
  }
}, {
  immediate: true,
  flush: 'post'
})

const handleSetSelectedColor = (color: string | null) => {
  selectedColor.value = color === selectedColor.value ? null : color as string
}
</script>

<template>
  <div class="gradient-wrapper" ref="gradientWrapper">
    <div class="background">
      <motion.div 
      class="gradient-background" 
      :style="{
        background: gradientBackground,
        scale: gradientScale,
      }"
      />
      <motion.div
          class="solid-background"
          :animate="{
              scale: selectedColor !== null ? 0.9 : 0.98,
          }"
          :transition="{
              type: 'spring',
              visualDuration: 0.2,
              bounce: 0.2,
          }"
      />

    </div>
    <div ref="containerRef" class="picker-background">
        <GradientCircle
          v-for="(dot, index) in dots"
          :key="`gradient-${index}`"
          :index="dot.index"
          :totalInRing="dot.totalInRing"
          :centerX="containerDimensions.centerX"
          :centerY="containerDimensions.centerY"
          :pointerX="pointerX"
          :pointerY="pointerY"
          :containerRadius="containerDimensions.radius"
        />
        <ColorDot
          v-for="dot in dots.slice().reverse()"
          :key="`${dot.ring}-${dot.index}`"
          :ring="dot.ring"
          :index="dot.index"
          :totalInRing="dot.totalInRing"
          :centerX="containerDimensions.centerX"
          :centerY="containerDimensions.centerY"
          :pointerX="pointerX"
          :pointerY="pointerY"
          :radius="containerDimensions.radius"
          :pushMagnitude="5"
          :pushSpring="{
              damping: 30,
              stiffness: 100,
          }"
          :selectedColor="selectedColor ?? undefined"
          :setSelectedColor="handleSetSelectedColor"
        />
    </div>

  </div>
</template>

<style scoped>
.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: #0b1011;
    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;
    transition: opacity 0.13s;
}
.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 Vue

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.