# 弹性 Spring > 动到终点会冲过头一点再弹回来,像果冻;用在点赞和弹窗上。 分类:动效 · 角色:基础(手感,哪里都能用、不抢眼) [全部组件和流程](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、内容站、活动页、作品集、后台、本地小店 - 放在:按钮、弹窗 - 手机上:好用:手机上点按的反馈更重要。 - 性能代价:低:一条 CSS 曲线。 - 常搭配:[缓动 Easing](https://www.dachuiai.com/c/easing.md)、[错峰 Stagger](https://www.dachuiai.com/c/stagger.md) ## 装进项目 - 写法:React 18 以上 + TypeScript + Tailwind CSS v4,一个文件就能用,不引用别的组件。 - 依赖:`npm i lucide-react` - 文件:原样存成 `spring.tsx`(比如 `src/components/spring.tsx`)。能跑命令就下载[原文](https://www.dachuiai.com/code/spring.tsx),比手抄准:`curl -fsSL -o src/components/spring.tsx https://www.dachuiai.com/code/spring.tsx`(Windows 写 `curl.exe`)。下载完看一眼第一行是 `import`,不是网页。 - 导出:`SPRING`、`LikeButton` 是可以直接用的部分;最后的 `export default` 是演示示例,只作参考。 - 图片:示例用了本站的 [/art/sunset.jpg](https://www.dachuiai.com/art/sunset.jpg)。换成用户自己的图;只想先看效果,可以从本站同样的路径下载,放进项目的 public 目录。 - 注意:SPRING 是 CSS linear() 曲线,直接写进 transition-timing-function。弹窗从 95% 放大到 100% 就够了,别从 0 开始。 - 保留代码里 `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、有赞这类建站平台装不进代码,告诉用户去平台自带的动效设置里找。 ## 参数 | 参数 | 默认值 | 作用 | | --- | --- | --- | | `initial` | `128` | 一开始的点赞数 | ## 你可以说 > 点赞按钮加 Spring 弹性效果:点下去心形冲过头一点再弹回来;确认弹窗从 95% 弹到 100% 出现。 ## 完整代码 ```tsx import {useEffect, useRef, useState} from 'react'; import {Heart, Send, X} from 'lucide-react'; /* --------------------------------------------------------------- * Spring 弹性 * 动到终点会冲过头一点再弹回来,像果冻。 * 用 CSS 的 linear() 写一条弹簧曲线,任何 transition / animation 都能用。 * 弹窗出现时从 95% 放大到 100% 就够了,别从 0 开始放大。 * ------------------------------------------------------------- */ export const SPRING = 'linear(0, .009, .035 2.1%, .141, .281 6.7%, .723 12.9%, .938 16.7%, 1.017, 1.077, 1.121, 1.149 24.3%, 1.159, 1.163, 1.161, 1.154 29.9%, 1.129 32.8%, 1.051 39.6%, 1.017 43.1%, .991, .977 51%, .974 53.8%, .975 57.1%, .997 69.8%, 1.003 76.9%, 1.004 83.8%, 1)'; const LIKE_CSS = ` .spr-pop { animation: spr-pop .6s ${SPRING}; } @keyframes spr-pop { from { transform: scale(.6); } } .spr-dot { animation: spr-dot .6s cubic-bezier(.22,1,.36,1) forwards; } @keyframes spr-dot { from { transform: rotate(var(--a)) translateY(0) scale(1); opacity: 1; } to { transform: rotate(var(--a)) translateY(-30px) scale(.3); opacity: 0; } } @media (prefers-reduced-motion: reduce) { .spr-pop, .spr-dot { animation: none; } .spr-dot { display: none; } } `; /* 点赞按钮:心形用弹性放大,四周迸出小点 */ export function LikeButton({initial = 128}: {initial?: number}) { const [liked, setLiked] = useState(false); const [burst, setBurst] = useState(0); return ( { setLiked(!liked); if (!liked) setBurst((b) => b + 1); }} aria-pressed={liked} aria-label={liked ? '取消点赞' : '点赞'} className={`flex h-11 items-center gap-2.5 rounded-full px-4 text-base font-black tabular-nums ring-1 transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-rose-500 ${ liked ? 'bg-rose-50 text-rose-600 ring-rose-200' : 'bg-white text-zinc-500 ring-zinc-200 hover:text-zinc-800' }`} > {burst > 0 && liked && ( {Array.from({length: 8}, (_, i) => ( ))} )} {initial + (liked ? 1 : 0)} ); } /* ---------------- 示例:点赞 + 发布确认弹窗 ---------------- */ export default function SpringDemo() { const [open, setOpen] = useState(false); const [sent, setSent] = useState(false); const confirm = useRef(null); useEffect(() => { if (!open) return; confirm.current?.focus(); const esc = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false); addEventListener('keydown', esc); return () => removeEventListener('keydown', esc); }, [open]); return ( {/* 弹窗:遮罩淡入,面板用弹性从 95% 放大 */} 海边的傍晚 太阳沿着一条金线沉进海里,风刚好停了。 { setSent(false); setOpen(true); }} className="flex h-12 items-center gap-2 rounded-full bg-zinc-900 px-7 font-bold text-white transition hover:bg-zinc-700 active:scale-[.97] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" > {sent ? '已发布' : '发布文章'} {open && ( setOpen(false)}> e.stopPropagation()} className="spr-dialog w-80 rounded-3xl bg-white p-6 shadow-[0_30px_60px_-12px_rgba(24,24,27,.35)]"> 确认发布? setOpen(false)} aria-label="关闭" className="-m-1 grid size-8 place-items-center rounded-lg text-zinc-400 hover:bg-zinc-100 hover:text-zinc-800"> 发布后所有人都能看到这篇文章。 setOpen(false)} className="h-11 rounded-xl bg-zinc-100 text-sm font-bold text-zinc-700 hover:bg-zinc-200 focus-visible:outline-2 focus-visible:outline-blue-600">取消 { setSent(true); setOpen(false); }} className="h-11 rounded-xl bg-blue-600 text-sm font-bold text-white hover:bg-blue-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600">发布 )} ); } ```
太阳沿着一条金线沉进海里,风刚好停了。
发布后所有人都能看到这篇文章。