使用Next.js+Framer Motion+Tailwind CSS开发书架UI时,点击书籍出现异常拉伸动画的原因及解决方法
Hey,我仔细看了你的问题描述和代码,这个“拉伸”问题其实是几个因素叠加导致的,咱们一步步拆解:
为什么会出现拉伸?
Framer Motion
layout与动态图片尺寸的冲突
你现在在组件里用useState动态计算图片的宽高,虽然是在图片加载后设置,但Framer Motion的layout属性会持续追踪元素的尺寸变化。当你点击书籍触发isSelected时,列表项(li)内部会新增详情区域,同时如果图片尺寸还在计算/更新,layout会把图片尺寸变化和列表项宽度扩展这两个变化混在一起计算动画,导致元素的宽高比被临时扭曲,看起来就像“拉伸”了。双重动画系统的冲突
你给图片加了Tailwind的transition-all duration-800,同时又用了Framer Motion的layout动画。这两个系统(CSS过渡 vs Framer Motion的JS驱动动画)会同时修改元素的样式(比如transform、width),但它们的动画时长、缓动函数不一样,中间状态就会出现不协调的拉伸效果。AnimatePresence的使用场景不对
你在<ul>外层套了AnimatePresence,但这个组件是用来处理组件挂载/卸载动画的,而你的Book组件是一直渲染的,只是内部详情显示/隐藏。把AnimatePresence放在这里不仅没用,还可能干扰layout的动画计算。
怎么解决?
咱们一步步修改代码,同时保留你需要的hover和选中动画:
1. 固定图片尺寸,避免动态计算
动态图片尺寸是触发layout额外计算的元凶之一,咱们用固定宽高比的容器来约束图片,不用再存imageSize的state:
// Book.js 里替换图片相关代码 // 用固定尺寸的容器包裹Next.js Image,fill属性让图片自适应容器 <motion.button onClick={() => onSelect(data)} whileHover={{ y: -8 }} // 用Framer Motion处理hover上移,代替Tailwind的translate-y transition={{ duration: 0.3 }} onMouseEnter={() => onHover(data)} onMouseLeave={() => onHover(null)} > <div className="w-32 aspect-[2/3] relative"> {/* 固定宽高比,比如书籍封面常用2:3 */} <Image alt={`Book spine of ${title}`} fill objectFit="contain" src={`/images/${route}`} /> </div> </motion.button>
这样图片的尺寸完全由容器控制,不会再动态变化,layout只会追踪列表项的宽度变化(因为选中时要显示详情)。
2. 统一用Framer Motion处理所有动画
把Tailwind的transition-all从图片上删掉,所有动画(hover、选中)都交给Framer Motion,避免双重冲突:
- 用
whileHover直接处理图片的opacity和上移动画,不用自己维护isHovered的state - 给内部的详情div套
AnimatePresence,处理显示/隐藏的动画
修改后的Book.js完整代码示例:
// Book.js import { motion, AnimatePresence } from "framer-motion"; import Image from "next/image"; export default function Book({ data, isSelected, onSelect, onHover }) { const { title, author, route, year } = data; return ( <motion.li initial={{ x: -50, opacity: 0 }} animate={{ x: 0, opacity: 1 }} exit={{ x: 50, opacity: 0 }} transition={{ duration: 0.4 }} layout="size" // 只追踪尺寸变化,减少不必要的计算 className="relative flex gap-2 items-end" > <motion.button onClick={() => onSelect(data)} whileHover={{ y: -8 }} whileTap={{ scale: 0.98 }} // 可选:加个点击反馈 transition={{ duration: 0.3 }} onMouseEnter={() => onHover(data)} onMouseLeave={() => onHover(null)} > <div className="w-32 aspect-[2/3] relative overflow-hidden"> <motion.div initial={{ opacity: 0.4 }} whileHover={{ opacity: 1 }} transition={{ duration: 0.3 }} className="w-full h-full" > <Image alt={`Book spine of ${title}`} fill objectFit="contain" src={`/images/${route}`} /> </motion.div> </div> </motion.button> {/* 只给详情区域套AnimatePresence,处理显示/隐藏 */} <AnimatePresence> {isSelected && ( <motion.div initial={{ width: 0, opacity: 0 }} animate={{ width: "auto", opacity: 1 }} exit={{ width: 0, opacity: 0 }} transition={{ duration: 0.4, ease: "easeOut" }} className="pr-2 whitespace-nowrap overflow-hidden" > <h3 className="text-2xl font-bold">{title}</h3> <div className="flex gap-2 mt-1"> <span>by {author}</span> <span>{year}</span> </div> </motion.div> )} </AnimatePresence> </motion.li> ); }
3. 优化page.js里的AnimatePresence
把原来套在<ul>上的AnimatePresence删掉,因为它不应该包裹一直存在的列表项,只需要在每个Book内部的详情区域用就好:
// page.js 修改后 import { motion } from "framer-motion"; import { useState } from "react"; import Book from "./Book"; export default function Home() { const [books, setBooks] = useState([]); const [selectedBook, setSelectedBook] = useState(null); const [hoveredBook, setHoveredBook] = useState(null); const handleSelectBook = (book) => { setSelectedBook(selectedBook === book ? null : book); }; return ( <ul className="flex relative overflow-x-scroll"> {books.map((book) => ( <Book key={book.id} data={book} isSelected={selectedBook === book} onSelect={handleSelectBook} onHover={setHoveredBook} /> ))} </ul> ); }
4. 可选:调整layout动画的缓动函数
如果还是有轻微的不协调,可以给motion.li的layout指定过渡参数,让它和详情的动画同步:
<motion.li // 其他属性不变 layout="size" layoutTransition={{ duration: 0.4, ease: "easeOut" }} >
总结
核心就是统一动画系统(全用Framer Motion)、固定图片尺寸(避免layout追踪不必要的变化)、正确使用AnimatePresence,这样就能消除拉伸,同时保留你需要的hover和选中动画效果。
备注:内容来源于stack exchange,提问作者Tyler Morales

