# 布局动画 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}
))}
);
}
```