Meteor Orbit

PreviousNext

orbit system featuring rotating meteors and gracefully animated icons.

Docs
scrollxuicomponent

Preview

Loading preview…
components/ui/meteor-orbit.tsx
"use client"

import React, { useState } from "react"
import { motion } from "framer-motion"
import { cn } from "@/lib/utils"

interface SocialIcon {
  icon: React.ReactNode
  orbitIndex?: number
  position?: number
}

interface MeteorOrbitProps {
  icons?: SocialIcon[]
  children?: React.ReactNode
  rippleCount?: number
  meteorSpeed?: number
  size?: number
  className?: string
  meteorClassName?: string | string[]
  meteorGradients?: [string, string][]
}

export function MeteorOrbit({
  icons = [],
  children,
  rippleCount = 5,
  meteorSpeed = 4,
  size = 500,
  className,
  meteorClassName = "",
  meteorGradients = [],
}: MeteorOrbitProps) {
  const [animatedIcons, setAnimatedIcons] = useState<Set<number>>(new Set())
  const uniqueId = React.useId()

  React.useEffect(() => {
    icons.forEach((_, index) => {
      setTimeout(() => {
        setAnimatedIcons((prev) => new Set([...prev, index]))
      }, index * 150)
    })
  }, [icons])

  const baseInset = 40
  const rippleBoxes = Array.from({ length: rippleCount }, (_, i) => {
    const insetPercent = baseInset - i * 8
    const radiusPercent = 50 - insetPercent
    return {
      inset: `${insetPercent}%`,
      radius: (size / 2) * (radiusPercent / 50),
      zIndex: 99 - i,
      delay: i * 0.3,
      opacity: 1 - i * 0.15,
    }
  })

  const calculatePosition = (
    index: number,
    total: number,
    radius: number,
    customAngle?: number
  ) => {
    const angle = customAngle !== undefined ? customAngle : (360 / total) * index
    const radian = (angle * Math.PI) / 180
    return { x: Math.cos(radian) * radius, y: Math.sin(radian) * radius }
  }

  const iconsByOrbit = icons.reduce((acc, icon, index) => {
    const orbitIdx = icon.orbitIndex ?? 0
    if (!acc[orbitIdx]) acc[orbitIdx] = []
    acc[orbitIdx].push({ ...icon, originalIndex: index })
    return acc
  }, {} as Record<number, Array<SocialIcon & { originalIndex: number }>>)

  const normalizedMeteorClass = Array.isArray(meteorClassName)
    ? meteorClassName
    : [meteorClassName]

  return (
    <div
      className={cn("relative", className)}
      style={{ width: size, height: size }}
    >
      <div className="absolute inset-0">
        {rippleBoxes.map((box, i) => (
          <div
            key={`ripple-${i}`}
            className="absolute rounded-full border-2 border-border/50"
            style={{
              width: box.radius * 2,
              height: box.radius * 2,
              left: "50%",
              top: "50%",
              marginLeft: -box.radius,
              marginTop: -box.radius,
              zIndex: box.zIndex,
              opacity: box.opacity,
              background: "transparent",
            }}
          >
            <motion.svg
              className="absolute"
              style={{
                left: 0,
                top: 0,
                width: "100%",
                height: "100%",
                overflow: "visible",
              }}
              viewBox={`0 0 ${box.radius * 2} ${box.radius * 2}`}
              animate={{ rotate: [0, 360] }}
              transition={{
                duration: meteorSpeed + i * 0.5,
                repeat: Infinity,
                ease: "linear",
                delay: i * 0.2,
              }}
            >
              <defs>
                <linearGradient
                  id={`${uniqueId}-gradient-${i}`}
                  gradientUnits="userSpaceOnUse"
                  x1={box.radius}
                  y1={0}
                  x2={box.radius + box.radius * Math.cos(Math.PI / 3)}
                  y2={box.radius + box.radius * Math.sin(Math.PI / 3)}
                >
                  {meteorGradients[i] ? (
                    <>
                      <stop offset="0%" stopColor={`${meteorGradients[i][0]}00`} />
                      <stop offset="60%" stopColor={`${meteorGradients[i][0]}99`} />
                      <stop offset="100%" stopColor={meteorGradients[i][1]} />
                    </>
                  ) : (
                    <>
                      <stop offset="0%" stopColor="rgba(34,211,238,0)" />
                      <stop offset="60%" stopColor="rgba(34,211,238,0.6)" />
                      <stop offset="100%" stopColor="rgba(34,211,238,1)" />
                    </>
                  )}
                </linearGradient>
              </defs>
              <path
                d={`M ${box.radius} 0 A ${box.radius} ${box.radius} 0 0 1 ${
                  box.radius + box.radius * Math.cos(Math.PI / 3)
                } ${box.radius + box.radius * Math.sin(Math.PI / 3)}`}
                stroke={`url(#${uniqueId}-gradient-${i})`}
                strokeWidth="2.5"
                fill="none"
                strokeLinecap="round"
                className={cn(normalizedMeteorClass[i % normalizedMeteorClass.length])}
              />
            </motion.svg>
          </div>
        ))}
      </div>

      {Object.entries(iconsByOrbit).map(([orbitIdx, orbitIcons]) => {
        const orbitIndex = Math.min(parseInt(orbitIdx), rippleBoxes.length - 1)
        const iconRippleRadius = rippleBoxes[orbitIndex].radius
        return (
          <div
            key={`orbit-${orbitIdx}`}
            className="absolute"
            style={{
              width: iconRippleRadius * 2,
              height: iconRippleRadius * 2,
              left: "50%",
              top: "50%",
              marginLeft: -iconRippleRadius,
              marginTop: -iconRippleRadius,
              zIndex: 101 + parseInt(orbitIdx),
            }}
          >
            {orbitIcons.map((social, localIndex) => {
              const position = calculatePosition(
                localIndex,
                orbitIcons.length,
                iconRippleRadius,
                social.position
              )
              const isAnimated = animatedIcons.has(social.originalIndex)
              return (
                <div
                  key={`icon-${social.originalIndex}`}
                  className="absolute"
                  style={{
                    left: "50%",
                    top: "50%",
                    marginLeft: -24,
                    marginTop: -24,
                    transform: isAnimated
                      ? `translate(${position.x}px, ${position.y}px) scale(1)`
                      : "translate(0px,0px) scale(0)",
                    transition: "transform 800ms cubic-bezier(0.34,1.56,0.64,1)",
                    opacity: isAnimated ? 1 : 0,
                  }}
                >
                  <motion.div
                    className={cn(
                      "flex items-center justify-center w-12 h-12 rounded-full bg-background text-foreground border border-border shadow-lg"
                    )}
                    whileHover={{ scale: 1.2 }}
                  >
                    {social.icon}
                  </motion.div>
                </div>
              )
            })}
          </div>
        )
      })}

      {children && (
        <div className="absolute inset-0 flex items-center justify-center z-[200] pointer-events-none">
          <motion.div
            initial={{ opacity: 0, scale: 0.5 }}
            animate={{ opacity: 1, scale: 1 }}
            transition={{ delay: 2, duration: 0.8, type: "spring" }}
          >
            {children}
          </motion.div>
        </div>
      )}
    </div>
  )
}

Installation

npx shadcn@latest add @scrollxui/meteor-orbit

Usage

import { MeteorOrbit } from "@/components/meteor-orbit"
<MeteorOrbit />