SurfSense 前端设计工程指南:基于 Emil Kowalski 设计哲学的 UI 打磨、动画决策与细节实现
【免费下载链接】SurfSenseOpen-source NotebookLM alternative. Research the open web with live data(Reddit, YT, IG, TikTok, Indeed, Google Search, Maps etc) through one platform, API or MCP server. Join our Discord: https://discord.gg/ejRNvftDp9项目地址: https://gitcode.com/GitHub_Trending/su/SurfSense
导读
本文围绕.cursor/skills/emil-design-eng/SKILL.md这份"设计工程(Design Engineering)"技能指南展开,系统梳理 Emil Kowalski 关于 UI 打磨、组件设计与动画决策的完整方法论。这套哲学在 SurfSense 前端(surfsense_web)中被大量落地:项目依赖中引入了motion(Framer Motion)、radix-ui、sonner(见 package.json),首页 Hero、营销页 Reveal、主题切换、Toaster 等组件均体现本文所述原则。读完本文,你将掌握一套可复用的动画决策框架、缓动曲线选型、组件构建规范与性能优化技巧,并能对照 SurfSense 源码看到每个原则的真实落点。
设计工程的核心哲学
品味是被训练出来的,而非天生
好的品味不是个人偏好,而是一种经过训练的本能:能够越过表象,识别出什么能让界面"感觉对"。培养方式包括:
- 持续接触优秀作品,深入思考"为什么它感觉好";
- 逆向工程动画,拆解交互细节;
- 保持好奇,反复实践。
构建 UI 时,不要只满足于"能用",要研究顶级界面为何给人那样的感觉。
不可见细节的复利效应
大多数细节用户从未有意识地注意到——这正是重点。当一个功能完全按照用户预期运作时,他们不会多看一眼,这即是目标。如 Paul Graham 所言:
"所有这些不可见的细节组合在一起,产生了令人惊艳的结果,就像一千个几乎听不见的声音在齐声歌唱。"
每一个决策之所以存在,正是因为无数微小的正确性聚合起来,造就了人们说不出原因却喜爱的界面。
美感是杠杆
人们根据整体体验选择工具,而不只是功能。好的默认值与好的动画是真正的差异化因素。美感在软件中未被充分利用,应当把它当作脱颖而出的杠杆。
评审格式:必须使用 Before/After 表格
当评审 UI 代码时,必须使用带Before/After列的 Markdown 表格,禁止用 "Before:" 和 "After:" 分行列表。每个问题一行,Why列简述理由:
| Before | After | Why |
|---|---|---|
transition: all 300ms | transition: transform 200ms ease-out | 指定精确属性,避免all |
transform: scale(0) | transform: scale(0.95); opacity: 0 | 现实世界中没有任何东西是从无到有的 |
ease-inon dropdown | ease-outwith custom curve | ease-in感觉迟钝;ease-out提供即时反馈 |
No:activestate on button | transform: scale(0.97)on:active | 按钮必须对按压有响应感 |
transform-origin: centeron popover | transform-origin: var(--radix-popover-content-transform-origin) | Popover 应从触发器处缩放(Modal 除外——保持居中) |
这个表格格式在 SurfSense 中可以对照验证:几乎所有radix-ui弹层组件都遵循了"从 trigger 缩放 + 从 0.95 进入"的规范。例如 popover.tsx、dropdown-menu.tsx、select.tsx 都使用了data-[state=open]:zoom-in-95(等价于从scale(0.95)进入)与origin-(--radix-dropdown-menu-content-transform-origin)(等价于文档中的var(--radix-...-content-transform-origin));assistant-message.tsx 中的聊天操作菜单同样如此。
动画决策框架
在写任何动画代码之前,按顺序回答以下四个问题。
1. 这个元素到底该不该动?
核心问题:用户每天会看到这个动画多少次?
| 频率 | 决策 |
|---|---|
| 每天 100+ 次(快捷键、命令面板切换) | 永远不要动画 |
| 每天数十次(悬停效果、列表导航) | 移除或大幅削减 |
| 偶尔(弹窗、抽屉、toast) | 标准动画 |
| 罕见/首次(新手引导、反馈表单、庆祝动效) | 可以增加趣味性 |
永远不要为键盘触发的操作添加动画。这类操作每天重复数百次,动画会使其显得缓慢、迟滞,与用户动作脱节。Raycast 没有开合动画——对于每天使用数百次的工具,这就是最优体验。
2. 动画的目的是什么?
每个动画都必须能明确回答"为什么要动"。合法目的包括:
- 空间一致性:toast 从同一方向进出,让滑动关闭显得直观;
- 状态指示:一个形态变化的反馈按钮展示状态变更;
- 解释说明:展示某功能如何工作的营销动画;
- 反馈:按钮按下时缩小,确认界面听到了用户的操作;
- 防止突兀变化:元素无过渡地出现或消失会显得像坏了。
如果目的仅仅是"看起来很酷"且用户会频繁看到,那就不要动。
3. 该用哪种缓动曲线?
决策流程:
- 元素在进入/退出?→ 用
ease-out(起步快,感觉响应迅速) - 否 → 在屏幕上移动/变形?→ 用
ease-in-out(自然的加速/减速) - 否 → 是悬停/颜色变化?→ 用
ease - 否 → 是恒定运动(跑马灯、进度条)?→ 用
linear - 默认 → 用
ease-out
关键:使用自定义缓动曲线。内置 CSS 缓动太弱,缺乏让动画显得有意图的冲击力:
/* 用于 UI 交互的强 ease-out */ --ease-out: cubic-bezier(0.23, 1, 0.32, 1); /* 用于屏幕上移动的强 ease-in-out */ --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); /* iOS 风格抽屉曲线(源自 Ionic Framework) */ --ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);永远不要在 UI 动画中使用ease-in。它起步慢,会让界面显得迟钝。300ms 的ease-in下拉菜单比同样 300ms 的ease-out感觉更慢——因为ease-in延迟了最初的移动,而那个瞬间正是用户最关注的地方。ease-out在 200ms 时感觉比同时长的ease-in更快,因为用户立即看到了移动。
SurfSense 中可以看到这条原则的两种落地形态:Tailwind 动画配置(tailwind.config.js)中的accordion-down/accordion-up使用0.2s ease-out,progress-indeterminate使用1.5s ease-in-out infinite(恒定运动用 ease-in-out);而 reveal.tsx 定义了更强的自定义曲线EASE_OUT = [0.16, 1, 0.3, 1]用于进入视口的滚动揭示动画。
4. 动画应该多快?
| 元素 | 时长 |
|---|---|
| 按钮按压反馈 | 100-160ms |
| Tooltip、小 popover | 125-200ms |
| 下拉菜单、select | 150-250ms |
| 弹窗、抽屉 | 200-500ms |
| 营销/解释类 | 可以更长 |
规则:UI 动画应控制在 300ms 以内。180ms 的下拉菜单比 400ms 的感觉响应更快。转得快的 spinner 会让应用"感觉"加载更快,即使实际加载时间完全相同。
感知性能
动画的速度不只是"灵敏"的感觉问题,它直接影响用户对应用性能的感知:
- 快速旋转的 spinner让加载感觉更快(加载时间相同,感知不同);
- 180ms 的 select动画比400ms的感觉响应更灵敏;
- 第一个 tooltip 打开后,后续 tooltip立即出现(跳过延迟 + 跳过动画),让整个工具栏感觉更快。
弹簧动画(Spring)
弹簧比基于时长的动画更自然,因为它模拟真实物理:没有固定时长,而是根据物理参数自然收敛。
何时使用弹簧
- 带惯性的拖拽交互;
- 应该"活起来"的元素(如 Apple 的灵动岛);
- 可能被中途打断的手势;
- 装饰性的鼠标追踪交互。
基于弹簧的鼠标交互
把视觉变化直接绑到鼠标位置会显得生硬,因为它缺乏运动。应使用 Motion(原 Framer Motion)的useSpring对数值变化做弹簧插值,而不是立即更新:
import { useSpring } from 'framer-motion'; // 无弹簧:感觉生硬、瞬时 const rotation = mouseX * 0.1; // 有弹簧:感觉自然、有动量 const springRotation = useSpring(mouseX * 0.1, { stiffness: 100, damping: 10, });SurfSense 的 tilt.tsx 正是这一原则的教科书级实现:用useSpring插值鼠标归一化坐标,再通过useTransform映射为rotateX/rotateY,最终用useMotionTemplate拼成perspective(1000px) rotateX(...) rotateY(...)的完整 transform 字符串,并设置transformStyle: "preserve-3d"。鼠标离开时x.set(0); y.set(0)让卡片平滑回正。这条原则同样出现在 github-stars-badge.tsx({ stiffness: 70, damping: 20 }的滚动切换)与 pricing.tsx(stiffness: 100)中。
之所以用弹簧,是因为这类动画是装饰性的——不承担功能职责。如果这是银行应用里的功能图表,那么不做动画反而更好。要懂得装饰何时有益、何时有害。
弹簧配置
Apple 风格(推荐——更易理解):
{ type: "spring", duration: 0.5, bounce: 0.2 }传统物理模型(控制力更强):
{ type: "spring", mass: 1, stiffness: 100, damping: 10 }使用 bounce 时保持轻微(0.1-0.3)。大多数 UI 场景应避免 bounce,仅在拖拽关闭和趣味交互中使用。
可中断性优势
弹簧被中断时会保持当前速度——而 CSS 动画和 keyframes 会从零重启。这让弹簧非常适合用户可能中途改变方向的手势:当你点击一个已展开的条目并迅速按 Escape 时,基于弹簧的动画会从当前位置平滑反向。
组件构建原则
按钮必须对按压有响应
在:active上加transform: scale(0.97),提供即时反馈,让 UI 感觉真正在倾听用户:
.button { transition: transform 160ms ease-out; } .button:active { transform: scale(0.97); }这适用于一切可按压元素。缩放幅度应轻微(0.95-0.98)。
永远不要从 scale(0) 开始动画
现实世界中没有任何东西会完全消失再出现。从scale(0)进入的元素看起来像凭空冒出来。应从scale(0.9)或更高起步,并配合 opacity——即使初始缩放几乎不可见,也会让进入更自然,像一个泄了气的气球也有可见的形状:
/* 不好 */ .entering { transform: scale(0); } /* 好 */ .entering { transform: scale(0.95); opacity: 0; }这条在 SurfSense 中体现为 Radix/Tailwind 生态的zoom-in-95工具类:所有 popover/dropdown/select/context-menu 的进入状态都是fade-in-0+zoom-in-95,退出是zoom-out-95,从未使用从 0 缩放。
让 popover 感知原点
Popover 应从触发器处缩放进入,而不是从中心。默认的transform-origin: center对几乎所有 popover 都是错的。例外:弹窗(modal)。Modal 应保持transform-origin: center,因为它们不锚定于某个触发器,而是在视口居中呈现:
/* Radix UI */ .popover { transform-origin: var(--radix-popover-content-transform-origin); } /* Base UI */ .popover { transform-origin: var(--transform-origin); }用户是否逐个注意到差异并不重要。在整体上,不可见细节会变得可见,它们不断复利。
Tooltip:后续悬停跳过延迟
Tooltip 在出现前应有延迟,防止误触。但一旦某个 tooltip 已打开,悬停相邻 tooltip 应立即打开且无动画。这感觉更快,同时不违背初始延迟的目的:
.tooltip { transition: transform 125ms ease-out, opacity 125ms ease-out; transform-origin: var(--transform-origin); } .tooltip[data-starting-style], .tooltip[data-ending-style] { opacity: 0; transform: scale(0.97); } /* 后续 tooltip 跳过动画 */ .tooltip[data-instant] { transition-duration: 0ms; }用 CSS transition 而非 keyframes 处理可中断 UI
CSS transition 可以在动画中途被中断并重新定向;keyframes 则从零重启。任何可能被快速反复触发的交互(连续添加 toast、切换状态),transition 都能产生更平滑的结果:
/* 可中断 - 适合 UI */ .toast { transition: transform 400ms ease; } /* 不可中断 - 动态 UI 中应避免 */ @keyframes slideIn { from { transform: translateY(100%); } to { transform: translateY(0); } }用模糊遮罩不完美的过渡
当两个状态间的交叉淡化在尝试各种缓动与时长后仍然别扭时,在过渡期间加上轻微的filter: blur(2px)。
为什么 blur 有效:没有 blur 时,交叉淡化中你会看到两个不同的对象——旧状态和新状态重叠。这看起来不自然。Blur 通过混合两个状态弥合视觉鸿沟,欺骗眼睛感知到一次平滑变换,而不是两个对象的互换。
把 blur 与按压缩放(scale(0.97))组合,得到精致的按钮状态过渡:
.button { transition: transform 160ms ease-out; } .button:active { transform: scale(0.97); } .button-content { transition: filter 200ms ease, opacity 200ms ease; } .button-content.transitioning { filter: blur(2px); opacity: 0.7; }Blur 控制在 20px 以内。重 blur 开销很大,尤其 Safari。
用 @starting-style 动画进入态
无需 JavaScript 的现代 CSS 元素进入动画方式:
.toast { opacity: 1; transform: translateY(0); transition: opacity 400ms ease, transform 400ms ease; @starting-style { opacity: 0; transform: translateY(100%); } }这取代了常见的 React 模式——用useEffect在初始渲染后设置mounted: true。浏览器支持允许时优先使用@starting-style,否则回退到data-mounted属性模式:
// 传统模式(仍到处可用) useEffect(() => { setMounted(true); }, []); // <div>/* 与抽屉高度无关 */ .drawer-hidden { transform: translateY(100%); } /* 与 toast 高度无关 */ .toast-enter { transform: translateY(-100%); }优先用百分比而非硬编码像素值,它们更不易出错且自适应内容。
scale() 会连带缩放子元素
与width/height不同,scale()也会缩放元素的子元素。按压缩放按钮时,字号、图标和内容会按比例缩放。这是特性,不是缺陷。
3D 变换制造深度
rotateX()、rotateY()配合transform-style: preserve-3d可在 CSS 中创造真实 3D 效果。环绕动画、硬币翻转、深度效果都无需 JavaScript:
.wrapper { transform-style: preserve-3d; } @keyframes orbit { from { transform: translate(-50%, -50%) rotateY(0deg) translateZ(72px) rotateY(360deg); } to { transform: translate(-50%, -50%) rotateY(360deg) translateZ(72px) rotateY(0deg); } }transform-origin
每个元素都有变换执行的锚点,默认为中心。把它设置为触发器所在位置,即可实现感知原点的交互。
用 clip-path 做动画
clip-path不只是做形状,它是 CSS 中最强大的动画工具之一。
inset 形状
clip-path: inset(top right bottom left)定义矩形裁剪区域。每个值从对应边"吃掉"元素:
/* 从右侧完全隐藏 */ .hidden { clip-path: inset(0 100% 0 0); } /* 完全可见 */ .visible { clip-path: inset(0 0 0 0); } /* 从左到右揭示 */ .overlay { clip-path: inset(0 100% 0 0); transition: clip-path 200ms ease-out; } .button:active .overlay { clip-path: inset(0 0 0 0); transition: clip-path 2s linear; }完美颜色过渡的 Tabs
复制 tab 列表。把副本样式化为"激活"态(不同背景、不同文字色)。裁剪副本使只有激活 tab 可见。切换 tab 时动画化 clip。这会创造计时单个颜色过渡永远无法达到的无缝颜色变换。SurfSense 的 theme-toggle.tsx 正是用getClipPath(start)计算裁剪路径,通过clip-path: ${clipPath.from}实现主题切换的环形揭示效果。
长按删除模式
在彩色覆盖层上使用clip-path: inset(0 100% 0 0)。:active时以 linear 计时在 2s 内过渡到inset(0 0 0 0)。松手时以 200ms ease-out 弹回。按钮上加scale(0.97)提供按压反馈。
滚动图片揭示
从clip-path: inset(0 0 100% 0)(从底部隐藏)开始。元素进入视口时动画到inset(0 0 0 0)。使用IntersectionObserver或 Framer Motion 的useInView配合{ once: true, margin: "-100px" }。SurfSense 的 reveal.tsx 用的是whileInView+viewport={{ once: true, amount: 0.2 }},即同一"一次性进入视口"模式的 Motion 化版本。
对比滑块
叠加两张图片。裁剪顶部那张为clip-path: inset(0 50% 0 0)。根据拖拽位置调整右 inset 值。无需额外 DOM 元素,完全硬件加速。
手势与拖拽交互
基于动量的关闭
不要要求拖过阈值。计算速度:Math.abs(dragDistance) / elapsedTime。如果速度超过约 0.11,无论距离多远都关闭。快速一甩就应该够:
const timeTaken = new Date().getTime() - dragStartTime.current.getTime(); const velocity = Math.abs(swipeAmount) / timeTaken; if (Math.abs(swipeAmount) >= SWIPE_THRESHOLD || velocity > 0.11) { dismiss(); }边界阻尼
当用户拖过自然边界(如抽屉已在顶部还继续上拖)时,施加阻尼。拖得越多,元素移动越少。现实中的物体不会戛然而止,而是先减速。
拖拽指针捕获
一旦拖拽开始,让元素捕获所有指针事件,确保即使指针移出元素边界也能继续拖拽。
多点触控保护
初始拖拽开始后忽略额外的触摸点。否则拖拽中换手指会导致元素跳到新位置:
function onPress() { if (isDragging) return; // 开始拖拽... }用摩擦代替硬停
不要完全阻止向上拖拽,而是允许并施加递增摩擦。这比撞上隐形墙感觉自然得多。
性能规则
只动画 transform 和 opacity
这两个属性跳过布局与绘制,跑在 GPU 上。动画padding、margin、height、width会触发全部三个渲染阶段。
CSS 变量可继承
在父元素上修改 CSS 变量会为所有子元素重算样式。在包含大量条目的抽屉中,在容器上更新--swipe-amount会导致昂贵的样式重算。应直接在元素上更新transform:
// 不好:触发所有子元素重算 element.style.setProperty('--swipe-amount', `${distance}px`); // 好:只影响该元素 element.style.transform = `translateY(${distance}px)`;Framer Motion 硬件加速的注意点
Framer Motion 的简写属性(x、y、scale)并非硬件加速,它们使用主线程上的requestAnimationFrame。要硬件加速,请使用完整 transform 字符串:
// 非硬件加速(方便,但负载下会掉帧) <motion.div animate={{ x: 100 }} /> // 硬件加速(即使主线程繁忙也保持流畅) <motion.div animate={{ transform: "translateX(100px)" }} />这在浏览器同时加载内容、运行脚本或绘制时至关重要。在 Vercel,仪表盘 tab 动画曾使用 Shared Layout Animations 并在页面加载时掉帧;改用 CSS 动画(主线程之外)后修复。SurfSense 的 tilt.tsx 正是用useMotionTemplate拼出perspective(1000px) rotateX(...) rotateY(...)的完整 transform 字符串,而不是用简写的x/y属性。
负载下 CSS 动画胜过 JS
CSS 动画跑在主线程之外。当浏览器忙于加载新页面时,Framer Motion 动画(用requestAnimationFrame)会掉帧,CSS 动画保持流畅。预定的动画用 CSS;动态、可中断的动画用 JS。
用 WAAPI 做编程式 CSS 动画
Web Animations API 给你 JavaScript 的控制力与 CSS 的性能。硬件加速、可中断、无需库:
element.animate([{ clipPath: 'inset(0 0 100% 0)' }, { clipPath: 'inset(0 0 0 0)' }], { duration: 1000, fill: 'forwards', easing: 'cubic-bezier(0.77, 0, 0.175, 1)', });无障碍
prefers-reduced-motion
动画可能引起晕动症。"减少动画"意味着更少、更温和的动画,而不是零动画。保留有助于理解的 opacity 与颜色过渡,移除位移和位置动画:
@media (prefers-reduced-motion: reduce) { .element { animation: fade 0.2s ease; /* 无 transform 位移 */ } }const shouldReduceMotion = useReducedMotion(); const closedX = shouldReduceMotion ? 0 : '-100%';这条原则在 SurfSense 中贯彻得极为彻底,useReducedMotion遍布首页与营销组件:
- hero-section.tsx 通过
reduceMotion控制首页 Hero 演示区的动画强度; - navbar.tsx 用它决定移动端菜单的关闭位移(
0还是-100%,与上述closedX模式完全一致); - reveal.tsx 在
reduce为真时直接静态渲染、不做滚动揭示; - flow-line.tsx、agent-transcript.tsx、use-case-art.tsx 同样遵循。
触屏设备悬停状态
@media (hover: hover) and (pointer: fine) { .element:hover { transform: scale(1.05); } }触屏设备在点按时会触发 hover,产生误报。把 hover 动画限制在这个媒体查询之后。
Sonner 原则:打造被喜爱的组件
这些原则来自构建 Sonner 的经验,适用于任何组件:
- 开发者体验是核心。无 hooks、无 context、无复杂配置。插入一次
<Toaster />,从任何地方调用toast()。采用摩擦越小,使用的人越多。SurfSense 在根布局级放置了 AnnouncementToastProvider.tsx("与<Toaster />并列放置一次"),并在登录、注册、日志管理等页面随处toast.success(...)/toast.error(...)(如 register/page.tsx/register/page.tsx#L59-L114)),正是这一模式。 - 好的默认值胜过选项。开箱即美。大多数用户从不自定义。默认的缓动、时长和视觉设计应当出色。
- 命名创造身份。"Sonner"(法语"响铃"之意)比 "react-toast" 更优雅。在适当时候用记忆性换取可发现性。
- 无形中处理边界情况。标签页隐藏时暂停 toast 计时器。用伪元素填补堆叠 toast 之间的空隙以维持 hover 状态。拖拽期间捕获指针事件。用户永远不会注意到这些——这正是对的。
- 动态 UI 用 transition,不用 keyframes。Toast 会被快速添加。keyframes 在中断时从零重启,transition 平滑重定向。
- 建立出色的文档站。让人们先上手把玩、理解,再使用产品。带现成代码片段的交互式示例降低采用门槛。
一致性至关重要
Sonner 的动画令人满意,部分原因是整体体验一致。缓动与时长契合库的气质:比典型 UI 动画略慢,用ease而非ease-out来显得更优雅。动画风格与 toast 设计、页面设计、名称——一切和谐统一。
选择动画数值时,考虑组件的性格。俏皮的组件可以更有弹性,专业的仪表盘应该干脆利落。让动效匹配情绪。
opacity + height 组合
列表条目进出(如 Family 的抽屉)时,opacity 变化必须与 height 动画良好配合。这往往是试错过程,没有公式——调到感觉对了为止。
第二天再评审你的工作
用新鲜的眼光评审动画。第二天你能注意到开发时遗漏的瑕疵。用慢速或逐帧播放动画,发现全速时不可见的时序问题。
非对称的进入/退出时序
按压在需要刻意时应该慢(长按删除:2s linear),但释放永远要干脆(200ms ease-out)。这个模式广泛适用:用户做决定时慢,系统响应时快:
/* 释放:快 */ .overlay { transition: clip-path 200ms ease-out; } /* 按压:慢而刻意 */ .button:active .overlay { transition: clip-path 2s linear; }交错动画(Stagger)
多个元素同时进入时,让它们依次交错出现。每个元素在上一个之后以小延迟进入,形成级联效果,比全部同时出现更自然:
.item { opacity: 0; transform: translateY(8px); animation: fadeIn 300ms ease-out forwards; } .item:nth-child(1) { animation-delay: 0ms; } .item:nth-child(2) { animation-delay: 50ms; } .item:nth-child(3) { animation-delay: 100ms; } .item:nth-child(4) { animation-delay: 150ms; } @keyframes fadeIn { to { opacity: 1; transform: translateY(0); } }保持交错延迟短(条目间 30-80ms)。长延迟让界面显得慢。交错是装饰性的——绝不要让交错动画阻塞交互。
调试动画
慢动作测试
以降低的速度播放动画,发现全速下不可见的问题。临时把时长放大到 2-5 倍,或用浏览器 DevTools 的动画检查器放慢播放。
慢动作下要观察的要点:
- 颜色是否平滑过渡,还是看到两个不同状态重叠?
- 缓动是否合适,还是起止突兀?
- transform-origin 是否正确,还是元素从错误的位置缩放?
- 多个动画属性(opacity、transform、color)是否同步?
逐帧检查
在 Chrome DevTools(Animations 面板)逐帧步进动画。这能揭示协调属性之间全速下无法察觉的时序问题。
在真实设备上测试
触屏交互(抽屉、滑动手势)要在实体设备上测试。用 USB 连接手机,通过 IP 访问本地开发服务器,使用 Safari 远程调试工具。Xcode Simulator 是替代方案,但真实硬件对手势测试更好。
评审清单
评审 UI 代码时逐项检查:
| 问题 | 修复 |
|---|---|
transition: all | 指定精确属性:transition: transform 200ms ease-out |
scale(0)进入动画 | 从scale(0.95)加opacity: 0起步 |
UI 元素上的ease-in | 换用ease-out或自定义曲线 |
popover 的transform-origin: center | 设为触发器位置或用 Radix/Base UI CSS 变量(modal 豁免——保持居中) |
| 键盘操作的动画 | 完全移除动画 |
| UI 元素时长 > 300ms | 降到 150-250ms |
| 无媒体查询的 hover 动画 | 加@media (hover: hover) and (pointer: fine) |
| 快速触发元素上的 keyframes | 用 CSS transition 保证可中断 |
负载下 Framer Motion 的x/y属性 | 用transform: "translateX()"硬件加速 |
| 进入/退出速度相同 | 退出比进入更快(如进入 2s,退出 200ms) |
| 所有元素同时出现 | 加交错延迟(条目间 30-80ms) |
在 SurfSense 中落地这套方法论
总结这套哲学在 surfsense_web 仓库中的具体落点,可作为自查与二次开发的参考:
- 依赖选型:
motion ^12、radix-ui ^1.4、sonner ^2(package.json),与文档建议的生态完全一致; - 弹层动画规范:popover/dropdown/select/context-menu 统一使用
zoom-in-95+origin-(--radix-...-transform-origin)+ 基于data-[side=...]的滑入偏移(如 dropdown-menu.tsx、select.tsx),实现了"从触发器缩放、从 0.95 进入、方向感知"三条原则; - 滚动揭示:
EASE_OUT = [0.16, 1, 0.3, 1]自定义曲线 +whileInView一次性触发 + reduced-motion 静态降级(reveal.tsx); - 装饰性弹簧交互:
useSpring+useTransform+ 完整 transform 字符串(tilt.tsx); - clip-path 创意动画:主题切换的环形揭示(theme-toggle.tsx);
- 无障碍降级:
useReducedMotion在首页 Hero、navbar、flow-line、agent-transcript、use-case-art 等组件中系统性使用; - toast 模式:根布局一次挂载
<Toaster />,业务代码随处toast.success/error(AnnouncementToastProvider.tsx、login/page.tsx/login/page.tsx#L40-L86))。
结合这份清单评审或编写 UI 时,可以回到本文的动画决策框架(该不该动 → 目的 → 缓动 → 时长)逐层推导,再用评审清单收尾自查,就能持续产出"每个细节都恰到好处"的界面。
【免费下载链接】SurfSenseOpen-source NotebookLM alternative. Research the open web with live data(Reddit, YT, IG, TikTok, Indeed, Google Search, Maps etc) through one platform, API or MCP server. Join our Discord: https://discord.gg/ejRNvftDp9项目地址: https://gitcode.com/GitHub_Trending/su/SurfSense
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考