Home/Components/Social Links/Magnetic Social Links - React Animated Footer Social Icons
Code · Live Preview
Componenttsx
// src/components/SocialLinks/SocialLinks.tsx
"use client";

/**
 * SocialLinks — animated footer social icons
 * Stack: React + TypeScript + Tailwind CSS + Framer Motion
 * Deps:  npm i framer-motion react-icons
 *
 * Hover / keyboard focus:
 *  - magnetic pull toward the cursor
 *  - brand colour rises inside the circle like liquid
 *  - icon rolls up and a white copy rolls in
 *  - label pops above the icon
 * Respects prefers-reduced-motion (falls back to a simple colour fade).
 */

import { useState, type PointerEvent } from "react";
import {
  motion,
  useMotionValue,
  useSpring,
  useReducedMotion,
  type Transition,
  type Variants,
} from "framer-motion";
import type { IconType } from "react-icons";
import {
  FaGithub,
  FaXTwitter,
  FaLinkedinIn,
  FaInstagram,
  FaYoutube,
  FaDiscord,
} from "react-icons/fa6";

export interface SocialLink {
  /** Accessible name and tooltip text */
  label: string;
  href: string;
  icon: IconType;
  /** Any CSS background value: hex, rgb, or a gradient */
  color: string;
}

export interface SocialLinksProps {
  links?: SocialLink[];
  /** Button diameter in px */
  size?: number;
  className?: string;
}

interface SocialItemProps {
  item: SocialLink;
  size: number;
  reduce: boolean;
}

type HoverState = "rest" | "hover";

const DEFAULT_LINKS: SocialLink[] = [
  {
    label: "GitHub",
    href: "https://github.com",
    icon: FaGithub,
    color: "#24292f",
  },
  { label: "X", href: "https://x.com", icon: FaXTwitter, color: "#0f1419" },
  {
    label: "LinkedIn",
    href: "https://linkedin.com",
    icon: FaLinkedinIn,
    color: "#0a66c2",
  },
  {
    label: "Instagram",
    href: "https://instagram.com",
    icon: FaInstagram,
    color: "linear-gradient(45deg,#f09433,#e6683c,#dc2743,#cc2366,#bc1888)",
  },
  {
    label: "YouTube",
    href: "https://youtube.com",
    icon: FaYoutube,
    color: "#ff0000",
  },
  {
    label: "Discord",
    href: "https://discord.com",
    icon: FaDiscord,
    color: "#5865f2",
  },
];

const listVariants: Variants = {
  hidden: {},
  show: { transition: { staggerChildren: 0.06 } },
};

const itemVariants: Variants = {
  hidden: { opacity: 0, y: 12 },
  show: {
    opacity: 1,
    y: 0,
    transition: { type: "spring", stiffness: 260, damping: 20 },
  },
};

const tooltipVariants: Variants = {
  rest: { opacity: 0, y: 6, scale: 0.8 },
  hover: { opacity: 1, y: 0, scale: 1 },
};

const spring: Transition = { type: "spring", stiffness: 280, damping: 22 };
const magnetSpring = { stiffness: 300, damping: 18, mass: 0.4 };

