mcpField
F
fluid-design

fluid-design

github.com/kemiljk设计与 UI794 次安装32 周前更新
代码仓库

关于 fluid-design

fluid-design 是一项面向界面设计与开发的技能,介绍让界面响应更贴近物理运动和用户输入的设计原则。内容涵盖弹簧动画、直接操控、跨视图过渡及布局变化等实现方式。

主要功能

  1. 01采用基于物理的弹簧动画
  2. 02支持拖动等直接操控
  3. 03在手势边界间保留速度
  4. 04用共享元素过渡维持空间感
  5. 05依据输入方式调整交互
  6. 06动画呈现布局变化

适用场景

  1. 01设计可拖动关闭的底部面板
  2. 02让缩略图平滑过渡到详情视图
  3. 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

评论

支持 Markdown