Deft
Ask AI

Search

Search skills, authors and topics

jakubkrehel / skills

better-ui

Design engineering principles for making interfaces feel polished. Use when building UI components, reviewing frontend code, implementing animations, hover states, shadows, borders, micro-interactions, enter/exit animations, or any visual detail work. Triggers on UI polish, design details, "make it feel better", "feels off", stagger animations, border radius, optical alignment, image outlines, box shadows.

Install

npx skills add https://github.com/jakubkrehel/skills --skill better-ui

Icon transitions

Cross-fade recipes for an icon that swaps on a state change, with and without a motion library. Icon weight, color and direction live in icons.md [blocked].

Which library

Check the project's package.json. Import from "motion/react" when motion is installed, or "framer-motion" when that is. Where both exist, follow the imports the component or its nearest peers already use. Where neither is present, use the CSS cross-fade and never add a dependency just for icon transitions.

Motion

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

initial={false} keeps the default icon from animating in on mount. useReducedMotion() drops scale and blur under the preference, so only the opacity cross-fade runs.

CSS, both icons in the DOM

Both icons stay mounted in one grid cell, so enter and exit both animate and the larger icon sets the size. The motion-reduce: classes leave only the opacity fade under reduced motion.

tsx
const layer = cn(  "[grid-area:1/1] transition-[opacity,filter,scale] duration-300",  "ease-[cubic-bezier(0.2,0,0,1)]",  "motion-reduce:scale-100 motion-reduce:blur-[0px]");const shown = "scale-100 opacity-100 blur-[0px]";const hidden = "scale-[0.25] opacity-0 blur-[4px]";
function IconButton({ isActive, ActiveIcon, InactiveIcon }) {  return (    <button>      <span className="grid place-items-center">        <span className={cn(layer, isActive ? shown : hidden)}>          <ActiveIcon />        </span>        <span className={cn(layer, isActive ? hidden : shown)}>          <InactiveIcon />        </span>      </span>    </button>  );}

Tailwind 4's scale-* utilities write the scale property, so transition-[...scale] animates them. Tailwind 3's write transform, so name transform in the transition instead.

Which icons animate

AnimateDon't animate
Infrequent state swaps such as play to pause, like to liked or copy to copiedHover-revealed actions in list rows
Icons in a toolbar that appears on selectionTab and navigation icon swaps
Loading and success indicatorsDecorative or always-visible icons