You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

使用Next.js+Framer Motion+Tailwind CSS开发书架UI时,点击书籍出现异常拉伸动画的原因及解决方法

Next.js+Framer Motion+Tailwind CSS开发书架UI时,点击书籍出现异常拉伸动画的原因及解决方法

Hey,我仔细看了你的问题描述和代码,这个“拉伸”问题其实是几个因素叠加导致的,咱们一步步拆解:

为什么会出现拉伸?

  1. Framer Motion layout 与动态图片尺寸的冲突
    你现在在组件里用useState动态计算图片的宽高,虽然是在图片加载后设置,但Framer Motion的layout属性会持续追踪元素的尺寸变化。当你点击书籍触发isSelected时,列表项(li)内部会新增详情区域,同时如果图片尺寸还在计算/更新,layout会把图片尺寸变化和列表项宽度扩展这两个变化混在一起计算动画,导致元素的宽高比被临时扭曲,看起来就像“拉伸”了。

  2. 双重动画系统的冲突
    你给图片加了Tailwind的transition-all duration-800,同时又用了Framer Motion的layout动画。这两个系统(CSS过渡 vs Framer Motion的JS驱动动画)会同时修改元素的样式(比如transform、width),但它们的动画时长、缓动函数不一样,中间状态就会出现不协调的拉伸效果。

  3. 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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.04.14 12:19:38