Interactive Character Face
An interactive character face with morphing emotions and eye-tracking built with Framer Motion.
React Code
"use client";
import React, { useRef, useState, useEffect } from "react";
import { motion, useMotionValue, useSpring, useTransform } from "framer-motion";
const emotions = ["neutral", "happy", "sad", "crying", "laughing", "confused"];
export default function InteractiveFace() {
const [emotionIndex, setEmotionIndex] = useState(0);
const currentEmotion = emotions[emotionIndex];
const containerRef = useRef<HTMLDivElement>(null);
const mouseX = useMotionValue(0);
const mouseY = useMotionValue(0);
const springConfig = { damping: 25, stiffness: 150 };
const smoothX = useSpring(mouseX, springConfig);
const smoothY = useSpring(mouseY, springConfig);
const eyeX = useTransform(smoothX, [-1, 1], [-12, 12]);
const eyeY = useTransform(smoothY, [-1, 1], [-12, 12]);
const faceX = useTransform(smoothX, [-1, 1], [-4, 4]);
const faceY = useTransform(smoothY, [-1, 1], [-4, 4]);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
// Normalize -1 to 1 based on window width to make it react across the screen
const x = (e.clientX - centerX) / (window.innerWidth / 2);
const y = (e.clientY - centerY) / (window.innerHeight / 2);
mouseX.set(Math.max(-1, Math.min(1, x)));
mouseY.set(Math.max(-1, Math.min(1, y)));
};
window.addEventListener("mousemove", handleMouseMove);
return () => window.removeEventListener("mousemove", handleMouseMove);
}, [mouseX, mouseY]);
const handleFaceClick = () => {
setEmotionIndex((prev) => (prev + 1) % emotions.length);
};
const leftEyeVariants = {
neutral: { width: 24, height: 48, y: 0, rotate: 0, borderRadius: "24px" },
happy: { width: 32, height: 16, y: -8, rotate: -15, borderRadius: "16px" },
sad: { width: 24, height: 32, y: 12, rotate: 10, borderRadius: "16px" },
crying: { width: 24, height: 24, y: 10, rotate: 15, borderRadius: "12px" },
laughing: { width: 36, height: 12, y: -12, rotate: -25, borderRadius: "12px" },
confused: { width: 36, height: 36, y: -5, rotate: 0, borderRadius: "36px" },
};
const rightEyeVariants = {
neutral: { width: 24, height: 48, y: 0, rotate: 0, borderRadius: "24px" },
happy: { width: 32, height: 16, y: -8, rotate: 15, borderRadius: "16px" },
sad: { width: 24, height: 32, y: 12, rotate: -10, borderRadius: "16px" },
crying: { width: 24, height: 24, y: 10, rotate: -15, borderRadius: "12px" },
laughing: { width: 36, height: 12, y: -12, rotate: 25, borderRadius: "12px" },
confused: { width: 16, height: 16, y: 5, rotate: 0, borderRadius: "16px" },
};
const mouthVariants = {
neutral: { width: 48, height: 16, y: 0, rotate: 0, borderRadius: "16px" },
happy: { width: 64, height: 32, y: 10, rotate: 0, borderRadius: "8px 8px 32px 32px" },
sad: { width: 56, height: 24, y: 24, rotate: 0, borderRadius: "24px 24px 8px 8px" },
crying: { width: 64, height: 32, y: 28, rotate: 0, borderRadius: "32px 32px 12px 12px" },
laughing: { width: 72, height: 56, y: 10, rotate: 0, borderRadius: "12px 12px 48px 48px" },
confused: { width: 32, height: 12, y: 15, rotate: -25, borderRadius: "16px" },
};
return (
<div className="w-full flex items-center justify-center p-8 bg-transparent" ref={containerRef}>
<motion.div
className="relative flex items-center justify-center cursor-pointer"
style={{ width: "240px", height: "240px" }}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
transition={{ type: "spring", stiffness: 300, damping: 20 }}
onClick={handleFaceClick}
>
{/* Ears from Figma */}
<div className="absolute inset-0 z-0 pointer-events-none">
<img
src="/images/ears/left_ear.svg?v=2"
className="absolute max-w-none"
style={{ width: "335px", height: "335px", left: "-80px", top: "-82px", transform: "scaleX(-1)" }}
alt=""
/>
<img
src="/images/ears/right_ear.svg?v=2"
className="absolute max-w-none"
style={{ width: "335px", height: "335px", left: "-12px", top: "-82px" }}
alt=""
/>
</div>
{/* Face Plate */}
<div
className="absolute inset-0 overflow-hidden z-10"
style={{
border: "24px solid rgba(31,33,35,0.33)",
borderRadius: "56px",
backdropFilter: "blur(12px)",
}}
>
<div className="absolute inset-0 bg-black/5 pointer-events-none" />
<motion.div
className="relative w-full h-full flex flex-col items-center justify-center pointer-events-none"
style={{ x: faceX, y: faceY }}
>
<div className="flex gap-10 mb-6 relative z-10">
{/* Left Eye Wrapper for tracking */}
<motion.div style={{ x: eyeX, y: eyeY }} className="w-[36px] h-[48px] flex items-center justify-center">
<motion.div
variants={leftEyeVariants}
animate={currentEmotion}
transition={{ type: "spring", stiffness: 200, damping: 15 }}
style={{ backgroundColor: "#EC4899" }}
/>
</motion.div>
{/* Right Eye Wrapper for tracking */}
<motion.div style={{ x: eyeX, y: eyeY }} className="w-[36px] h-[48px] flex items-center justify-center">
<motion.div
variants={rightEyeVariants}
animate={currentEmotion}
transition={{ type: "spring", stiffness: 200, damping: 15 }}
style={{ backgroundColor: "#EC4899" }}
/>
</motion.div>
</div>
{/* Mouth */}
<motion.div
variants={mouthVariants}
animate={currentEmotion}
transition={{ type: "spring", stiffness: 200, damping: 15 }}
className="relative z-10" style={{ backgroundColor: "#EC4899" }}
/>
{/* Tears for Crying Emotion */}
{currentEmotion === "crying" && (
<>
<motion.div
className="absolute bg-blue-500 rounded-b-full rounded-t-sm w-3 h-4 left-[30%]"
initial={{ opacity: 0, top: "45%" }}
animate={{ opacity: [0, 1, 0], top: ["45%", "65%", "75%"] }}
transition={{ repeat: Infinity, duration: 1.2 }}
/>
<motion.div
className="absolute bg-blue-500 rounded-b-full rounded-t-sm w-3 h-4 right-[30%]"
initial={{ opacity: 0, top: "45%" }}
animate={{ opacity: [0, 1, 0], top: ["45%", "65%", "75%"] }}
transition={{ repeat: Infinity, duration: 1.2, delay: 0.6 }}
/>
</>
)} </motion.div>
</div>
</motion.div>
</div>
);
}