Micro-interactions & UX Animations
Button feedback, loading states, skeleton screens, toasts, drag-and-drop UX.
uxanimationreactfrontenddesign
# Micro-interactions & UX Animations
## Button states
```css
/* Ripple effect */
.btn { position: relative; overflow: hidden; }
.btn::after {
content: '';
position: absolute;
inset: -50%;
background: radial-gradient(circle, rgba(255,255,255,0.3) 0%, transparent 60%);
transform: scale(0);
transition: transform 0.4s, opacity 0.4s;
opacity: 0;
}
.btn:active::after { transform: scale(1); opacity: 1; transition: none; }
/* Magnetic hover */
```
```ts
// Magnetic button (follows cursor)
const btn = document.querySelector('.btn-magnetic')!
btn.addEventListener('mousemove', (e: MouseEvent) => {
const rect = btn.getBoundingClientRect()
const cx = rect.left + rect.width / 2
const cy = rect.top + rect.height / 2
const dx = (e.clientX - cx) * 0.3
const dy = (e.clientY - cy) * 0.3
;(btn as HTMLElement).style.transform = `translate(${dx}px, ${dy}px)`
})
btn.addEventListener('mouseleave', () => {
;(btn as HTMLElement).style.transform = ''
})
```
## Skeleton screens
```css
.skeleton {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.dark .skeleton {
background: linear-gradient(90deg, #2a2a2a 25%, #3a3a3a 50%, #2a2a2a 75%);
background-size: 200% 100%;
}
```
```tsx
// React skeleton component
function SkeletonCard() {
return (
<div className="rounded-lg border p-4 space-y-3">
<div className="skeleton h-4 w-3/4" />
<div className="skeleton h-3 w-full" />
<div className="skeleton h-3 w-5/6" />
<div className="skeleton h-8 w-24 mt-4" />
</div>
)
}
```
## Loading states (button)
```tsx
function SubmitButton({ loading, children }) {
return (
<button disabled={loading} className="relative">
<span className={loading ? 'opacity-0' : 'opacity-100'}>{children}</span>
{loading && (
<span className="absolute inset-0 flex items-center justify-center">
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"
className="opacity-25" fill="none" />
<path className="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
</span>
)}
</button>
)
}
```
## Toast notifications
```tsx
import { toast } from 'sonner'
// Install: npm install sonner
// In layout: <Toaster richColors position="top-right" />
tost.success('Saved!', { description: 'Your changes have been saved.' })
toast.error('Failed', { description: error.message })
toast.promise(saveData(), {
loading: 'Saving...',
success: 'Saved!',
error: 'Failed to save.',
})
toast.custom(() => <div className="flex gap-2"><span>Custom toast</span></div>)
```
## Drag and drop (dnd-kit)
```bash
npm install @dnd-kit/core @dnd-kit/sortable
```
```tsx
import { DndContext, closestCenter } from '@dnd-kit/core'
import { SortableContext, useSortable, arrayMove, verticalListSortingStrategy } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
function SortableItem({ id, children }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id })
return (
<div
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1 }}
{...attributes} {...listeners}
>
{children}
</div>
)
}
<DndContext collisionDetection={closestCenter} onDragEnd={({ active, over }) => {
if (over && active.id !== over.id) {
setItems(items => arrayMove(items, items.indexOf(active.id), items.indexOf(over.id)))
}
}}>
<SortableContext items={items} strategy={verticalListSortingStrategy}>
{items.map(id => <SortableItem key={id} id={id}>{id}</SortableItem>)}
</SortableContext>
</DndContext>
```
## Number counter animation
```ts
function animateCount(el: HTMLElement, from: number, to: number, duration = 1000) {
const start = performance.now()
const update = (now: number) => {
const p = Math.min((now - start) / duration, 1)
const ease = 1 - Math.pow(1 - p, 3) // ease-out-cubic
el.textContent = Math.round(from + (to - from) * ease).toLocaleString()
if (p < 1) requestAnimationFrame(update)
}
requestAnimationFrame(update)
}
```API: /api/skills/micro-interactions