# 共享元素转场 Shared Element Transition > 点列表里的小图,它直接飞到详情页大图的位置,返回时再飞回去。 分类:动效 · 角色:基础(手感,哪里都能用、不抢眼) [全部组件和流程](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. 告诉用户用了什么、放在哪个文件、以后想改快慢、颜色、大小改哪个参数。 ## 什么时候用 - 适合:列表进详情:商品、作品、目的地、文章封面,让人不迷路。 - 不适合:列表和详情用的不是同一张图;后台表格。 - 适合的网站:电商、作品集、内容站、官网 - 放在:转场、列表、卡片 - 手机上:好用:点击触发,手机上体验最好。 - 性能代价:低:只动 transform 和 clip-path。 - 常搭配:[页面转场 Page Transition](https://www.dachuiai.com/c/page-transition.md)、[布局动画 Layout Animation](https://www.dachuiai.com/c/layout-animation.md)、[悬停上浮 Hover Lift](https://www.dachuiai.com/c/hover-lift.md) ## 装进项目 - 写法:React 18 以上 + TypeScript + Tailwind CSS v4,一个文件就能用,不引用别的组件。 - 依赖:`npm i lucide-react`(lucide-react 只有最后的演示示例用到;不留示例的话,删掉对应的 import 就不用装) - 文件:原样存成 `shared-element.tsx`(比如 `src/components/shared-element.tsx`)。能跑命令就下载[原文](https://www.dachuiai.com/code/shared-element.tsx),比手抄准:`curl -fsSL -o src/components/shared-element.tsx https://www.dachuiai.com/code/shared-element.tsx`(Windows 写 `curl.exe`)。下载完看一眼第一行是 `import`,不是网页。 - 导出:`flyFrom`,是做这个效果用的工具(hook、函数或常量);页面结构在最后的 `export default` 示例里,照着示例搭。 - 图片:示例用了本站的 [/art/dunes.jpg](https://www.dachuiai.com/art/dunes.jpg)、[/art/aurora.jpg](https://www.dachuiai.com/art/aurora.jpg)、[/art/peaks.jpg](https://www.dachuiai.com/art/peaks.jpg)、[/art/ocean.jpg](https://www.dachuiai.com/art/ocean.jpg)、[/art/sunset.jpg](https://www.dachuiai.com/art/sunset.jpg)、[/art/ice.jpg](https://www.dachuiai.com/art/ice.jpg)。换成用户自己的图;只想先看效果,可以从本站同样的路径下载,放进项目的 public 目录。 - 注意:示例是在同一个页面里切换列表和详情。项目是多页面路由时,用浏览器自带的 View Transitions(document.startViewTransition)做同样的效果。 - 保留代码里 `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、有赞这类建站平台装不进代码,告诉用户去平台自带的动效设置里找。 ## 参数 | 参数 | 默认值 | 作用 | | --- | --- | --- | | `from` | | 小图的位置(getBoundingClientRect()) | | `to` | | 大图的位置 | | `fromRadius` | | 小图的圆角(px) | | `toRadius` | | 大图的圆角(px) | ## 你可以说 > 列表到详情页用 Shared Element Transition 共享元素转场:点一张小图,它直接飞到详情页大图的位置,返回时再飞回去。 ## 完整代码 ```tsx import {useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react'; import {ArrowLeft, Clock, Mountain, Thermometer} from 'lucide-react'; /* --------------------------------------------------------------- * SharedElement 共享元素转场 Shared Element Transition * 点一张小图,它直接飞到下一页的位置,眼睛不会迷路;返回时再飞回去。 * 做法(FLIP):下一页的大图直接渲染在终点,先用 transform 把它 * 缩回小图的位置、用 clip-path 裁成小图的样子,再放开,让它飞到终点。 * 全程只动 transform 和 clip-path,图不会被拉伸。 * ------------------------------------------------------------- */ const EASE = 'cubic-bezier(.32,.72,0,1)'; const DURATION = 620; /** 算出"大图 → 小图"的变换:先等比缩放到能盖住小图,再裁掉多出来的部分 */ export function flyFrom(from: DOMRect, to: DOMRect, fromRadius: number, toRadius: number) { const s = Math.max(from.width / to.width, from.height / to.height); const dx = from.left + from.width / 2 - (to.left + to.width / 2); const dy = from.top + from.height / 2 - (to.top + to.height / 2); const cutX = (to.width - from.width / s) / 2, cutY = (to.height - from.height / s) / 2; return [ {transform: `translate(${dx}px, ${dy}px) scale(${s})`, clipPath: `inset(${cutY}px ${cutX}px round ${fromRadius / s}px)`}, {transform: 'none', clipPath: `inset(0px 0px round ${toRadius}px)`}, ]; } /* ---------------- 示例:目的地列表 → 详情页 ---------------- */ const PLACES = [ {id: 'dunes', name: '撒哈拉沙丘', en: 'Sahara', img: '/art/dunes.jpg', text: '清晨五点出发,骑骆驼走进沙海,太阳升起时风把沙脊吹成一条线。', days: '5 天', temp: '12–38℃', alt: '340 米'}, {id: 'aurora', name: '北极光之夜', en: 'Aurora', img: '/art/aurora.jpg', text: '零下二十度的晴夜,绿色光幕在头顶翻动,安静得能听见雪。', days: '6 天', temp: '-22℃', alt: '90 米'}, {id: 'peaks', name: '晨雾群山', en: 'Ridges', img: '/art/peaks.jpg', text: '住在山脊上的小木屋,推开窗就是一层一层淡下去的远山。', days: '4 天', temp: '6–18℃', alt: '2600 米'}, {id: 'ocean', name: '蓝洞潜水', en: 'Blue Hole', img: '/art/ocean.jpg', text: '光线从海面打下来,一束一束,你在里面慢慢往下沉。', days: '3 天', temp: '27℃', alt: '-40 米'}, {id: 'sunset', name: '落日海岸', en: 'Coast', img: '/art/sunset.jpg', text: '这片海岸朝西,每天傍晚太阳都沿着同一条金线沉进海里。', days: '3 天', temp: '24℃', alt: '0 米'}, {id: 'ice', name: '蓝冰峡谷', en: 'Glacier', img: '/art/ice.jpg', text: '走进冰川内部,一万年前的雪压成了这种发光的蓝。', days: '7 天', temp: '-8℃', alt: '1200 米'}, ]; export default function SharedElementDemo() { const [open, setOpen] = useState(null); const [leaving, setLeaving] = useState(false); const busy = useRef(false); const cards = useRef>({}); const hero = useRef(null); const place = PLACES.find((p) => p.id === open); const reduce = () => matchMedia('(prefers-reduced-motion: reduce)').matches; // 打开:大图已经在终点了,这里让它从小图的位置飞过去 useLayoutEffect(() => { if (!open || !hero.current) return; const from = cards.current[open]!.getBoundingClientRect(), to = hero.current.getBoundingClientRect(); busy.current = true; const anim = hero.current.animate(flyFrom(from, to, 18, 22), {duration: reduce() ? 1 : DURATION, easing: EASE}); anim.onfinish = () => { busy.current = false; }; }, [open]); const back = useCallback(() => { if (!open || busy.current || !hero.current) return; busy.current = true; setLeaving(true); const to = hero.current.getBoundingClientRect(), from = cards.current[open]!.getBoundingClientRect(); const anim = hero.current.animate(flyFrom(from, to, 18, 22).reverse(), {duration: reduce() ? 1 : DURATION * 0.85, easing: EASE, fill: 'forwards'}); anim.onfinish = () => { const card = cards.current[open]; setOpen(null); setLeaving(false); busy.current = false; card?.closest('button')?.focus({preventScroll: true}); }; }, [open]); useEffect(() => { if (!open) return; const esc = (e: KeyboardEvent) => e.key === 'Escape' && back(); addEventListener('keydown', esc); return () => removeEventListener('keydown', esc); }, [open, back]); return (
{/* 列表淡出、详情文字依次出现:示例页面自己的样式 */} {/* 列表页 */}

下一站去哪

    {PLACES.map((p) => (
  • ))}
{/* 详情页:大图的比例和原图一样(16:10),飞行时只缩放不变形 */} {place && (
{place.name}
{place.en.toUpperCase()}

{place.name}

{place.text}

{[[Clock, place.days], [Thermometer, place.temp], [Mountain, place.alt]].map(([I, v], k) => { const Icon = I as typeof Clock; return
{v as string}
; })}
)}
); } ```