agentleFS
soilmasssoilmass/motion-design-agent/skills/container-transform/SKILL.md

container-transform

Skill7 starsChanged 10 months agoMITView on GitHub

Google M3 signature transition that morphs one element into another. Use when implementing card-to-detail, FAB-to-dialog, or list-to-fullscreen transitions. The premium transition pattern for connected navigation.

The file

Files
SKILL.md

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.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.