// 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>
);
}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
npm install framer-motion react-iconsDependencies
Discussion
Feedback, implementation tips, and usage notes.
Comments
Sign in to join the conversation
Similar Components
More from the same category




