# 物理掉落 Physics Drop > 一堆标签从上面掉下来,互相碰撞堆在底部,可以用鼠标或手指抓起来扔。 分类:动效 · 角色:主角(占一整块、抢全部注意力,一页最多一个) [全部组件和流程](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. 告诉用户用了什么、放在哪个文件、以后想改快慢、颜色、大小改哪个参数。 ## 什么时候用 - 适合:页面底部、页脚前的趣味收尾:技能、服务、兴趣标签。 - 不适合:首屏(掉落要好几秒);金融、医疗这类严肃行业;后台。 - 适合的网站:作品集、活动页、官网 - 放在:页脚、内容区 - 手机上:好用:手指能抓;标签减到 15 个以内。 - 性能代价:中:要装 matter-js(约 80KB),滚到眼前才开始计算。 - 常搭配:[速度跑马灯 Velocity Marquee](https://www.dachuiai.com/c/velocity-marquee.md)、[滚动出现 Scroll Reveal](https://www.dachuiai.com/c/scroll-reveal.md) ## 装进项目 - 写法:React 18 以上 + TypeScript + Tailwind CSS v4,一个文件就能用,不引用别的组件。 - 依赖:`npm i matter-js` - 文件:原样存成 `physics-drop.tsx`(比如 `src/components/physics-drop.tsx`)。能跑命令就下载[原文](https://www.dachuiai.com/code/physics-drop.tsx),比手抄准:`curl -fsSL -o src/components/physics-drop.tsx https://www.dachuiai.com/code/physics-drop.tsx`(Windows 写 `curl.exe`)。下载完看一眼第一行是 `import`,不是网页。 - 导出:`PhysicsDrop` 是可以直接用的部分;最后的 `export default` 是演示示例,只作参考。 - `tags` 要写成组件外面的常量,或者用 useMemo 包起来:每次渲染都传一个新写的数组,组件会以为内容变了,从头重来。 - 保留代码里 `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、有赞这类建站平台装不进代码,告诉用户去平台自带的动效设置里找。 ## 参数 | 参数 | 默认值 | 作用 | | --- | --- | --- | | `tags` | | 标签列表:每个 label、bg 底色,可选 fg 字色、round 圆形 | | `className` | `''` | 加在最外层的类名,用来调大小、位置、间距 | ## 你可以说 > 页面底部加 Physics Drop 物理掉落:一堆标签从上面掉下来,互相碰撞堆在底部,可以用鼠标抓起来扔,用 matter-js 做。 ## 完整代码 ```tsx import {useEffect, useRef} from 'react'; import Matter from 'matter-js'; /* --------------------------------------------------------------- * PhysicsDrop 物理掉落 * 标签像真东西一样掉下来,还能拖着撞来撞去。放在页面底部,很难不去玩。 * 物理计算用 matter-js(npm i matter-js),画面是普通的网页元素, * 每帧把物理世界里的位置和角度写到元素的 transform 上。 * 滚到眼前才开始掉;开了"减弱动态效果"时直接整齐地摆在底部。 * ------------------------------------------------------------- */ export type Tag = {label: string; bg: string; fg?: string; round?: boolean}; export function PhysicsDrop({tags, className = ''}: {tags: Tag[]; className?: string}) { const stage = useRef(null); const els = useRef<(HTMLElement | null)[]>([]); useEffect(() => { const box = stage.current!; const {Engine, Bodies, Body, Composite, Constraint, Query} = Matter; const W = box.clientWidth, H = box.clientHeight; if (matchMedia('(prefers-reduced-motion: reduce)').matches) { let x = 12, y = H - 12; els.current.forEach((el) => { if (!el) return; if (x + el.offsetWidth > W - 12) { x = 12; y -= el.offsetHeight + 8; } el.style.transform = `translate(${x}px, ${y - el.offsetHeight}px)`; x += el.offsetWidth + 8; }); return; } const engine = Engine.create({gravity: {x: 0, y: 1.1}}); const wall = {isStatic: true, friction: 0.6}; Composite.add(engine.world, [ Bodies.rectangle(W / 2, H + 40, W + 400, 80, wall), Bodies.rectangle(-40, H / 2 - 400, 80, H + 1400, wall), Bodies.rectangle(W + 40, H / 2 - 400, 80, H + 1400, wall), ]); const items = els.current.flatMap((el, i) => { if (!el) return []; const w = el.offsetWidth, h = el.offsetHeight; const x = 60 + Math.random() * (W - 120), y = -80 - i * 62; const opts = {restitution: 0.42, friction: 0.28, frictionAir: 0.008, density: 0.0016, angle: (Math.random() - 0.5) * 0.9}; const body = tags[i].round ? Bodies.circle(x, y, w / 2, opts) : Bodies.rectangle(x, y, w, h, {...opts, chamfer: {radius: h / 2 - 1}}); el.style.transform = `translate(${-999}px, ${-999}px)`; return [{el, body, w, h}]; }); let raf = 0, running = false, started = false, last = 0; const tick = (now: number) => { raf = requestAnimationFrame(tick); Engine.update(engine, Math.min(1000 / 60, now - last || 1000 / 60)); last = now; for (const {el, body, w, h} of items) el.style.transform = `translate(${body.position.x - w / 2}px, ${body.position.y - h / 2}px) rotate(${body.angle}rad)`; }; const start = () => { if (!started) { started = true; Composite.add(engine.world, items.map((i) => i.body)); } if (!running) { running = true; last = performance.now(); raf = requestAnimationFrame(tick); } }; const stop = () => { running = false; cancelAnimationFrame(raf); }; const io = new IntersectionObserver(([e]) => (e.isIntersecting ? start() : stop()), {threshold: 0.25}); io.observe(box); // 用鼠标或手指抓:在抓到的位置挂一根弹簧,拉着物体走 let grab: Matter.Constraint | null = null; const point = (e: PointerEvent) => { const r = box.getBoundingClientRect(); return {x: e.clientX - r.left, y: e.clientY - r.top}; }; const down = (e: PointerEvent) => { const p = point(e); const hit = Query.point(items.map((i) => i.body), p)[0]; if (!hit) return; grab = Constraint.create({pointA: p, bodyB: hit, pointB: {x: p.x - hit.position.x, y: p.y - hit.position.y}, stiffness: 0.12, damping: 0.08, length: 0}); Composite.add(engine.world, grab); box.setPointerCapture(e.pointerId); box.dataset.grab = '1'; }; const move = (e: PointerEvent) => { const p = point(e); if (grab) grab.pointA = p; else box.dataset.over = Query.point(items.map((i) => i.body), p).length ? '1' : ''; }; const up = () => { if (grab) { Composite.remove(engine.world, grab); grab = null; } delete box.dataset.grab; }; // 双击,把附近的标签炸开 const blast = (e: MouseEvent) => { if (e.detail !== 2) return; const r = box.getBoundingClientRect(), x = e.clientX - r.left, y = e.clientY - r.top; for (const {body} of items) { const dx = body.position.x - x, dy = body.position.y - y, d = Math.hypot(dx, dy) + 1; if (d < 260) Body.applyForce(body, body.position, {x: (dx / d) * 0.09 * body.mass, y: ((dy / d) * 0.09 - 0.05) * body.mass}); } }; box.addEventListener('pointerdown', down); box.addEventListener('pointermove', move); box.addEventListener('pointerup', up); box.addEventListener('pointercancel', up); box.addEventListener('click', blast); return () => { stop(); io.disconnect(); box.removeEventListener('pointerdown', down); box.removeEventListener('pointermove', move); box.removeEventListener('pointerup', up); box.removeEventListener('pointercancel', up); box.removeEventListener('click', blast); Engine.clear(engine); }; }, [tags]); return (
); } /* ---------------- 示例:页面底部的标签堆 ---------------- */ const P = ['#FF6B4A', '#FFC53D', '#2EE6A8', '#6C7BFF', '#FF5FD2', '#7DF9FF', '#F4F1EA']; const TAGS: Tag[] = ['粒子聚合', '滚动绑定', '遮罩揭开', '液态扭曲', '滚动叙事', '卡片堆叠', '共享元素', '无限画廊', '物理掉落', '横向滚动', '页面转场', '光标跟随', '悬停预览', '视差滚动', '乱码解码', '磁吸', '布局动画', '错峰', '弹性', '缓动'] .map((label, i) => ({label, bg: P[i % P.length], round: label.length <= 2})); export default function PhysicsDropDemo() { return (

20 种动效

); } ```