ambient 1200ms · primary 300ms · secondary 400ms
Three Pillars
primary(主役)だけのフェードは flat に感じる。secondary(影・アイコン)と ambient(背景の生命感)を重ねると、同じ通知 UI でも体感の質が一段上がる。
Bad · flat(linear opacity only)
Notification
primary layer のみ
Good · 3層重ね
Saved successfully
primary + secondary + ambient
// notes
- primary layer だけの opacity フェードは情報は伝わるが体温がない。
- ambient layer(背景の淡いグラデーション伸縮)は直視されなくても余韻を作る。
- secondary layer(影のフェード・アイコンのオーバーシュート)が「物理的な質感」を担う。
- 3 層は同時に動かさず、primary → shadow → icon → title → desc の順に微妙な delay(0 / 50 / 80 / 120 / 180ms)を重ねると自然に見える。
/**
* Three Pillars — primary / secondary / ambient の3層を flat 版と比較する。
* 移植元: motion-playground app/_sections/ThreePillars.tsx(dark 変換 M-B-2)
*/
import { motion } from 'motion/react';
import { durations, easings } from '../../../lib/motion-tokens/playground';
import { useLabReplay } from './use-lab-replay';
function FlatCard({ playKey }: { playKey: number }) {
return (
<div className="relative h-64 overflow-hidden rounded-lg border border-[var(--border-subtle)] bg-[var(--bg-elevated)]">
<motion.div
key={playKey}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: durations.cardEnter, ease: 'linear' }}
className="absolute inset-0 flex items-center justify-center"
>
<div className="rounded-lg bg-[var(--bg-surface)] px-6 py-4 shadow-sm ring-1 ring-[var(--border-subtle)]">
<p className="text-sm font-medium text-[var(--text-primary)]">Notification</p>
<p className="mt-1 text-xs text-[var(--text-muted)]">primary layer のみ</p>
</div>
</motion.div>
</div>
);
}
function LayeredCard({ playKey }: { playKey: number }) {
return (
<div className="relative h-64 overflow-hidden rounded-lg border border-[var(--border-subtle)] bg-gradient-to-br from-[var(--bg-elevated)] to-[rgba(56,189,248,0.06)]">
{/* Ambient layer: subtle gradient pulse */}
<motion.div
key={`ambient-${playKey}`}
initial={{ opacity: 0, scale: 1.1 }}
animate={{ opacity: 1, scale: 1 }}
transition={{
duration: 1.2,
ease: easings.gentle,
}}
className="pointer-events-none absolute -top-12 -right-12 h-48 w-48 rounded-full bg-[rgba(56,189,248,0.15)] blur-3xl"
/>
<div className="absolute inset-0 flex items-center justify-center">
{/* Primary + Secondary */}
<motion.div
key={`primary-${playKey}`}
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{
duration: durations.cardEnter,
ease: easings.md3Emphasized,
}}
className="rounded-lg bg-[var(--bg-surface)] px-6 py-4 ring-1 ring-[var(--border-subtle)]"
style={{ boxShadow: '0 1px 2px rgba(0, 0, 0, 0.3)' }}
>
<motion.div
key={`shadow-${playKey}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
duration: durations.cardEnter,
ease: easings.gentle,
delay: 0.05,
}}
className="pointer-events-none absolute inset-0 -z-10 rounded-lg"
style={{ boxShadow: '0 8px 24px rgba(0, 0, 0, 0.4)' }}
/>
<div className="flex items-center gap-3">
<motion.div
key={`icon-${playKey}`}
initial={{ scale: 0.6, rotate: -8 }}
animate={{ scale: 1, rotate: 0 }}
transition={{
duration: 0.4,
ease: easings.bounceSettle,
delay: 0.08,
}}
className="flex h-8 w-8 items-center justify-center rounded-full bg-[var(--success)] text-[var(--text-inverted)]"
aria-hidden="true"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
>
<path d="m5 13 4 4L19 7" />
</svg>
</motion.div>
<div>
<motion.p
key={`title-${playKey}`}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.3,
ease: easings.gentle,
delay: 0.12,
}}
className="text-sm font-medium text-[var(--text-primary)]"
>
Saved successfully
</motion.p>
<motion.p
key={`desc-${playKey}`}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.3,
ease: easings.gentle,
delay: 0.18,
}}
className="mt-0.5 text-xs text-[var(--text-muted)]"
>
primary + secondary + ambient
</motion.p>
</div>
</div>
</motion.div>
</div>
</div>
);
}
export default function ThreePillars() {
const { rootRef, playKey } = useLabReplay();
return (
<div
ref={rootRef}
data-lab-island
className="grid w-full gap-6 p-4 sm:grid-cols-2 lg:p-6"
>
<div>
<p className="mb-3 text-xs font-medium uppercase tracking-wide text-[var(--text-muted)]">
Bad · flat(linear opacity only)
</p>
<FlatCard playKey={playKey} />
</div>
<div>
<p className="mb-3 text-xs font-medium uppercase tracking-wide text-[var(--text-muted)]">
Good · 3層重ね
</p>
<LayeredCard playKey={playKey} />
</div>
</div>
);
} .card {
opacity: 0;
animation: card-enter 300ms cubic-bezier(0.05, 0.7, 0.1, 1) forwards;
}
.card-shadow { opacity: 0; animation: fade-in 300ms cubic-bezier(0.4, 0, 0.2, 1) forwards; animation-delay: 50ms; }
.card-icon { transform: scale(0.6) rotate(-8deg); animation: icon-pop 400ms cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards; animation-delay: 80ms; }
.card-title { opacity: 0; animation: text-in 300ms cubic-bezier(0.4, 0, 0.2, 1) forwards; animation-delay: 120ms; }
.card-desc { opacity: 0; animation: text-in 300ms cubic-bezier(0.4, 0, 0.2, 1) forwards; animation-delay: 180ms; }
.ambient-glow { opacity: 0; transform: scale(1.1); animation: glow-in 1200ms cubic-bezier(0.4, 0, 0.2, 1) forwards; }
@keyframes card-enter { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes icon-pop { from { transform: scale(0.6) rotate(-8deg); } to { transform: scale(1) rotate(0); } }
@keyframes text-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
@keyframes glow-in { from { opacity: 0; transform: scale(1.1); } to { opacity: 1; transform: scale(1); } } Layer a UI entrance into three passes instead of one flat fade (motion/react):
- primary card: y 20 -> 0, opacity 0 -> 1, 300ms, cubic-bezier(0.05, 0.7, 0.1, 1)
- secondary shadow: fade in, 300ms, cubic-bezier(0.4, 0, 0.2, 1), delay 50ms
- secondary icon: scale 0.6 -> 1 + rotate -8deg -> 0, 400ms, cubic-bezier(0.175, 0.885, 0.32, 1.275), delay 80ms
- text: y 4 -> 0 + fade, 300ms gentle, delays 120 / 180ms
- ambient glow: opacity 0 -> 1, scale 1.1 -> 1, 1200ms gentle (slow, barely noticeable but adds life)
- re-trigger everything by changing a shared React key