Orbiting Skills

Animated skill badges that orbit a center element. On desktop the ring follows the cursor on hover; on mobile it's always visible.

Made by Léo
Open in

With icons

Text only

demo-orbiting-skills.tsx
"use client";

import type { ReactNode } from "react";

import {
  FirstBracketIcon as Braces,
  Database01Icon as Database,
  FigmaIcon as Figma,
  Github01Icon as Github,
  Globe02Icon as Globe,
  Layers01Icon as Layers,
  ColorsIcon as Palette,
  FastWindIcon as Wind,
} from "hugeicons-react";

import {
  OrbitingSkills,
  type OrbitSkillItem,
  type OrbitingSkillsProps,
} from "@/components/unlumen-ui/orbiting-skills";

const ICON_SKILLS: OrbitSkillItem[] = [
  { label: "React", icon: <Globe className="size-4" /> },
  { label: "TypeScript", icon: <Braces className="size-4" /> },
  { label: "Tailwind", icon: <Wind className="size-4" /> },
  { label: "Figma", icon: <Figma className="size-4" /> },
  { label: "GitHub", icon: <Github className="size-4" /> },
  { label: "Database", icon: <Database className="size-4" /> },
];

const TEXT_SKILLS: OrbitSkillItem[] = [
  { label: "Design" },
  { label: "Motion" },
  { label: "UX" },
  { label: "CSS" },
  { label: "a11y" },
  { label: "Perf" },
];

type OrbitingSkillsDemoProps = Pick<
  OrbitingSkillsProps,
  "radius" | "duration" | "showPath" | "followCursor"
>;

function Avatar({ children }: { children: ReactNode }) {
  return (
    <div className="flex size-20 items-center justify-center rounded-full border border-border bg-background shadow-md">
      {children}
    </div>
  );
}

export const OrbitingSkillsDemo = ({
  radius = 96,
  duration = 18,
  showPath = true,
  followCursor = true,
}: OrbitingSkillsDemoProps) => {
  return (
    <div className="flex min-h-[400px] flex-wrap items-center justify-center gap-24 p-16">
      {/* ── With Lucide icons ── */}
      <div className="flex flex-col items-center gap-4">
        <OrbitingSkills
          items={ICON_SKILLS}
          radius={radius}
          duration={duration}
          showPath={showPath}
          followCursor={followCursor}
        >
          <Avatar>
            <Layers className="size-8 text-foreground/70" />
          </Avatar>
        </OrbitingSkills>
        <p className="text-xs text-muted-foreground">With icons</p>
      </div>

      {/* ── Text only ── */}
      <div className="flex flex-col items-center gap-4">
        <OrbitingSkills
          items={TEXT_SKILLS}
          radius={radius}
          duration={duration}
          showPath={showPath}
          followCursor={followCursor}
        >
          <Avatar>
            <Palette className="size-8 text-foreground/70" />
          </Avatar>
        </OrbitingSkills>
        <p className="text-xs text-muted-foreground">Text only</p>
      </div>
    </div>
  );
};

Installation

Install the following dependencies:

Copy and paste the following code into your project:

components/unlumen-ui/orbiting-skills.tsx
"use client";

import * as React from "react";
import {
  AnimatePresence,
  motion,
  useInView,
  useMotionValue,
  useSpring,
} from "motion/react";

import { cn } from "@/lib/utils";

export type OrbitSkillItem = {
  /** Short label displayed under the icon */
  label: string;
  /** Icon element (e.g. an <svg> or <img>) */
  icon?: React.ReactNode;
};

export interface OrbitingSkillsProps {
  /** Skill items to orbit around the center element */
  items: OrbitSkillItem[];
  /**
   * Orbit radius in pixels.
   * @default 88
   */
  radius?: number;
  /**
   * Duration of one full orbit rotation, in seconds.
   * @default 18
   */
  duration?: number;
  /** Whether to render the circular orbit path */
  showPath?: boolean;
  /**
   * On desktop the orbit appears only on hover and follows the cursor.
   * Set to `false` to keep it centered at all times.
   * @default true
   */
  followCursor?: boolean;
  /** Center content (avatar, logo, …) */
  children?: React.ReactNode;
  className?: string;
}

function useIsMobile() {
  const [mobile, setMobile] = React.useState(false);
  React.useEffect(() => {
    const mq = window.matchMedia("(max-width: 767px)");
    setMobile(mq.matches);
    const handler = (e: MediaQueryListEvent) => setMobile(e.matches);
    mq.addEventListener("change", handler);
    return () => mq.removeEventListener("change", handler);
  }, []);
  return mobile;
}

type BadgeProps = {
  item: OrbitSkillItem;
  index: number;
  total: number;
  radius: number;
  duration: number;
  size?: "sm" | "md";
};

