关于 fluid-design
fluid-design 是一项面向界面设计与开发的技能,介绍让界面响应更贴近物理运动和用户输入的设计原则。内容涵盖弹簧动画、直接操控、跨视图过渡及布局变化等实现方式。
主要功能
- 01采用基于物理的弹簧动画
- 02支持拖动等直接操控
- 03在手势边界间保留速度
- 04用共享元素过渡维持空间感
- 05依据输入方式调整交互
- 06动画呈现布局变化
适用场景
- 01设计可拖动关闭的底部面板
- 02让缩略图平滑过渡到详情视图
- 03调整触屏与鼠标的交互方式
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ kemiljk/ fluid-design --skill fluid-design安装后在 Claude Code 中输入/fluid-design确认已加载。
详细介绍
流畅界面的 10 项原则
流畅的界面仿佛具有物理重量、动量和感知能力。它会像真实物体一样响应你的输入,而不是在屏幕上呈现一系列状态变化。这 10 项原则定义了一个界面只是能用和真正感觉自然之间的区别。
每项原则都包含其基本概念、重要性,以及如何使用具体工具实现。
原则 1:动效应基于物理,而不是基于时间
传统的 CSS 过渡使用固定时长和缓动曲线。这样的动效会显得机械——无论元素移动多远,也无论用户松手时移动得多快,每段动画都恰好耗时 300ms。基于物理的动效则使用弹簧动力学:张力、摩擦力和质量共同决定元素如何移动。因此,轻轻推一下会产生柔和的回弹,而快速一甩则会带来有活力的过冲。动效会根据触发它的情境自适应。
重要性: 弹簧动画之所以自然,是因为它们模拟了真实物体的行为。它们没有固定时长——能量消散后,动画便会结束。这样就消除了用户输入速度与动画响应之间令人不适的脱节。
实现:
Framer Motion 的 spring 类型是最易上手的选择。若要进行更底层的控制,可以使用 Popmotion,或基于 requestAnimationFrame 和弹簧物理构建。
// Framer Motion — spring is the default for physical properties
<motion.div
animate={{ x: targetX }}
transition={{
type: "spring",
stiffness: 300, // How tight the spring pulls
damping: 25, // How quickly oscillation settles
mass: 0.8, // How heavy the element feels
}}
/>
// Vanilla JS — spring physics on rAF
function springAnimation(current, target, velocity, { stiffness = 300, damping = 25, mass = 1 }) {
const force = -stiffness * (current - target);
const dampingForce = -damping * velocity;
const acceleration = (force + dampingForce) / mass;
const newVelocity = velocity + acceleration * (1 / 60);
const newPosition = current + newVelocity * (1 / 60);
return { position: newPosition, velocity: newVelocity };
}
关键调节值: 对于响应灵敏的 UI 元素,刚度设为 200–400。阻尼设为 20–30,可获得自然的回弹,同时尽量减少过冲。对于应显得轻巧灵活的元素(开关、标签),将质量降低到 1.0 以下;对于应显得厚重的元素(模态框、抽屉),将质量提高到 1.0 以上。
工具: Framer Motion、React Spring、Popmotion、Motion One、SwiftUI 的 .spring()。
原则 2:每段动画都必须可中断
如果用户在模态框仍在打开的动画过程中点击按钮,模态框应从当前位置平滑地反向移动,而不是先完成打开再关闭。如果用户在卡片弹跳过程中开始拖动,卡片应立即响应手指操作。无法中断的动画会造成根本性的脱节:界面正在执行用户没有要求的操作,而用户只能等待。
重要性: 可中断性是决定界面给人感觉响应灵敏还是迟缓的最重要因素。可以在任意时刻中断的 400ms 动画,感觉上会比锁定输入的 200ms 动画更快。
实现:
弹簧动画本质上可以中断——只需更改目标值,弹簧就会根据当前位置和速度重新计算。CSS 动画和关键帧默认无法中断;你需要读取计算后的样式,并从当前位置重新开始。
// Framer Motion — interruptible by default
// Changing `isOpen` mid-animation reverses smoothly
<motion.div
animate={{ height: isOpen ? "auto" : 0 }}
transition={{ type: "spring", stiffness: 350, damping: 30 }}
/>
/* CSS approach — use transitions, not keyframes, for interruptibility */
.panel {
transition: transform 250ms ease-out;
/* Changing the class mid-transition reverses from current position */
}
.panel.open { transform: translateY(0); }
.panel.closed { transform: translateY(100%); }
经验法则: 如果用户能在动画播放期间触发状态变化,该动画就必须可以中断。如果你在交互元素上使用 @keyframes,请重新考虑。
工具: Framer Motion、React Spring(两者都原生支持此功能)。对于 CSS,任何由用户触发的状态变化都优先使用 transition,而不是 @keyframes。
原则 3:优先直接操控,而非间接控制
尽可能让用户通过直接操控来移动、调整大小、重新排序和关闭元素——例如拖动、滑动、捏合,而不是按下触发这些操作的按钮。通过拖动关闭的面板,与点击关闭按钮的感觉截然不同。直接操控能让用户对界面产生拥有感和真实的连接感。
为什么重要: 直接操控缩短了意图与结果之间的距离。用户不是在告诉界面该做什么,而是亲自去做。这让交互感觉即时而直观,尤其是在触屏设备上。
实现:
跟踪指针的位置和速度。将指针移动直接映射到元素位置(手势过程中保持 1:1 跟随)。释放时,根据指针的速度决定结果——快速滑动会关闭元素,缓慢释放则会让元素弹回原位。
// Framer Motion — drag with velocity-based snap/dismiss
<motion.div
drag="y"
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={0.2}
onDragEnd={(_, info) => {
// Velocity-based decision: fast swipe = dismiss
if (info.velocity.y > 500 || info.offset.y > 200) {
onDismiss();
}
}}
/>
// Vanilla JS — pointer tracking with velocity
let lastY = 0, lastTime = 0, velocity = 0;
element.addEventListener('pointermove', (e) => {
const now = performance.now();
velocity = (e.clientY - lastY) / (now - lastTime);
lastY = e.clientY;
lastTime = now;
// 1:1 tracking — element follows the pointer exactly
element.style.transform = `translateY(${e.clientY - startY}px)`;
});
element.addEventListener('pointerup', () => {
if (Math.abs(velocity) > 0.5) dismiss();
else snapBack();
});
适用场景: 底部面板、抽屉、堆叠卡片、可重新排序的列表、图片库、可关闭通知、滑块,以及用户可能会下意识尝试抓取的任何元素。
工具: Framer Motion(drag)、用于复杂手势识别的 @use-gesture/react、用于重新排序的 Pragmatic Drag and Drop,以及用于手势控制的原生 CSS touch-action。
原则 4:跨越手势边界时保留速度
用户释放拖动的元素时,元素应以释放瞬间的速度继续移动,而不是突然停下,再动画到目标位置。这就是动量。同样,当元素吸附到某个位置时,吸附动画应继承手势速度,并将其作为初始速度。
为什么重要: 保留速度,才能让界面感觉像是在操控物体,而不是在切换状态。它连接了手势(直接操控)与动画(系统响应)。
实现:
在释放的瞬间捕获指针速度。将其作为 initialVelocity 传给弹簧动画。弹簧会以这股能量启动,再自然地消散。
// Framer Motion — velocity is preserved automatically during drag
// For manual control:
const y = useMotionValue(0);
function onPointerUp(velocity) {
animate(y, snapPoint, {
type: "spring",
velocity: velocity, // Inherit gesture velocity
stiffness: 300,
damping: 30,
});
}
测试方法: 快速将元素朝目标方向甩去。元素应略微过冲,然后稳定下来——因为它到达时带有多余的能量。缓慢拖动元素,并在目标附近释放。元素应平滑地滑入目标位置,不发生过冲。如果两种情况的感觉相同,就说明没有保留速度。
工具: Framer Motion 和 React Spring 都支持将 velocity 作为动画参数。对于原生实现,可在手势过程中跟踪 dx/dt,并将其传入弹簧函数。
原则 5:使用共享元素过渡来保持空间上下文
在视图之间导航时,同时存在于两个视图中的元素应连续过渡,而不是消失后再次出现。缩略图展开为完整图像、列表项变形为详情视图、按钮变为模态框——这些共享元素过渡能够保持空间上下文,并帮助用户理解自己所在的位置。
**为什么重要:**如果没有空间过渡,每次导航都像是生硬的切换。用户会失去方位感。共享元素过渡传达出这样的信息:新视图是他们正在查看内容的扩展,而非替代。
实现:
View Transitions API 是跨视图过渡的现代标准。对于单个页面内的组件级过渡,可在 Framer Motion 中使用 layoutId,或使用 FLIP(First、Last、Invert、Play)技术。
// Framer Motion — shared layout animation via layoutId
// List view
<motion.div layoutId={`card-${item.id}`}>
<Thumbnail />
</motion.div>
// Detail view — same layoutId, Framer animates between them
<motion.div layoutId={`card-${item.id}`}>
<FullImage />
</motion.div>
// View Transitions API — cross-page or cross-route transitions
// Assign matching view-transition-name values to shared elements
// CSS:
// .thumbnail { view-transition-name: hero-image; }
// .full-image { view-transition-name: hero-image; }
document.startViewTransition(() => {
updateDOM(); // Swap the views
});
**设计规则:**找出在不同视图间持续存在的元素,并为它们设置连续过渡。其他元素可以作为整体淡入淡出或滑入滑出。持续存在的元素能够锚定用户的空间认知。
**工具:**View Transitions API(原生)、Framer Motion(layoutId、AnimatePresence)、FLIP 技术(手动)、用于 MPA 过渡的 Navigation API。
原则 6:根据输入方式响应,而不仅仅是屏幕尺寸
流体界面不仅会适应视口尺寸,也会适应用户的交互方式。由悬停触发的工具提示系统,在触屏设备上应转换为长按触发。与滚动关联的动画应考虑滚动速度。指针精度应影响目标尺寸。无论当前使用哪种输入方式,界面都应让人感觉自然。
**为什么重要:**在触屏设备上显示悬停工具提示的界面并非响应式,而是无法正常工作。真正的输入响应意味着交互模型会根据当前使用的输入设备变化,而不仅仅依据屏幕宽度。
实现:
使用 @media (hover: hover) 和 @media (pointer: fine | coarse) 来调整交互模式。对于混合设备,动态跟踪当前使用的输入类型。
/* Hover-dependent interactions only when hover is available */
@media (hover: hover) and (pointer: fine) {
.tooltip-trigger:hover .tooltip { opacity: 1; }
.card:hover { transform: translateY(-2px); }
}
/* Larger targets for coarse pointers */
@media (pointer: coarse) {
.interactive { min-height: 48px; min-width: 48px; }
.list-item { padding-block: 14px; }
}
// Scroll velocity detection for scroll-linked animations
let lastScroll = 0, scrollVelocity = 0;
window.addEventListener('scroll', () => {
scrollVelocity = window.scrollY - lastScroll;
lastScroll = window.scrollY;
// Faster scroll = more dramatic parallax / header collapse
header.style.transform = `translateY(${Math.min(0, -scrollVelocity * 0.5)}px)`;
}, { passive: true });
**工具:**CSS Media Queries Level 4(hover、pointer、any-hover、any-pointer)、用于规范化跨输入手势处理的 @use-gesture/react、用于声明式滚动关联效果的 CSS Scroll-Driven Animations。
原则 7:为布局变化添加动画,而不是瞬间跳转
在布局中添加、移除或重新排序元素时,周围的元素应通过动画移动到新位置,而不是瞬间跳转。删除列表项时,该项应平滑收起,同时相邻项向上滑动以填补空隙。新元素应展开出现,同时将相邻元素推开。
**为什么重要:**布局突变是最常见的视觉卡顿来源之一。它们会破坏用户对页面空间布局的认知——用户正在查看的元素会突然移动,却没有任何解释。为布局变化添加动画能够保持连贯性,并帮助用户追踪发生了哪些变化。
实现:
使用 Framer Motion 的 layout 属性自动为布局添加动画。若采用原生方案,可使用 FLIP 技术:记录 DOM 变化前的元素位置,应用变化,然后从旧位置向新位置播放动画。
// Framer Motion — automatic layout animation
<AnimatePresence>
{items.map(item => (
<motion.li
key={item.id}
layout // Animate position changes
initial={{ opacity: 0, y: 20 }} // Enter animation
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: -100 }} // Exit animation
transition={{ type: "spring", stiffness: 300, damping: 30 }}
>
{item.label}
</motion.li>
))}
</AnimatePresence>
// FLIP technique — vanilla JS
function animateLayoutChange(elements, domUpdate) {
// FIRST: record current positions
const positions = elements.map(el => el.getBoundingClientRect());
// Apply DOM change
domUpdate();
// LAST: record new positions
elements.forEach((el, i) => {
const newPos = el.getBoundingClientRect();
const dx = positions[i].left - newPos.left;
const dy = positions[i].top - newPos.top;
// INVERT: offset to old position
el.style.transform = `translate(${dx}px, ${dy}px)`;
// PLAY: animate to new position
requestAnimationFrame(() => {
el.style.transition = 'transform 300ms ease-out';
el.style.transform = '';
});
});
}
**工具:**Framer Motion(layout、AnimatePresence)、AutoAnimate(即插即用、无需配置)、FLIP 技术(手动)、用于文档级布局偏移的 View Transitions API。
原则 8:在边界处施加渐进式阻力
当用户拖动或滚动超出容器边界时,界面应逐渐产生阻力,而不是突然停止,也不是任由内容滚入空白区域。这就是橡皮筋效果。轻轻拉动时,元素会以较低的速率跟随。拉得越远,阻力越大。松开后,元素会像弹簧一样弹回。这种效果能在不阻止手势的情况下传达边界。
**重要性:**硬性停止的感觉就像撞上了一堵墙。不受限制地溢出则会让人觉得界面出了问题。渐进式阻力能以一种真实且直观的方式传达“你已到达边缘”。这就是界面有边缘与有边界之间的区别。
实现方式:
对过度滚动距离应用对数或平方根阻尼函数。超出边界越远,指针每移动一个像素,元素移动的距离就越小。
// Framer Motion — elastic drag constraints
<motion.div
drag="y"
dragConstraints={{ top: -300, bottom: 0 }}
dragElastic={0.3} // 30% tracking beyond boundaries
/>
// Vanilla — rubber band formula
function rubberBand(offset, limit, elasticity = 0.55) {
// Returns dampened offset that approaches limit asymptotically
const clamped = Math.abs(offset);
return Math.sign(offset) * (limit * (1 - Math.exp(-clamped / limit / elasticity)));
}
// Usage during drag past boundary
const overscroll = currentY - boundaryY;
const dampened = rubberBand(overscroll, 120);
element.style.transform = `translateY(${boundaryY + dampened}px)`;
**适用场景:**滚动容器到达滚动极限时、可拖动元素到达约束范围时、下拉刷新交互、底部面板达到最小/最大高度时,以及轮播图边缘。
**工具:**Framer Motion(dragElastic、dragConstraints)、用于基本滚动边界控制的 CSS overscroll-behavior,以及用于精细控制的自定义橡皮筋函数。
原则 9:编排动画序列,不要一次性显示所有内容
当多个元素同时进入或切换状态时,应让它们按顺序依次出现,而不是一拥而上。卡片列表应从上到下依次出现。仪表板应逐个构建各个区块。多个元素同时动画会让人觉得它们融成了一团;错开动画则能让每个元素都有机会单独吸引注意力,并营造出经过精心编排的感觉。
**重要性:**错开动画能营造节奏、引导注意力,让界面显得经过精心打造,而不是一股脑地堆到屏幕上。它还能提升感知性能——相比等待所有数据就绪后再一起播放动画,第一个元素会更早出现。
实现方式:
为组内的每个元素逐步增加延迟。保持各元素的动画时长一致,只改变开始时间。错开动画序列总时长应控制在 400–600ms 内,以免显得缓慢。
// Framer Motion — stagger children
const container = {
animate: {
transition: {
staggerChildren: 0.06, // 60ms between each child
delayChildren: 0.1, // 100ms before first child
}
}
};
const child = {
initial: { opacity: 0, y: 12 },
animate: { opacity: 1, y: 0, transition: { type: "spring", stiffness: 300, damping: 24 } },
};
<motion.ul variants={container} initial="initial" animate="animate">
{items.map(item => (
<motion.li key={item.id} variants={child}>{item.label}</motion.li>
))}
</motion.ul>
/* CSS stagger using custom property */
.stagger-item {
opacity: 0;
transform: translateY(12px);
animation: fadeUp 350ms ease-out forwards;
animation-delay: calc(var(--i) * 60ms);
}
@keyframes fadeUp {
to { opacity: 1; transform: translateY(0); }
}
**时间建议:**每个元素的错开延迟为 40–80ms。将整个序列的时长限制在约 600ms(例如,10 个元素按 60ms 间隔错开,最后一个元素会在 540ms 时开始动画)。对于很长的列表,只对最先出现的 8–10 个可见元素应用错开动画,其余元素则立即渲染。
**工具:**Framer Motion(staggerChildren、variants)、GSAP(stagger)、使用自定义属性设置 animation-delay 的 CSS,以及 AutoAnimate。
原则 10:尊重用户的动态效果偏好
以上所有原则都从属于这一原则:如果用户表示偏好减少动态效果,就应完全尊重这一偏好。这意味着将弹簧动画替换为即时或近乎即时的过渡,禁用视差和自动播放的动画,并简化手势交互。流畅不等于动态效果繁多——对于对动态效果敏感的用户而言,流畅的界面应过渡顺畅,不会引发不适。
重要原因: 很大一部分用户会受到动态效果敏感的影响。前庭障碍、偏头痛和其他状况可能使动画界面造成身体不适。忽略 prefers-reduced-motion 的界面并不流畅——而是令人反感。
实现:
提供减少动态效果模式,在保留空间关系和反馈(不透明度、颜色变化)的同时,去除平移、缩放和旋转动画。
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
// Framer Motion — respect motion preference
import { useReducedMotion } from "framer-motion";
function Card({ children }) {
const reducedMotion = useReducedMotion();
return (
<motion.div
layout={!reducedMotion}
initial={reducedMotion ? false : { opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={reducedMotion
? { duration: 0 }
: { type: "spring", stiffness: 300, damping: 24 }
}
>
{children}
</motion.div>
);
}
规则: 绝不要出于美观原因禁用 prefers-reduced-motion 行为。始终提供减少动态效果的方案。启用该偏好设置,测试你的界面。界面仍应完整且易用——只是更克制。
工具: prefers-reduced-motion 媒体查询、Framer Motion 的 useReducedMotion()、JS 中的 matchMedia('(prefers-reduced-motion: reduce)')。
快速参考
| 原则 | 核心理念 | 关键工具 |
|---|---|---|
| 1. 基于物理的动态效果 | 使用弹簧,而非时长 | Framer Motion、React Spring |
| 2. 每个动画都可中断 | 在动画进行中取消并重定向 | 弹簧(本身可中断) |
| 3. 直接操作 | 拖动、滑动、捏合,而非点击按钮 | @use-gesture/react、Framer drag |
| 4. 保留速度 | 手势动量延续到动画中 | 弹簧上的 initialVelocity |
| 5. 共享元素过渡 | 元素在不同视图间延续 | layoutId、View Transitions API |
| 6. 响应输入方式 | 适配触摸、鼠标和滚动速度 | hover、pointer 媒体查询 |
| 7. 动画呈现布局变化 | 布局变化时不要瞬间跳转 | layout 属性、FLIP、AutoAnimate |
| 8. 渐进式阻力 | 在边界处产生橡皮筋效果 | dragElastic、橡皮筋计算 |
| 9. 编排动画序列 | 错开显示,不要一次性全部呈现 | staggerChildren、GSAP stagger |
| 10. 尊重动态效果偏好 | 减少动态效果不等于完全没有动态效果 | prefers-reduced-motion |