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では、ユーザー操作による状態変化には@keyframesよりもtransitionを優先してください。
原則 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:境界では段階的な抵抗を適用する
ユーザーがコンテナーの境界を越えてドラッグまたはスクロールするとき、インターフェースは段階的に抵抗する必要があります。急に止まったり、何もない空間まで自由にスクロールしたりするのではなく、ラバーバンド効果を適用します。少し引くと、要素は速度を落として追従します。さらに引くと、抵抗が強まります。手を離すと、バネのように元の位置へ戻ります。こうすることで、ジェスチャーを妨げずに境界を伝えられます。
重要な理由: 急停止は壁にぶつかったように感じられます。制限のないオーバーフローは、壊れているように感じられます。段階的な抵抗を適用すると、物理的で分かりやすい方法で「端に到達した」ことを伝えられます。これは、端があるインターフェースと、境界があるインターフェースの違いです。
実装:
オーバースクロール距離に、対数関数または平方根関数を用いた減衰を適用します。境界を越える距離が大きくなるほど、ポインターの移動 1 ピクセルあたりに要素が動く距離を小さくします。
// 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)、カスタムプロパティを使った CSS の animation-delay、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 |