| name | interface-feel |
| description | Make interfaces feel responsive and alive — micro-interactions, spring physics, press states, optimistic updates, skeleton loaders, cursor feedback, sound design hints, and delight moments. Use when asked to "make this feel better", "interface feels unresponsive", "add micro-interactions", "the UI feels dead", "springy animations", "delight", "optimistic UI", "skeleton screen", "cursor feedback", "feel more like a native app", "haptic feedback patterns", or "interface lacks personality". Do NOT use for: animation performance — see fixing-motion-performance. Do NOT use for: full animation system design — see motion-design.
|
| origin | adapted:MIT © jakubkrehel |
| license | MIT © 2026 Vũ Văn Tâm |
| version | 1.0.0 |
| compatibility | React ≥ 18, Framer Motion v11. Patterns apply to any interactive UI. |
When to Use
- Use when: button clicks feel delayed or unacknowledged
- Use when: loading states show spinner but page feels frozen
- Use when: the interface feels like a form, not a product
- Use when: adding delight moments to a well-functioning but dry UI
- Do NOT use for: animation performance issues — see fixing-motion-performance
- Do NOT use for: typography and spacing polish — see impeccable
Instant Feedback — The 100ms Rule
< 16ms : imperceptible — system state change
< 100ms : instant — user perceives as immediate
< 300ms : fast — acceptable for animation
> 300ms : needs loader — user notices the wait
> 1000ms: needs progress — or user thinks it's broken
<motion.button
onPointerDown={() => setPressed(true)}
onPointerUp={() => setPressed(false)}
animate={{ scale: pressed ? 0.96 : 1 }}
transition={{ type: 'spring', stiffness: 800, damping: 25 }}
>
Submit
</motion.button>
Spring Physics
const snappy = { type: 'spring', stiffness: 700, damping: 30 };
const bouncy = { type: 'spring', stiffness: 400, damping: 10, mass: 0.8 };
const smooth = { type: 'spring', stiffness: 200, damping: 30, mass: 1.2 };
const instant = { type: 'spring', stiffness: 2000, damping: 100 };
Optimistic Updates
function LikeButton({ postId, initialLiked }) {
const [liked, setLiked] = useState(initialLiked);
const [count, setCount] = useState(post.likes);
async function handleLike() {
const prev = { liked, count };
setLiked(!liked);
setCount(c => liked ? c - 1 : c + 1);
try {
await api.toggleLike(postId);
} catch {
setLiked(prev.liked);
setCount(prev.count);
toast.error('Could not save — try again');
}
}
return (
<motion.button
onClick={handleLike}
animate={{ scale: liked ? [1, 1.3, 1] : 1 }}
transition={{ duration: 0.25 }}
>
{liked ? '❤️' : '🤍'} {count}
);
}
Skeleton Screens (vs Spinners)
function ProductCardSkeleton() {
return (
<div className="animate-pulse space-y-3">
<div className="h-48 bg-gray-200 rounded-lg" /> {/* image placeholder */}
<div className="h-4 bg-gray-200 rounded w-3/4" /> {/* title */}
<div className="h-3 bg-gray-200 rounded w-1/2" /> {/* subtitle */}
<div className="h-8 bg-gray-200 rounded w-1/3" /> {/* price */}
</div>
);
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton {
background: linear-gradient(90deg,
hsl(220 14% 92%) 25%,
hsl(220 14% 96%) 50%,
hsl(220 14% 92%) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
}
Cursor Feedback
button, [role="button"], a, label[for] { cursor: pointer; }
input[type="text"], textarea { cursor: text; }
[draggable="true"] { cursor: grab; }
[draggable="true"]:active { cursor: grabbing; }
[disabled], [aria-disabled="true"] { cursor: not-allowed; opacity: 0.5; }
Delight Moments
import confetti from 'canvas-confetti';
function SuccessModal({ isFirstOrder }) {
useEffect(() => {
if (isFirstOrder) {
confetti({ particleCount: 100, spread: 70, origin: { y: 0.6 } });
}
}, [isFirstOrder]);
}
<motion.span
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
onAnimationComplete={() => controls.start({ value: targetValue })}
>
<Counter from={0} to={targetValue} duration={1} />
</motion.span>
Empty States That Invite Action
function EmptyTaskList({ onAdd }) {
return (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col items-center gap-4 py-16 text-center"
>
<TaskEmptyIllustration className="w-40 opacity-60" />
<p className="text-muted-foreground text-sm">
No tasks yet — add your first to get started.
</p>
<Button onClick={onAdd}>Add task</Button>
</motion.div>
);
}
Anti-Fake-Pass Rules
Before claiming the interface "feels good", you MUST show:
Reference: gates/anti-fake-pass-gate.md