function SocialItem({ item, size, reduce }: SocialItemProps) {
  const [active, setActive] = useState<boolean>(false);

  // magnetic offset
  const x = useMotionValue(0);
  const y = useMotionValue(0);
  const sx = useSpring(x, magnetSpring);
  const sy = useSpring(y, magnetSpring);

  const onMove = (e: PointerEvent<HTMLDivElement>) => {
    if (reduce) return;
    const r = e.currentTarget.getBoundingClientRect();
    x.set((e.clientX - r.left - r.width / 2) * 0.35);
    y.set((e.clientY - r.top - r.height / 2) * 0.35);
  };

  const onLeave = () => {
    x.set(0);
    y.set(0);
    setActive(false);
  };

  const Icon = item.icon;
  const iconSize = Math.round(size * 0.42);
  const state: HoverState = active ? "hover" : "rest";

  const fill: Variants = reduce
    ? { rest: { opacity: 0 }, hover: { opacity: 1 } }
    : { rest: { y: "105%", rotate: -20 }, hover: { y: "-18%", rotate: 0 } };

  const iconOut: Variants = reduce
    ? { rest: { opacity: 1 }, hover: { opacity: 0 } }
    : { rest: { y: "0%" }, hover: { y: "-120%" } };

  const iconIn: Variants = reduce
    ? { rest: { opacity: 0 }, hover: { opacity: 1 } }
    : { rest: { y: "120%" }, hover: { y: "0%" } };

  return (
    <motion.li variants={itemVariants} className="relative list-none">
      <motion.div
        className="relative"
        style={{ x: sx, y: sy }}
        initial="rest"
        animate={state}
        onPointerMove={onMove}
        onPointerEnter={() => setActive(true)}
        onPointerLeave={onLeave}
        onFocus={() => setActive(true)}
        onBlur={() => setActive(false)}
      >
        {/* Tooltip */}
        <span className="pointer-events-none absolute bottom-full left-1/2 mb-3 -translate-x-1/2">
          <motion.span
            variants={tooltipVariants}
            transition={spring}
            className="relative block whitespace-nowrap rounded-md bg-neutral-900 px-2 py-1 text-xs font-medium text-white dark:bg-white dark:text-neutral-900"
          >
            {item.label}
            <span className="absolute left-1/2 top-full -mt-1 h-2 w-2 -translate-x-1/2 rotate-45 bg-neutral-900 dark:bg-white" />
          </motion.span>
        </span>

        {/* Button */}
        <motion.a
          href={item.href}
          target="_blank"
          rel="noopener noreferrer"
          aria-label={item.label}
          whileTap={{ scale: 0.9 }}
          style={{ width: size, height: size }}
          className="relative grid place-items-center overflow-hidden rounded-full border border-neutral-300 text-neutral-700 outline-none transition-colors focus-visible:ring-2 focus-visible:ring-neutral-900 focus-visible:ring-offset-2 dark:border-neutral-700 dark:text-neutral-300 dark:focus-visible:ring-white dark:focus-visible:ring-offset-neutral-950"
        >
          {/* Liquid fill */}
          <motion.span
            aria-hidden="true"
            variants={fill}
            transition={{ type: "spring", stiffness: 200, damping: 22 }}
            className="absolute -left-1/4 top-0 h-[150%] w-[150%] rounded-[42%]"
            style={{ background: item.color }}
          />

          {/* Icon roll */}
          <span
            className="relative overflow-hidden"
            style={{ width: iconSize, height: iconSize }}
          >
            <motion.span
              variants={iconOut}
              transition={spring}
              className="absolute inset-0 grid place-items-center"
            >
              <Icon size={iconSize} />
            </motion.span>
            <motion.span
              variants={iconIn}
              transition={{ ...spring, delay: reduce ? 0 : 0.03 }}
              className="absolute inset-0 grid place-items-center text-white"
            >
              <Icon size={iconSize} />
            </motion.span>
          </span>
        </motion.a>
      </motion.div>
    </motion.li>
  );
}

export default function SocialLinks({
  links = DEFAULT_LINKS,
  size = 48,
  className = "",
}: SocialLinksProps) {
  const reduce = useReducedMotion() ?? false;

  return (
    <motion.ul
      variants={listVariants}
      initial="hidden"
      whileInView="show"
      viewport={{ once: true, amount: 0.6 }}
      className={`flex flex-wrap items-center gap-3 pt-10 ${className}`}
      aria-label="Social media"
    >
      {links.map((item) => (
        <SocialItem key={item.label} item={item} size={size} reduce={reduce} />
      ))}
    </motion.ul>
  );
}
DesktopSandpackTailwind v4

Component details

Overview, install command, dependencies, and project links.

A set of animated social media icons for React built with TypeScript, Framer Motion, React Icons, and Tailwind CSS. It works well for portfolio footers, landing pages, personal sites, link-in-bio pages, and contact sections. Each icon sits in a round outlined button, and hovering or focusing it with the keyboard starts four effects together. The button leans toward the cursor with a springy magnetic pull. The brand color rises inside the circle like liquid, from a tilted, rounded blob that straightens as it fills. The icon rolls up and out while a white copy rolls in from below. A small tooltip with the platform name pops up above the button. The icons also fade and slide up one after another the first time the list scrolls into view, and each button shrinks slightly when tapped. The color for each link accepts any CSS background value, so you can use a solid brand hex or a gradient such as Instagram's. Pass your own links array with any icon from React Icons, or use the built-in defaults for GitHub, X, LinkedIn, Instagram, YouTube, and Discord. The size prop sets the button diameter, and the icon scales with it. The borders and tooltip adapt to light and dark mode through Tailwind's dark: classes. Every link has an accessible label, opens in a new tab with rel="noopener noreferrer", and shows a visible focus ring for keyboard users. When prefers-reduced-motion is enabled, the magnetic pull and rolling motion are turned off and the hover falls back to a simple color fade. It works in Vite, Create React App, and Next.js.

Install

terminal
npm
npm install framer-motion react-icons

Dependencies

framer-motionreact-icons
Added Sep 29, 2026

Discussion

Feedback, implementation tips, and usage notes.

0 threads

Comments

Sign in to join the conversation