ambient 1200ms · primary 300ms · secondary 400ms

Three Pillars

primary(主役)だけのフェードは flat に感じる。secondary(影・アイコン)と ambient(背景の生命感)を重ねると、同じ通知 UI でも体感の質が一段上がる。

ambient 1200ms · primary 300ms · secondary 400ms

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>
  );
}