container-transform

الگوی حرفه‌ای Material 3 برای تبدیل روان کارت، FAB یا آیتم لیست به نمای جزئیات

این Skill ترنزیشن Container Transform را بر اساس استاندارد Material Design 3 طراحی و پیاده‌سازی می‌کند؛ از تبدیل کارت به صفحه جزئیات و FAB به دیالوگ تا گسترش thumbnail به گالری. زمان‌بندی ۳۰۰ میلی‌ثانیه، easing تأکیدی، cross-fade محتوا و مسیر حرکتی قوسی را مدیریت می‌کند.

برای پروژه‌های Remotion، GSAP و Framer Motion، منطق محاسبه‌ی bounds، تغییر نسبت تصویر، scrim و پشتیبانی از prefers-reduced-motion را آماده می‌کند تا ناوبری متصل، طبیعی و قابل‌دسترس باشد.

چه چیزی می‌گیرید

  • پیاده‌سازی Container Transform با زمان‌بندی استاندارد ۳۰۰ میلی‌ثانیه
  • پشتیبانی از Remotion، GSAP و Framer Motion
  • تشخیص خودکار نیاز به حرکت قوسی بر اساس تغییر نسبت تصویر
  • cross-fade هماهنگ برای محتوای قدیمی و جدید
  • رفتار جایگزین برای کاربران دارای prefers-reduced-motion

برای چه کاری خوب است

  • تبدیل کارت محصول یا مقاله به صفحه‌ی جزئیات
  • گسترش FAB به دیالوگ یا پنل تمام‌صفحه
  • تبدیل thumbnail، آیتم لیست یا chip به نمای بازشده
  • ساخت انیمیشن‌های ناوبری متصل در رابط‌های مبتنی بر Material 3

برای چه کاری نیست

  • انیمیشن‌های مستقل و بدون رابطه‌ی بصری بین مبدأ و مقصد
  • ترنزیشن‌های پیچیده‌ی چندمرحله‌ای یا افکت‌های سینمایی غیرمرتبط با Container Transform
  • پروژه‌هایی که به انیمیشن دسترسی‌پذیر یا کنترل دقیق bounds نیاز ندارند

دربارهٔ این اسکیل

Container Transform

The premium Google M3 transition pattern. Use when one UI element transforms into another.

Standards Reference

StandardDocumentation
Material Design 3m3.material.io/styles/motion/transitions/transition-patterns
Container TransformAndroid Developers - Material Motion

When to Use

  • Card → Detail page
  • FAB → Full dialog
  • List item → Full screen
  • Thumbnail → Gallery
  • Chip → Expanded card

Timing Breakdown (300ms total)

ComponentStartEndDurationEasing
Container bounds0ms300ms300msemphasized (0.2, 0, 0, 1)
Old content fade0ms100ms100mslinear
New content fade100ms300ms200mslinear
Scrim (optional)0ms150ms150mslinear
Arc path (if needed)0ms300ms300msemphasized

Arc Path Rule

Use arc motion when aspect ratio changes by >10%

const startAspect = startWidth / startHeight;
const endAspect = endWidth / endHeight;
const needsArc = Math.abs(startAspect - endAspect) / startAspect > 0.1;

Load threshold from: tokens/position.tokens.json → motion.position.arc-threshold

Token References

{
  "duration": "{motion.duration.medium-2}",
  "easing": "{motion.easing.emphasized}",
  "arc-threshold": "{motion.position.arc-threshold}",
  "scrim-opacity": "{motion.scrim.opacity.standard}"
}

Implementation

Remotion

import { interpolate, useCurrentFrame, Easing } from 'remotion';

interface Bounds {
  x: number;
  y: number;
  width: number;
  height: number;
  radius?: number;
}

