Deft
Ask AI

Search

Search skills, authors and topics

jakubkrehel / make-interfaces-feel-better

make-interfaces-feel-better

Design engineering principles for making interfaces feel polished, with focus on micro-interactions, typography, and visual details.

Install

npx skills add https://github.com/jakubkrehel/make-interfaces-feel-better --skill make-interfaces-feel-better

Animations

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

Interruptible Animations

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

CSS Transitions vs. Keyframes

CSS TransitionsCSS Keyframe Animations
BehaviorInterpolate toward latest stateRun on a fixed timeline
InterruptibleYes — retargets mid-animationNo — restarts from beginning
Use forInteractive state changes (hover, toggle, open/close)Staged sequences that run once (enter animations, loading)
DurationFixed; retargets the value mid-flight, not the timelineFixed timeline, restarts from the beginning
css
/* 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 */
css
/* 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

Use this pattern for infrequent staged entrances where sequence helps communicate hierarchy, such as the first load of a page hero, success state, or empty state. Break a large container into semantic chunks and animate each individually. Do not stagger routine interactions such as row hovers, keystrokes, or repeated tab changes.

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

tsx
// Motion (Framer Motion) — staggered enterfunction 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

css
.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.

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

Full Exit (When Context Matters)

tsx
// 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: "easeOut" },  }}>  {content}</motion.div>

Good vs. Bad

css
/* Good — subtle exit */.item-exit {  opacity: 0;  transform: translateY(-12px);  transition: opacity 150ms ease-out, transform 150ms ease-out;}
/* Bad — dramatic exit that steals focus */.item-exit {  opacity: 0;  transform: translateY(-100%) scale(0.5);  transition: all 400ms ease-out;}
/* Sometimes correct — remove immediately when motion adds no context */.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)
  • Use a subtle exit when it preserves spatial context. Remove immediately when motion adds no information, the interaction repeats frequently, or reduced motion is requested.

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

This example uses the motion package. If the project instead has framer-motion, import the same APIs from "framer-motion"; never mix an installed package with the other package's import path.

tsx
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.

tsx
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",            "ease-[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",            "ease-[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 animationYesYes
Exit animationYes (via AnimatePresence)Yes (cross-fade — icon never unmounts)
Spring physicsYesNo — use cubic-bezier(0.2, 0, 0, 1) as approximation
When to useProject already uses motion or framer-motionNo motion dependency, or keeping bundle small

Rule: Check the project's package.json. Import from "motion/react" when motion is installed, or from "framer-motion" when framer-motion is installed. If both exist, follow the imports already used by the component or its nearest peers. If neither is present, use the CSS cross-fade pattern — don't add a dependency just for icon transitions.

When to Animate Icons

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

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

  • scale: 0.25 → 1 (never use 0.5 or 0.6)
  • opacity: 0 → 1
  • 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

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

Tailwind Example

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

Motion Example

tsx
<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:

tsx
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

tsx
// 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.

tsx
// 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.

Motion Restraint

Motion is a budget, not a garnish:

  • No custom animation on high-frequency interactions. Repeated interactions get instant feedback or a minimal opacity or background-color transition at ≤150ms.
  • Motion is never the only feedback channel. Every animated state change also needs a static cue such as color, icon, or label.
  • Brief and precise beats prominent. If a shorter, smaller animation communicates the same thing, use it.
  • Honor reduced-motion preferences. Preserve the static cue and remove unnecessary movement.
css
/* Good: high-frequency hover gets a minimal transition */.row:hover {  background-color: var(--surface-hover);  transition: background-color 100ms ease-out;}
/* Bad: every hover replays a full entrance */.row:hover .row-icon {  animation: bounceIn 500ms;}