"use client";
import React, {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import {
motion,
useMotionValue,
useReducedMotion,
useSpring,
useTransform,
} from "motion/react";
import { cn } from "@/lib/utils";
export interface TwitterCardProps {
username: string;
name?: string;
avatarUrl?: string;
bannerUrl?: string;
staticCard?: boolean;
joinedDate?: string;
year?: number | string;
text?: string;
linkText?: string;
href?: string;
enableLinkTilt?: boolean;
linkTiltMaxRotate?: number;
enableCardTilt?: boolean;
cardTiltMaxRotate?: number;
className?: string;
popoverClassName?: string;
linkClassName?: string;
labelClassName?: string;
}
const VIEWPORT_MARGIN = 24;
const XIcon = ({ className }: { className?: string }) => (
);
const VerifiedBadge = ({ className }: { className?: string }) => (
);
const LocationIcon = ({ className }: { className?: string }) => (
);
const LinkIcon = ({ className }: { className?: string }) => (
);
const CalendarIcon = ({ className }: { className?: string }) => (
);
const DefaultAvatar = ({ className }: { className?: string }) => (
);
export const TwitterCard = ({
username,
name = "Twitter User",
avatarUrl,
bannerUrl,
staticCard = false,
joinedDate,
year = 2026,
text = "Follow me on",
linkText = "X",
href,
enableLinkTilt = true,
linkTiltMaxRotate = 5,
enableCardTilt = true,
cardTiltMaxRotate = 5,
className,
popoverClassName,
linkClassName,
labelClassName,
}: TwitterCardProps) => {
const prefersReducedMotion = useReducedMotion();
const [isHovered, setIsHovered] = useState(false);
const [failedAvatar, setFailedAvatar] = useState(null);
const [failedBanner, setFailedBanner] = useState(null);
const profileUrl = href || `https://x.com/${username}`;
const linkRef = useRef(null);
const cardRef = useRef(null);
const containerRef = useRef(null);
const tapRevealRef = useRef(false);
const [cardShift, setCardShift] = useState(0);
const [profile, setProfile] = useState({
name: name || "Twitter User",
avatarUrl: avatarUrl || "",
bannerUrl: bannerUrl || "",
bio: "This user hasn't added a bio yet.",
following: 0,
followers: 0,
joinedDate: joinedDate || `Joined ${year}`,
location: "",
website: null as { url: string; display_url: string } | null,
verified: false,
});
useEffect(() => {
if (!username) return;
let isMounted = true;
const controller = new AbortController();
fetch(`https://api.fxtwitter.com/${username}`, {
signal: controller.signal,
})
.then((res) => {
if (!res.ok) throw new Error("API request failed");
return res.json();
})
.then((data) => {
if (!isMounted) return;
if (data.code === 200 && data.user) {
const user = data.user;
const formattedJoined = user.joined
? `Joined ${new Date(user.joined).toLocaleDateString("en-US", {
month: "long",
year: "numeric",
})}`
: joinedDate || `Joined ${year}`;
setProfile({
name: user.name || name || "Twitter User",
avatarUrl:
user.avatar_url?.replace("_normal", "_400x400") ||
avatarUrl ||
"",
bannerUrl: user.banner_url || bannerUrl || "",
bio: user.description || "This user hasn't added a bio yet.",
following: user.following ?? 0,
followers: user.followers ?? 0,
joinedDate: formattedJoined,
location: user.location || "",
website: user.website || null,
verified: Boolean(user.verification?.verified),
});
}
})
.catch(() => {});
return () => {
isMounted = false;
controller.abort();
};
}, [username, name, avatarUrl, bannerUrl, joinedDate, year]);
const linkX = useMotionValue(0);
const linkY = useMotionValue(0);
const linkSpringX = useSpring(linkX, { stiffness: 320, damping: 24 });
const linkSpringY = useSpring(linkY, { stiffness: 320, damping: 24 });
const linkRotateX = useTransform(
linkSpringY,
[-1, 1],
[linkTiltMaxRotate, -linkTiltMaxRotate],
);
const linkRotateY = useTransform(
linkSpringX,
[-1, 1],
[-linkTiltMaxRotate, linkTiltMaxRotate],
);
const cardX = useMotionValue(0);
const cardY = useMotionValue(0);
const cardSpringX = useSpring(cardX, {
stiffness: 300,
damping: 24,
mass: 0.6,
});
const cardSpringY = useSpring(cardY, {
stiffness: 300,
damping: 24,
mass: 0.6,
});
const cardRotateX = useTransform(
cardSpringY,
[-1, 1],
[cardTiltMaxRotate, -cardTiltMaxRotate],
);
const cardRotateY = useTransform(
cardSpringX,
[-1, 1],
[-cardTiltMaxRotate, cardTiltMaxRotate],
);
const handleLinkMouseMove = useCallback(
(e: React.MouseEvent) => {
if (!enableLinkTilt || !linkRef.current || prefersReducedMotion) return;
const rect = linkRef.current.getBoundingClientRect();
const nx = (e.clientX - rect.left - rect.width / 2) / (rect.width / 2);
const ny = (e.clientY - rect.top - rect.height / 2) / (rect.height / 2);
linkX.set(Math.max(-1, Math.min(1, nx)));
linkY.set(Math.max(-1, Math.min(1, ny)));
},
[enableLinkTilt, linkX, linkY, prefersReducedMotion],
);
const handleCardMouseMove = useCallback(
(e: React.MouseEvent) => {
if (!enableCardTilt || !cardRef.current || prefersReducedMotion) return;
const rect = cardRef.current.getBoundingClientRect();
const nx = (e.clientX - rect.left - rect.width / 2) / (rect.width / 2);
const ny = (e.clientY - rect.top - rect.height / 2) / (rect.height / 2);
cardX.set(Math.max(-1, Math.min(1, nx)));
cardY.set(Math.max(-1, Math.min(1, ny)));
},
[enableCardTilt, cardX, cardY, prefersReducedMotion],
);
const handleContainerMouseLeave = useCallback(() => {
setIsHovered(false);
linkX.set(0);
linkY.set(0);
cardX.set(0);
cardY.set(0);
}, [linkX, linkY, cardX, cardY]);
useLayoutEffect(() => {
if (!isHovered) return;
const updateShift = () => {
const container = containerRef.current;
const card = cardRef.current;
if (!container || !card) return;
const box = container.getBoundingClientRect();
const width = card.offsetWidth;
const naturalLeft = box.left + box.width / 2 - width / 2;
const maxLeft = window.innerWidth - VIEWPORT_MARGIN - width;
const left = Math.max(VIEWPORT_MARGIN, Math.min(maxLeft, naturalLeft));
setCardShift(left - naturalLeft);
};
updateShift();
window.addEventListener("resize", updateShift);
return () => window.removeEventListener("resize", updateShift);
}, [isHovered]);
useEffect(() => {
if (!isHovered) return;
const closeOnOutsidePointer = (event: PointerEvent) => {
if (!containerRef.current?.contains(event.target as Node)) {
handleContainerMouseLeave();
}
};
document.addEventListener("pointerdown", closeOnOutsidePointer);
return () =>
document.removeEventListener("pointerdown", closeOnOutsidePointer);
}, [isHovered, handleContainerMouseLeave]);
const handleTriggerPointerDown = (e: React.PointerEvent) => {
tapRevealRef.current = e.pointerType !== "mouse" && !isHovered;
};
const handleTriggerClick = (e: React.MouseEvent) => {
if (!tapRevealRef.current) return;
tapRevealRef.current = false;
e.preventDefault();
setIsHovered(true);
};
const handleContainerBlur = (e: React.FocusEvent) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
handleContainerMouseLeave();
}
};
const handleContainerKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape" && isHovered) handleContainerMouseLeave();
};
const handleStaticMouseLeave = useCallback(() => {
cardX.set(0);
cardY.set(0);
}, [cardX, cardY]);
const formatCount = (count: number | string | undefined): string => {
if (count === undefined || count === null) return "0";
if (typeof count === "string") return count;
if (count >= 1000000) {
return `${(count / 1000000).toFixed(1).replace(/\.0$/, "")}M`;
}
if (count >= 1000) {
return `${(count / 1000).toFixed(1).replace(/\.0$/, "")}K`;
}
return count.toLocaleString("en-US");
};
const ProfileLink = staticCard ? "span" : "a";
const cardContent = (
{profile.bannerUrl && failedBanner !== profile.bannerUrl ? (

setFailedBanner(profile.bannerUrl)}
className="h-full w-full object-cover"
/>
) : (
)}
{profile.avatarUrl && failedAvatar !== profile.avatarUrl ? (

setFailedAvatar(profile.avatarUrl)}
className="h-full w-full object-cover"
/>
) : (
)}
{profile.name}
{profile.verified && (
)}
@{username}
{profile.bio}
{profile.location && (
{profile.location}
)}
{profile.website && (
{profile.website.display_url}
)}
{profile.joinedDate && (
{profile.joinedDate}
)}
{formatCount(profile.following)}
Following
{formatCount(profile.followers)}
Followers
);
if (staticCard) {
return (
{cardContent}
);
}
return (
{text}
setIsHovered(true)}
onMouseLeave={handleContainerMouseLeave}
onFocus={() => setIsHovered(true)}
onBlur={handleContainerBlur}
onKeyDown={handleContainerKeyDown}
>
{linkText}
{cardContent}
);
};
export default TwitterCard;