export const ContainerTransform: React.FC<{
  startBounds: Bounds;
  endBounds: Bounds;
  startContent: React.ReactNode;
  endContent: React.ReactNode;
  fps?: number;
}> = ({ startBounds, endBounds, startContent, endContent, fps = 30 }) => {
  const frame = useCurrentFrame();
  const totalFrames = Math.round((300 / 1000) * fps); // 300ms
  const progress = frame / totalFrames;

  const emphasized = Easing.bezier(0.2, 0, 0, 1);

  // Container bounds (emphasized easing)
  const x = interpolate(progress, [0, 1], [startBounds.x, endBounds.x], {
    easing: emphasized,
    extrapolateRight: 'clamp',
  });
  const y = interpolate(progress, [0, 1], [startBounds.y, endBounds.y], {
    easing: emphasized,
    extrapolateRight: 'clamp',
  });
  const width = interpolate(progress, [0, 1], [startBounds.width, endBounds.width], {
    easing: emphasized,
    extrapolateRight: 'clamp',
  });
  const height = interpolate(progress, [0, 1], [startBounds.height, endBounds.height], {
    easing: emphasized,
    extrapolateRight: 'clamp',
  });

  // Arc offset for disproportionate morph (10% threshold)
  const startAspect = startBounds.width / startBounds.height;
  const endAspect = endBounds.width / endBounds.height;
  const needsArc = Math.abs(startAspect - endAspect) / startAspect > 0.1;
  const arcOffset = needsArc
    ? interpolate(progress, [0, 0.5, 1], [0, -50, 0], { easing: emphasized })
    : 0;

  // Content cross-fade (linear, nested timing)
  const oldOpacity = interpolate(progress, [0, 0.33], [1, 0], {
    extrapolateRight: 'clamp',
  });
  const newOpacity = interpolate(progress, [0.33, 1], [0, 1], {
    extrapolateLeft: 'clamp',
  });

  const borderRadius = interpolate(
    progress,
    [0, 1],
    [startBounds.radius || 0, endBounds.radius || 0],
    { easing: emphasized }
  );

  return (
    <div style={{
      position: 'absolute',
      left: x,
      top: y + arcOffset,
      width,
      height,
      overflow: 'hidden',
      borderRadius,
    }}>
      <div style={{ position: 'absolute', inset: 0, opacity: oldOpacity }}>
        {startContent}
      </div>
      <div style={{ position: 'absolute', inset: 0, opacity: newOpacity }}>
        {endContent}
      </div>
    </div>
  );
};

GSAP

import gsap from 'gsap';

function containerTransform(startEl, endEl, options = {}) {
  const duration = options.duration || 0.3;
  const tl = gsap.timeline();

  const startRect = startEl.getBoundingClientRect();
  const endRect = endEl.getBoundingClientRect();

  // Check if arc needed (10% threshold)
  const startAspect = startRect.width / startRect.height;
  const endAspect = endRect.width / endRect.height;
  const needsArc = Math.abs(startAspect - endAspect) / startAspect > 0.1;

  // Create morph container
  const container = document.createElement('div');
  container.className = 'container-transform';
  container.style.cssText = `
    position: fixed;
    left: ${startRect.left}px;
    top: ${startRect.top}px;
    width: ${startRect.width}px;
    height: ${startRect.height}px;
    overflow: hidden;
    z-index: 9999;
  `;

  // Clone contents
  const startClone = startEl.cloneNode(true);
  const endClone = endEl.cloneNode(true);
  container.appendChild(startClone);
  container.appendChild(endClone);
  gsap.set(endClone, { position: 'absolute', inset: 0, opacity: 0 });
  gsap.set(startClone, { position: 'absolute', inset: 0 });

  document.body.appendChild(container);
  gsap.set([startEl, endEl], { visibility: 'hidden' });

  // Container morph (emphasized easing)
  tl.to(container, {
    left: endRect.left,
    top: endRect.top,
    width: endRect.width,
    height: endRect.height,
    duration,
    ease: 'power2.out',
  }, 0);

  // Arc path if needed
  if (needsArc) {
    tl.to(container, { y: -50, duration: duration / 2, ease: 'power2.out' }, 0);
    tl.to(container, { y: 0, duration: duration / 2, ease: 'power2.in' }, duration / 2);
  }

  // Content cross-fade (linear, nested timing)
  tl.to(startClone, { opacity: 0, duration: duration * 0.33, ease: 'none' }, 0);
  tl.to(endClone, { opacity: 1, duration: duration * 0.67, ease: 'none' }, duration * 0.33);

  // Cleanup
  tl.call(() => {
    gsap.set(endEl, { visibility: 'visible' });
    container.remove();
  });

  return tl;
}

Framer Motion

import { motion, AnimatePresence } from 'framer-motion';

// Use layoutId for automatic container transform
<motion.div layoutId="card-1">
  {isExpanded ? <DetailView /> : <CardView />}
</motion.div>

Accessibility

const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

if (prefersReducedMotion) {
  // Instant swap, no animation
  startEl.style.display = 'none';
  endEl.style.display = 'block';
} else {
  containerTransform(startEl, endEl);
}

See references/timing-breakdown.md for frame-by-frame details.

دانلود ZIP