Files
simstudioai--sim/.agents/skills/make-interfaces-feel-better/animations.md
T
wehub-resource-sync d25d482dc2
CI / Migrate DB (push) Blocked by required conditions
CI / Migrate Dev DB (push) Waiting to run
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Blocked by required conditions
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Blocked by required conditions
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Blocked by required conditions
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Blocked by required conditions
CI / Deploy Trigger.dev (Dev) (push) Blocked by required conditions
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Blocked by required conditions
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Blocked by required conditions
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Blocked by required conditions
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Blocked by required conditions
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Blocked by required conditions
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Blocked by required conditions
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Blocked by required conditions
CI / Check Docs Changes (push) Waiting to run
CI / Process Docs (push) Blocked by required conditions
CI / Create GitHub Release (push) Blocked by required conditions
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Test and Build (push) Waiting to run
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Blocked by required conditions
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Blocked by required conditions
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Blocked by required conditions
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Blocked by required conditions
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Blocked by required conditions
CI / Detect Version (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

11 KiB

Animations

Interruptible animations, enter/exit transitions, and contextual icon animations.

Interruptible Animations

Users change intent mid-interaction. If animations aren't interruptible, the interface feels broken.

CSS Transitions vs. Keyframes

CSS Transitions CSS Keyframe Animations
Behavior Interpolate toward latest state Run on a fixed timeline
Interruptible Yes — retargets mid-animation No — restarts from beginning
Use for Interactive state changes (hover, toggle, open/close) Staged sequences that run once (enter animations, loading)
Duration Adapts to remaining distance Fixed regardless of state
/* Good — interruptible transition for a toggle */
.drawer {
  transform: translateX(-100%);
  transition: transform 200ms ease-out;
}
.drawer.open {
  transform: translateX(0);
}

/* Clicking again mid-animation smoothly reverses — no jank */
/* Bad — keyframe animation for interactive element */
.drawer.open {
  animation: slideIn 200ms ease-out forwards;
}

/* Closing mid-animation snaps or restarts — feels broken */

Rule: Always prefer CSS transitions for interactive elements. Reserve keyframes for one-shot sequences.

Enter Animations: Split and Stagger

Don't animate a single large container. Break content into semantic chunks and animate each individually.

Step by Step

  1. Split into logical groups (title, description, buttons)
  2. Stagger with ~100ms delay between groups
  3. For titles, consider splitting into individual words with ~80ms stagger
  4. Combine opacity, blur, and translateY for the enter effect

Code Example

// Motion (Framer Motion) — staggered enter
function PageHeader() {
  return (
    <motion.div
      initial="hidden"
      animate="visible"
      variants={{
        visible: { transition: { staggerChildren: 0.1 } },
      }}
    >
      <motion.h1
        variants={{
          hidden: { opacity: 0, y: 12, filter: "blur(4px)" },
          visible: { opacity: 1, y: 0, filter: "blur(0px)" },
        }}
      >
        Welcome
      </motion.h1>

      <motion.p
        variants={{
          hidden: { opacity: 0, y: 12, filter: "blur(4px)" },
          visible: { opacity: 1, y: 0, filter: "blur(0px)" },
        }}
      >
        A description of the page.
      </motion.p>

      <motion.div
        variants={{
          hidden: { opacity: 0, y: 12, filter: "blur(4px)" },
          visible: { opacity: 1, y: 0, filter: "blur(0px)" },
        }}
      >
        <Button>Get started</Button>
      </motion.div>
    </motion.div>
  );
}

CSS-Only Stagger

.stagger-item {
  opacity: 0;
  transform: translateY(12px);
  filter: blur(4px);
  animation: fadeInUp 400ms ease-out forwards;
}

.stagger-item:nth-child(1) { animation-delay: 0ms; }
.stagger-item:nth-child(2) { animation-delay: 100ms; }
.stagger-item:nth-child(3) { animation-delay: 200ms; }

@keyframes fadeInUp {
  to {
    opacity: 1;
    transform: translateY(0);
    filter: blur(0);
  }
}

Exit Animations

Exit animations should be softer and less attention-grabbing than enter animations. The user's focus is moving to the next thing — don't fight for attention.

// Small fixed translateY — indicates direction without drama
<motion.div
  exit={{
    opacity: 0,
    y: -12,
    filter: "blur(4px)",
    transition: { duration: 0.15, ease: "easeIn" },
  }}
>
  {content}
</motion.div>

Full Exit (When Context Matters)

// Slide fully out — use when spatial context is important
// (e.g., a card returning to a list, a drawer closing)
<motion.div
  exit={{
    opacity: 0,
    x: "-100%",
    transition: { duration: 0.2, ease: "easeIn" },
  }}
>
  {content}
</motion.div>

Good vs. Bad

/* Good — subtle exit */
.item-exit {
  opacity: 0;
  transform: translateY(-12px);
  transition: opacity 150ms ease-in, transform 150ms ease-in;
}

/* Bad — dramatic exit that steals focus */
.item-exit {
  opacity: 0;
  transform: translateY(-100%) scale(0.5);
  transition: all 400ms ease-in;
}

/* Bad — no exit animation at all (element just vanishes) */
.item-exit {
  display: none;
}

Key points:

  • Use a small fixed translateY (e.g., -12px) instead of the full container height
  • Keep some directional movement to indicate where the element went
  • Exit duration should be shorter than enter duration (150ms vs 300ms)
  • Don't remove exit animations entirely — subtle motion preserves context

Contextual Icon Animations

When icons appear or disappear contextually (on hover, on state change), animate them with opacity, scale, and blur rather than just toggling visibility.

Motion Example

import { AnimatePresence, motion } from "motion/react";

function IconButton({ isActive, icon: Icon }) {
  return (
    <button>
      <AnimatePresence mode="popLayout">
        <motion.span
          key={isActive ? "active" : "inactive"}
          initial={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
          animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
          exit={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
          transition={{ type: "spring", duration: 0.3, bounce: 0 }}
        >
          <Icon />
        </motion.span>
      </AnimatePresence>
    </button>
  );
}

CSS Transition Approach (No Motion)

If the project doesn't use Motion (Framer Motion), keep both icons in the DOM and cross-fade them with CSS transitions. Because neither icon unmounts, both enter and exit animate smoothly.

The trick: one icon is absolutely positioned on top of the other. Toggling state cross-fades them — the entering icon scales up from 0.25 while the exiting icon scales down to 0.25, both with opacity and blur.

function IconButton({ isActive, ActiveIcon, InactiveIcon }) {
  return (
    <button>
      <div className="relative">
        <div
          className={cn(
            "absolute inset-0 flex items-center justify-center",
            "transition-[opacity,filter,scale] duration-300",
            "cubic-bezier(0.2, 0, 0, 1)",
            isActive
              ? "scale-100 opacity-100 blur-0"
              : "scale-[0.25] opacity-0 blur-[4px]"
          )}
        >
          <ActiveIcon />
        </div>
        <div
          className={cn(
            "transition-[opacity,filter,scale] duration-300",
            "cubic-bezier(0.2, 0, 0, 1)",
            isActive
              ? "scale-[0.25] opacity-0 blur-[4px]"
              : "scale-100 opacity-100 blur-0"
          )}
        >
          <InactiveIcon />
        </div>
      </div>
    </button>
  );
}

The non-absolute icon (InactiveIcon) defines the layout size. The absolute icon (ActiveIcon) overlays it without affecting flow.

Choosing Between Motion and CSS

Motion (Framer Motion) CSS transitions (both icons in DOM)
Enter animation Yes Yes
Exit animation Yes (via AnimatePresence) Yes (cross-fade — icon never unmounts)
Spring physics Yes No — use cubic-bezier(0.2, 0, 0, 1) as approximation
When to use Project already uses motion/react No motion dependency, or keeping bundle small

Rule: Check the project's package.json for motion or framer-motion. If present, use the Motion approach. If not, use the CSS cross-fade pattern — don't add a dependency just for icon transitions.

When to Animate Icons

Animate Don't animate
Icons that appear on hover (action buttons) Static navigation icons
State change icons (play → pause, like → liked) Decorative icons
Icons in contextual toolbars Icons that are always visible
Loading/success state indicators Icon labels (text next to icon)

Important: Always use exactly these values for contextual icon animations — do not deviate:

  • scale: 0.251 (never use 0.5 or 0.6)
  • opacity: 01
  • filter: "blur(4px)""blur(0px)"
  • transition: { type: "spring", duration: 0.3, bounce: 0 }bounce must always be 0, never 0.1 or any other value

Scale on Press

A subtle scale-down on click gives buttons tactile feedback. Always use scale(0.96). Never use a value smaller than 0.95 — anything below feels exaggerated. Use CSS transitions for interruptibility — if the user releases mid-press, it should smoothly return.

Not every button needs this. Add a static prop to your button component that disables the scale effect when the motion would be distracting.

CSS Example

.button {
  transition-property: scale;
  transition-duration: 150ms;
  transition-timing-function: ease-out;
}

.button:active {
  scale: 0.96;
}

Tailwind Example

<button className="transition-transform duration-150 ease-out active:scale-[0.96]">
  Click me
</button>

Motion Example

<motion.button whileTap={{ scale: 0.96 }}>
  Click me
</motion.button>

Static Prop Pattern

Extract the scale class into a variable and conditionally apply it based on a static prop:

const tapScale = "active:not-disabled:scale-[0.96]";

function Button({ static: isStatic, className, children, ...props }) {
  return (
    <button
      className={cn(
        "transition-transform duration-150 ease-out",
        !isStatic && tapScale,
        className,
      )}
      {...props}
    >
      {children}
    </button>
  );
}

// Usage
<Button>Click me</Button>           {/* scales on press */}
<Button static>Submit</Button>       {/* no scale */}

Skip Animation on Page Load

Use initial={false} on AnimatePresence to prevent enter animations from firing on first render. Elements that are already in their default state shouldn't animate in on page load — only on subsequent state changes.

When It Works

// Good — icon doesn't animate in on mount, only on state change
<AnimatePresence initial={false} mode="popLayout">
  <motion.span
    key={isActive ? "active" : "inactive"}
    initial={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
    animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
    exit={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
  >
    <Icon />
  </motion.span>
</AnimatePresence>

Works well for: icon swaps, toggles, tabs, segmented controls — anything that has a default state on page load.

When It Breaks

Don't use initial={false} when the component relies on its initial prop to set up a first-time enter animation, like a staggered page hero or a loading state. In those cases, removing the initial animation skips the entire entrance.

// Bad — initial={false} would skip the staggered page enter entirely
<AnimatePresence initial={false}>
  <motion.div initial="hidden" animate="visible" variants={...}>
    ...
  </motion.div>
</AnimatePresence>

Verify the component still looks right on a full page refresh before applying this.