function OrbitBadge({
  item,
  index,
  total,
  radius,
  duration,
  size = "md",
}: BadgeProps) {
  const startAngle = (360 / total) * index;
  const isSm = size === "sm";

  return (
    <motion.div
      className="absolute"
      style={{ width: 0, height: 0 }}
      initial={{ rotate: startAngle }}
      animate={{ rotate: startAngle + 360 }}
      transition={{ duration, ease: "linear", repeat: Infinity }}
    >
      <motion.div
        className="absolute"
        style={{ y: -radius }}
        initial={{ scale: 0, opacity: 0 }}
        animate={{ scale: 1, opacity: 1 }}
        transition={{
          delay: index * 0.1,
          duration: 0.5,
          type: "spring",
          bounce: 0.45,
        }}
      >
        {/* counter-rotate so the badge stays upright */}
        <motion.div
          className={cn(
            "flex flex-col items-center gap-0.5 whitespace-nowrap rounded-xl border border-border bg-background shadow-sm",
            isSm ? "px-2 py-1" : "px-3 py-2",
          )}
          style={{ x: "-50%", y: "-50%" }}
          initial={{ rotate: -startAngle }}
          animate={{ rotate: -startAngle - 360 }}
          transition={{ duration, ease: "linear", repeat: Infinity }}
        >
          {item.icon && (
            <span
              className={cn("leading-none", isSm ? "text-sm" : "text-base")}
            >
              {item.icon}
            </span>
          )}
          <span
            className={cn(
              "font-medium leading-none",
              isSm ? "text-[8px]" : "text-[10px]",
            )}
          >
            {item.label}
          </span>
        </motion.div>
      </motion.div>
    </motion.div>
  );
}

type RingProps = { radius: number; cx?: number; cy?: number };

function OrbitRing({ radius, cx, cy }: RingProps) {
  return (
    <svg
      className="pointer-events-none absolute"
      style={{
        width: radius * 2,
        height: radius * 2,
        left: cx !== undefined ? cx - radius : 0,
        top: cy !== undefined ? cy - radius : 0,
      }}
    >
      <circle
        className="stroke-foreground/8 stroke-1"
        cx={radius}
        cy={radius}
        r={radius - 0.5}
        fill="none"
      />
    </svg>
  );
}

export function OrbitingSkills({
  items,
  radius = 88,
  duration = 18,
  showPath = true,
  followCursor = true,
  children,
  className,
}: OrbitingSkillsProps) {
  const wrapperRef = React.useRef<HTMLDivElement>(null);
  const centerRef = React.useRef<HTMLDivElement>(null);
  const isInView = useInView(wrapperRef, {
    once: true,
    margin: "0px 0px -60px 0px",
  });
  const isMobile = useIsMobile();

  const rawX = useMotionValue(0);
  const rawY = useMotionValue(0);
  const x = useSpring(rawX, { stiffness: 200, damping: 18 });
  const y = useSpring(rawY, { stiffness: 200, damping: 18 });

  const [hovered, setHovered] = React.useState(false);

  const handleMouseMove = React.useCallback(
    (e: React.MouseEvent<HTMLDivElement>) => {
      if (!wrapperRef.current) return;
      const rect = wrapperRef.current.getBoundingClientRect();
      rawX.set(e.clientX - rect.left);
      rawY.set(e.clientY - rect.top);
    },
    [rawX, rawY],
  );

  // Default cursor position to center of wrapper on mount (prevents flash)
  React.useEffect(() => {
    if (!wrapperRef.current) return;
    const { width, height } = wrapperRef.current.getBoundingClientRect();
    rawX.set(width / 2);
    rawY.set(height / 2);
  }, [rawX, rawY]);

  const showDesktopOrbit =
    !isMobile && followCursor ? hovered : !isMobile && !followCursor;
  const mobileRadius = Math.round(radius * 0.78);

  return (
    <div
      ref={wrapperRef}
      className={cn("relative", className)}
      onMouseEnter={() => setHovered(true)}
      onMouseLeave={() => setHovered(false)}
      onMouseMove={handleMouseMove}
    >
      <div ref={centerRef}>{children}</div>

      {isMobile && children && (
        <div
          className="pointer-events-none absolute inset-0"
          aria-hidden="true"
        >
          <MobileCenterOrbit
            items={items}
            radius={mobileRadius}
            duration={duration}
            showPath={showPath}
            isInView={isInView}
            centerRef={centerRef}
          />
        </div>
      )}

      <AnimatePresence>
        {showDesktopOrbit && (
          <motion.div
            className="pointer-events-none absolute inset-0 z-10 overflow-visible"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: 0.25 }}
            aria-hidden="true"
          >
            <motion.div className="absolute" style={{ left: x, top: y }}>
              {showPath && <OrbitRing radius={radius} cx={0} cy={0} />}
              {items.map((item, i) => (
                <OrbitBadge
                  key={item.label}
                  item={item}
                  index={i}
                  total={items.length}
                  radius={radius}
                  duration={duration}
                />
              ))}
            </motion.div>
          </motion.div>
        )}
      </AnimatePresence>

      {!isMobile && !followCursor && (
        <div
          className="pointer-events-none absolute inset-0 overflow-visible"
          aria-hidden="true"
        >
          <DesktopCenteredOrbit
            items={items}
            radius={radius}
            duration={duration}
            showPath={showPath}
            centerRef={centerRef}
          />
        </div>
      )}
    </div>
  );
}

