# 布局动画 Layout Animation > 筛选、排序、增删时,元素滑到新位置,不是跳过去;新来的淡入,要走的淡出。 分类:动效 · 角色:基础(手感,哪里都能用、不抢眼) [全部组件和流程](https://www.dachuiai.com/llms.txt) · [动效选型规则](https://www.dachuiai.com/guides/motion.md) ## 给 AI:用户把这个组件发给了你 1. 先看用户的项目:框架、页面类型、现在的风格、手机还是电脑为主。 2. 对照下面的「适合 / 不适合」判断合不合适。不合适就直接说,并从[动效选型规则](https://www.dachuiai.com/guides/motion.md)或[全部组件](https://www.dachuiai.com/llms.txt)里另挑一个。 3. 合适就按「装进项目」装好,示例里的文字和图片换成用户自己的。 4. 告诉用户用了什么、放在哪个文件、以后想改快慢、颜色、大小改哪个参数。 ## 什么时候用 - 适合:能筛选、排序、切换视图的列表或网格:商品筛选、作品分类、待办清单。 - 不适合:一次上百项的长列表(每次要量所有元素的位置);没有布局变化的页面。 - 适合的网站:电商、作品集、SaaS、后台、内容站 - 放在:列表 - 手机上:好用:点按筛选、排序和电脑上一样。 - 性能代价:低:只在变化那一下量位置;超过 100 项会卡。 - 常搭配:[错峰 Stagger](https://www.dachuiai.com/c/stagger.md)、[共享元素转场 Shared Element Transition](https://www.dachuiai.com/c/shared-element.md) ## 装进项目 - 写法:React 18 以上 + TypeScript + Tailwind CSS v4,一个文件就能用,不引用别的组件。 - 依赖:`npm i lucide-react`(lucide-react 只有最后的演示示例用到;不留示例的话,删掉对应的 import 就不用装) - 文件:原样存成 `layout-animation.tsx`(比如 `src/components/layout-animation.tsx`)。能跑命令就下载[原文](https://www.dachuiai.com/code/layout-animation.tsx),比手抄准:`curl -fsSL -o src/components/layout-animation.tsx https://www.dachuiai.com/code/layout-animation.tsx`(Windows 写 `curl.exe`)。下载完看一眼第一行是 `import`,不是网页。 - 导出:`useFlip`、`usePresence`,是做这个效果用的工具(hook、函数或常量);页面结构在最后的 `export default` 示例里,照着示例搭。 - 图片:示例用了本站的图片按名字拼出的 `/art/<名字>.jpg`。换成用户自己的图;只想先看效果,可以从本站同样的路径下载,放进项目的 public 目录。 - 注意:useFlip(依赖, 选项) 给列表外层一个 ref,每个元素加 data-flip="唯一 id"(数字 id 用 String() 转)。默认 0.52 秒、带一点过冲,适合展示型网站;后台写 useFlip(依赖, {duration: 200, easing: 'cubic-bezier(.22,1,.36,1)'})。用 usePresence 做退场时,给要走的元素加 data-leaving={a.leaving || undefined},淡出由 useFlip 自己播,不用另写样式。翻页时给列表加 key={页码},整页直接换,不播动画。用在 上时 ref 写成 ref={root as never}。 - 保留代码里 `prefers-reduced-motion` 的处理:用户开了「减弱动态效果」时内容照样完整显示。 - Next.js(有 app 文件夹):文件第一行加 `'use client';`。 - 用 JavaScript 不用 TypeScript:Vite 和 Next.js 都能直接用 .tsx 文件;也可以删掉类型标注,改名 .jsx。 - 没有 Tailwind:先装 Tailwind CSS v4(Vite 项目 `npm i tailwindcss @tailwindcss/vite`,vite 配置的 plugins 里加 `tailwindcss()`,入口引入的那个 CSS 文件(一般是 `src/index.css`)第一行写 `@import "tailwindcss";`),或者把类名改写成项目用的样式。 - 已经装了 Tailwind v3:别为了一个组件升级。类名大多通用,少数 v4 写法要换,比如焦点框 `outline-2` 在 v3 里要写成 `outline outline-2`。 - 不是 React(Vue、Svelte、小程序、纯 HTML):照着代码的做法改写,时长、曲线、参数保持一样。Wix、有赞这类建站平台装不进代码,告诉用户去平台自带的动效设置里找。 ## 参数 **useFlip** | 参数 | 默认值 | 作用 | | --- | --- | --- | | `dep` | | 这个值一变就播一次布局动画(一般传列表 id 拼成的字符串) | | `options` | `{}` | 传数字就是时长(毫秒),传对象可以写 duration 时长(默认 520)、easing 曲线(默认带一点过冲)、stagger 每个错开几毫秒(默认 12)、maxStagger 最多错开几毫秒(默认 150) | **usePresence** | 参数 | 默认值 | 作用 | | --- | --- | --- | | `items` | | 要显示的一组内容,格式见代码里的类型 | | `ms` | `280` | 退场动画的时长(毫秒) | ## 你可以说 > 筛选和排序加 Layout Animation 布局动画:元素换位置时滑过去,不是跳过去;新出现的淡入,消失的淡出。 ## 完整代码 ```tsx import {useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode} from 'react'; import {ArrowDownAZ, LayoutGrid, Rows3, Shuffle} from 'lucide-react'; /* --------------------------------------------------------------- * LayoutAnimation 布局动画 * 元素换位置时,滑过去,不是跳过去。筛选和排序都用得上。 * 做法(FLIP):记下每个元素变化前的位置 → 让页面变化 → 量出新位置 → * 用 transform 把元素放回旧位置,再放开让它滑到新位置。 * useFlip 只需要给列表外层一个 ref,给每个元素一个 data-flip="唯一 id"。 * 新出现的放大淡入;要消失的元素由 usePresence 留到动画结束再移除, * 给它加上 data-leaving,useFlip 会把它钉在原地淡出,不用另写样式。 * 动画途中又变了(连点筛选),从眼睛看到的位置接着滑,不会跳。 * options:传数字就是时长(毫秒),传对象可以写 duration 时长(默认 520)、easing 曲线(默认带一点过冲)、stagger 每个错开几毫秒(默认 12)、maxStagger 最多错开几毫秒(默认 150) * 删掉的元素用 0.26 秒淡出(duration 更短时跟着变短)。 * 后台、工具类要快、不过冲:useFlip(依赖, {duration: 200, easing: 'cubic-bezier(.22,1,.36,1)'})。 * ------------------------------------------------------------- */ const EASE = 'cubic-bezier(.3,1.25,.4,1)'; type FlipOptions = {duration?: number; easing?: string; stagger?: number; maxStagger?: number}; const OUT = new WeakMap(); // 正在淡出的元素和它的动画 const MOVE = new WeakMap(); // 正在滑动的元素和它的动画 /** 元素在布局里的位置,不算 transform(量的时候先临时关掉,正在播的动画也不算) */ function layoutRects(nodes: HTMLElement[]) { const saved = nodes.map((n) => [n.style.getPropertyValue('transform'), n.style.getPropertyPriority('transform')]); nodes.forEach((n) => n.style.setProperty('transform', 'none', 'important')); const rects = nodes.map((n) => n.getBoundingClientRect()); nodes.forEach((n, i) => n.style.setProperty('transform', saved[i][0], saved[i][1])); return rects; } export function useFlip(dep: unknown, options: number | FlipOptions = {}) { const {duration = 520, easing = EASE, stagger = 12, maxStagger = 150} = typeof options === 'number' ? {duration: options} : options; const root = useRef(null); const prev = useRef(new Map()); useLayoutEffect(() => { const el = root.current!; const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches; const nodes = [...el.querySelectorAll('[data-flip]')]; if (getComputedStyle(el).position === 'static') el.style.position = 'relative'; // 要消失的元素按列表外层定位 const box = el.getBoundingClientRect(); const next = new Map(); // 先处理要消失的:钉在原来的位置淡出、不占位置,其他元素同时补上来 for (const n of nodes) { const id = n.dataset.flip!, was = prev.current.get(id); if (!n.dataset.leaving) { if (OUT.has(n)) { // 淡出途中又被点回来 OUT.get(n)?.cancel(); OUT.delete(n); Object.assign(n.style, {position: '', left: '', top: '', width: '', height: '', display: ''}); } continue; } if (was) next.set(id, was); if (OUT.has(n)) continue; if (reduce) { n.style.display = 'none'; OUT.set(n, null); continue; } n.style.position = 'absolute'; if (was) { const left = was.left - box.left - el.clientLeft + el.scrollLeft, top = was.top - box.top - el.clientTop + el.scrollTop; Object.assign(n.style, {left: `${left}px`, top: `${top}px`, width: `${was.width}px`, height: `${was.height}px`}); } OUT.set(n, n.animate([{opacity: 0, transform: 'scale(.8)'}], {duration: Math.min(260, duration), easing: 'ease-in', fill: 'both'})); } // 其他元素:量新位置,和上次比;变了就从眼睛现在看到的位置滑过去 const stay = nodes.filter((n) => !n.dataset.leaving); const seen = stay.map((n) => n.getBoundingClientRect()); // 含正在播的动画 const now = layoutRects(stay); stay.forEach((n, i) => { const id = n.dataset.flip!, was = prev.current.get(id), to = now[i]; next.set(id, to); if (reduce) return; const opts: KeyframeAnimationOptions = {duration, easing, delay: Math.min(i * stagger, maxStagger), fill: 'backwards'}; if (was) { if (was.left === to.left && was.top === to.top && was.width === to.width && was.height === to.height) return; // 位置没变:正在播的接着播 const moving = MOVE.get(n)?.playState === 'running', off = moving ? seen[i] : to; // 正在滑:从现在看到的地方接着滑 const dx = was.left - to.left + off.left - to.left, dy = was.top - to.top + off.top - to.top; const sx = (was.width / to.width) * (off.width / to.width), sy = (was.height / to.height) * (off.height / to.height); MOVE.get(n)?.cancel(); MOVE.set(n, n.animate([{transformOrigin: '0 0', transform: `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`}, {transformOrigin: '0 0', transform: 'none'}], opts)); } else if (prev.current.size) { n.animate([{opacity: 0, transform: 'scale(.8)'}, {opacity: 1, transform: 'none'}], opts); } }); prev.current = next; // 每次变化都要量,所以依赖 dep }, [dep, duration, easing, stagger, maxStagger]); return root; } /** 列表减少时,先留着要走的元素(leaving: true,放在原来的位置),播完退场再真正移除。 * 在同一次渲染里就带上要走的元素,useFlip 才量得到它们原来在哪 */ export function usePresence(items: T[], ms = 280) { const key = items.map((i) => i.id).join('\n'); const [last, setLast] = useState({key, items}); const [gone, setGone] = useState<{item: T; index: number; at: number}[]>([]); if (key !== last.key) { const ids = new Set(items.map((i) => i.id)), leaving = new Set(gone.map((g) => g.item.id)); const out = last.items.flatMap((item, index) => (ids.has(item.id) || leaving.has(item.id) ? [] : [{item, index, at: Date.now()}])); setLast({key, items}); setGone([...gone.filter((g) => !ids.has(g.item.id)), ...out]); } useEffect(() => { if (!gone.length) return; const t = setTimeout(() => setGone((g) => g.filter((x) => Date.now() - x.at < ms)), Math.max(0, Math.min(...gone.map((g) => g.at)) + ms - Date.now())); return () => clearTimeout(t); }, [gone, ms]); const list: (T & {leaving: boolean})[] = items.map((i) => ({...i, leaving: false})); for (const g of [...gone].sort((a, b) => a.index - b.index)) list.splice(Math.min(g.index, list.length), 0, {...g.item, leaving: true}); return list; } /* ---------------- 示例:能筛选、排序、切换视图的作品墙 ---------------- */ type Art = {id: string; name: string; tag: string; year: number}; const ART: Art[] = [ ['planet', '远行星', '宇宙', 2026], ['aurora', '极光', '宇宙', 2024], ['bokeh', '夜色', '宇宙', 2023], ['dunes', '沙丘', '风景', 2025], ['peaks', '晨山', '风景', 2026], ['forest', '雾林', '风景', 2022], ['sunset', '落日', '风景', 2024], ['ocean', '光束', '风景', 2025], ['fluid-rose', '颜料一', '抽象', 2023], ['fluid-teal', '颜料二', '抽象', 2026], ['silk-violet', '紫绸', '抽象', 2022], ['lava', '地火', '抽象', 2025], ].map(([id, name, tag, year]) => ({id, name, tag, year}) as Art); const TAGS = ['全部', '风景', '抽象', '宇宙']; const Tool = ({on, onClick, label, children}: {on?: boolean; onClick: () => void; label: string; children: ReactNode}) => ( ); export default function LayoutAnimationDemo() { const [tag, setTag] = useState('全部'); const [sorted, setSorted] = useState(false); const [rows, setRows] = useState(false); const [seed, setSeed] = useState(0); const list = useMemo(() => { const a = ART.filter((x) => tag === '全部' || x.tag === tag); if (sorted) return [...a].sort((p, q) => q.year - p.year); if (seed) return [...a].sort((p, q) => ((p.id.charCodeAt(1) * seed) % 17) - ((q.id.charCodeAt(1) * seed) % 17)); return a; }, [tag, sorted, seed]); const items = usePresence(list); const root = useFlip(items.map((i) => i.id + i.leaving).join() + rows); return (
{TAGS.map((t) => ( ))} { setSorted(!sorted); setSeed(0); }} label="按年份排序"> { setSorted(false); setSeed((s) => s + 3); }} label="打乱"> setRows(!rows)} label={rows ? '网格视图' : '列表视图'}>{rows ? : }
{items.map((a) => (
{a.name} {a.year}
))}
); } ```