type SubOrbitProps = {
  items: OrbitSkillItem[];
  radius: number;
  duration: number;
  showPath: boolean;
  centerRef: React.RefObject<HTMLDivElement | null>;
};

function DesktopCenteredOrbit({
  items,
  radius,
  duration,
  showPath,
  centerRef,
}: SubOrbitProps) {
  const [center, setCenter] = React.useState<{ x: number; y: number } | null>(
    null,
  );

  React.useLayoutEffect(() => {
    if (!centerRef.current) return;
    const el = centerRef.current;
    const parent = el.closest("[data-orbit-root]") ?? el.parentElement;
    if (!parent) return;
    const pr = parent.getBoundingClientRect();
    const cr = el.getBoundingClientRect();
    setCenter({
      x: cr.left - pr.left + cr.width / 2,
      y: cr.top - pr.top + cr.height / 2,
    });
  }, [centerRef]);

  if (!center) return null;

  return (
    <div className="absolute" style={{ left: center.x, top: center.y }}>
      {showPath && <OrbitRing radius={radius} cx={0} cy={0} />}
      {items.map((item, i) => (
        <OrbitBadge
          key={item.label}
          item={item}
          index={i}
          total={items.length}
          radius={radius}
          duration={duration}
        />
      ))}
    </div>
  );
}

type MobileSubOrbitProps = SubOrbitProps & { isInView: boolean };

function MobileCenterOrbit({
  items,
  radius,
  duration,
  showPath,
  isInView,
  centerRef,
}: MobileSubOrbitProps) {
  const [center, setCenter] = React.useState<{ x: number; y: number } | null>(
    null,
  );

  React.useLayoutEffect(() => {
    if (!centerRef.current) return;
    const el = centerRef.current;
    const parent = el.closest("[data-orbit-root]") ?? el.parentElement;
    if (!parent) return;
    const pr = parent.getBoundingClientRect();
    const cr = el.getBoundingClientRect();
    setCenter({
      x: cr.left - pr.left + cr.width / 2,
      y: cr.top - pr.top + cr.height / 2,
    });
  }, [centerRef]);

  if (!center) return null;

  return (
    <div className="absolute" style={{ left: center.x, top: center.y }}>
      {showPath && (
        <motion.svg
          className="pointer-events-none absolute"
          style={{
            width: radius * 2,
            height: radius * 2,
            left: -radius,
            top: -radius,
          }}
          initial={{ scale: 0.5, opacity: 0 }}
          animate={
            isInView ? { scale: 1, opacity: 1 } : { scale: 0.5, opacity: 0 }
          }
          transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
        >
          <circle
            className="stroke-foreground/5 stroke-1"
            cx={radius}
            cy={radius}
            r={radius - 0.5}
            fill="none"
          />
        </motion.svg>
      )}
      {isInView &&
        items.map((item, i) => (
          <OrbitBadge
            key={item.label}
            item={item}
            index={i}
            total={items.length}
            radius={radius}
            duration={duration}
            size="sm"
          />
        ))}
    </div>
  );
}

Update the import paths to match your project setup.

Usage

import {
  OrbitingSkills,
  type OrbitSkillItem,
} from "@/components/unlumen-ui/orbiting-skills";

const SKILLS: OrbitSkillItem[] = [
  { label: "React", icon: <span>⚛️</span> },
  { label: "TypeScript", icon: <span>🔷</span> },
  { label: "Tailwind", icon: <span>🌊</span> },
];

<OrbitingSkills items={SKILLS} radius={100}>
  <div className="size-20 rounded-full bg-muted" />
</OrbitingSkills>;

Props

PropTypeDefaultDescription
itemsOrbitSkillItem[]Skill items (label + optional icon) to orbit
radiusnumber88Orbit radius in pixels
durationnumber18Duration of one full orbit in seconds
showPathbooleantrueWhether to render the circular orbit ring
followCursorbooleantrueOn desktop: follow the cursor on hover. Set false to always stay centered
childrenReactNodeCenter element (avatar, logo, …)
classNamestringExtra class names on the wrapper

OrbitSkillItem

type OrbitSkillItem = {
  label: string; // Text displayed under the icon
  icon?: ReactNode; // Any icon element
};

Behavior

  • Desktop (followCursor={true}) — the orbit ring and badges appear on hover and smoothly track the cursor position via a spring animation.
  • Desktop (followCursor={false}) — the orbit is always visible and centered on the children element.
  • Mobile — the orbit is always on and centered, with a spring entrance animation when the component scrolls into view. Badges are slightly smaller to avoid overflow.
  • All badges counter-rotate so their labels stay upright regardless of the orbit angle.

Built by Léo from Unlumen :3

Last updated: 4/29/2026