Install
$ agentstack add skill-eliasoulkadi-shokunin-motion-craft ✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
About
Motion Craft
Animations that communicate, not decorate. Every frame must earn its place.
Inspired by Emil Kowalski (Linear, Sonner, Vaul), Paul Lewis (Google Chrome), and the CSS Animation Working Group.
Workflow
The Animation Decision Framework
Before writing any animation code, answer these four questions in order.
1. Should this animate at all?
| Frequency | Decision | Reason | |-----------|----------|--------| | 100+ times/day (keyboard shortcuts, command palette, search toggle) | No animation. Ever. | Animation makes frequent actions feel delayed. Raycast has no open/close animation. Optimal. | | Tens of times/day (hover effects, list navigation) | Remove or drastically reduce | Users notice animation latency on repeated interactions | | Occasional (modals, drawers, toasts) | Standard animation | The right place for motion | | Rare / first-time (onboarding, confirmation, celebration) | Add delight | These moments earn longer, richer animations |
Never animate keyboard-initiated actions. These repeat hundreds of times daily.
2. What is the purpose?
Every animation must have a clear answer to "why does this animate?"
| Purpose | Example | Valid? | |---------|---------|--------| | Spatial consistency | Toast enters and exits from the same direction | ? | | State indication | Button morphs to show "sent" | ? | | Feedback | Button scales down on press, confirming the interface heard the user | ? | | Preventing jarring changes | Elements fading in instead of appearing abruptly | ? | | Explanation | Marketing animation showing how a feature works | ? | | "It looks cool" AND the user sees it often | Gratuitous motion | ? |
3. What easing should it use?
| Scenario | Easing | CSS | |----------|--------|-----| | Element entering | ease-out (starts fast, feels responsive) | cubic-bezier(0, 0, 0.2, 1) | | Element exiting | ease-in (starts slow, grabs attention briefly then accelerates away) | cubic-bezier(0.4, 0, 1, 1) | | On-screen movement / morphing | ease-in-out | cubic-bezier(0.4, 0, 0.2, 1) | | Hover / color change | ease | cubic-bezier(0.25, 0.1, 0.25, 1) | | Constant motion (marquee, progress bar) | linear | cubic-bezier(0, 0, 1, 1) | | UI interactions (buttons, tooltips, dropdowns) | strong ease-out | cubic-bezier(0.23, 1, 0.32, 1) |
Critical: never use ease-in for UI entering animations. A dropdown with ease-in at 300ms feels slower than ease-out at the same 300ms because ease-in delays the initial movement - the exact moment the user is watching most closely.
4. How fast should it be?
| Element | Duration | Easing | |---------|----------|--------| | Button press feedback (scale) | 100-160ms | cubic-bezier(0.23, 1, 0.32, 1) | | Tooltip enter / exit | 125-200ms | cubic-bezier(0.23, 1, 0.32, 1) | | Dropdown / select / popover | 150-250ms | cubic-bezier(0.23, 1, 0.32, 1) | | Hover transform (scale, lift) | 200ms | cubic-bezier(0.23, 1, 0.32, 1) | | Modal / dialog enter | 200-300ms | cubic-bezier(0, 0, 0.2, 1) | | Modal / dialog exit | 150-200ms | cubic-bezier(0.4, 0, 1, 1) | | Drawer / sheet enter | 200-400ms | cubic-bezier(0.32, 0.72, 0, 1) (iOS-like) | | Toast enter | 300-400ms | cubic-bezier(0.23, 1, 0.32, 1) | | Toast exit | 200-300ms | cubic-bezier(0.4, 0, 1, 1) | | Page transition | 300-400ms | cubic-bezier(0.4, 0, 0.2, 1) | | Scroll reveal (fade + slide up) | 400-800ms | cubic-bezier(0.16, 1, 0.3, 1) | | Loading shimmer | 1000-1500ms (infinite) | linear | | Hold-to-delete (press) | 2000ms | linear (deliberate) | | Hold-to-delete (release) | 200ms | cubic-bezier(0.23, 1, 0.32, 1) (snappy) | | Marketing / explanatory | 800-3000ms | variable, depends on narrative |
Rule: UI animations should stay under 300ms. A 180ms dropdown feels noticeably more responsive than a 400ms one.
Custom easing curves
The built-in CSS ease / ease-in / ease-out are too weak. Use these stronger custom variants:
:root {
--ease-out-strong: cubic-bezier(0.23, 1, 0.32, 1); /* UI interactions, Emil's signature */
--ease-in-out-strong: cubic-bezier(0.77, 0, 0.175, 1); /* On-screen movement */
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1); /* iOS-like drawer */
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); /* Slow reveals, dramatic */
--ease-out-quint: cubic-bezier(0.22, 1, 0.36, 1); /* Alternative strong ease-out */
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); /* Slight overshoot, sparingly */
}
Sources: easing.dev, easings.co
The 60fps Golden Rule
16.67ms per frame. Verify with Chrome DevTools Performance tab. Only animate transform and opacity.
| Property | GPU composite? | Triggers | |----------|:--:|----------| | transform (translate, scale, rotate) | ? Yes | Composite only | | opacity | ? Yes | Composite only | | filter | ?? Sometimes | Paint + Composite | | clip-path | ?? Sometimes | Paint + Composite | | width, height, top, left, margin, padding, border-width | ? No | Layout + Paint + Composite | | box-shadow | ? No | Paint + Composite | | color, background-color | ? No | Paint only |
Review Format (Required)
When reviewing UI animation code, you MUST use a markdown table with Before | After | Why columns. Do NOT use a list with "Before:" and "After:" on separate lines.
Correct format:
| Before | After | Why | |--------|-------|-----| | transition: all 300ms | transition: transform 200ms ease-out | Specify exact properties; avoid all which triggers layout recalculation | | transform: scale(0) | transform: scale(0.95); opacity: 0 | Nothing in the real world appears from nothing | | ease-in on dropdown | cubic-bezier(0.23, 1, 0.32, 1) | ease-in starts slow - feels sluggish. Strong ease-out gives instant feedback | | No :active state on button | transform: scale(0.97) on :active | Buttons must feel responsive to press | | transform-origin: center on popover | transform-origin: var(--radix-popover-content-transform-origin) | Popovers should scale from their trigger. Modals stay centered. | | transition: 300ms without prefers-reduced-motion | @media (prefers-reduced-motion: reduce) { transition-duration: 0.01ms } | WCAG 2.1 requires reduced-motion support | | animate={{ x: 100 }} in Framer Motion | animate={{ transform: "translateX(100px)" }} | Framer Motion x/y are NOT hardware-accelerated. Use transform string for GPU. |
Wrong format (never do this):
Before: transition: all 300ms
After: transition: transform 200ms ease-out
CSS Transitions (preferred for UI)
CSS transitions are interruptible - they can be retargeted mid-animation. Keyframes restart from zero. For any interaction that can be triggered rapidly (toasts, toggles, dropdowns), transitions produce smoother results.
.button {
transition: transform 160ms ease-out;
}
.button:active {
transform: scale(0.97);
}
.tooltip {
transition: transform 125ms ease-out, opacity 125ms ease-out;
transform-origin: var(--transform-origin);
}
Asymmetric enter / exit timing
Enter can be slower (user is deciding), exit must be fast (system responding):
.overlay {
transition: clip-path 200ms ease-out; /* release: fast */
}
.button:active .overlay {
transition: clip-path 2s linear; /* press: slow and deliberate */
}
Tooltips: skip delay on subsequent hovers
.tooltip {
transition: transform 125ms ease-out, opacity 125ms ease-out;
transform-origin: var(--transform-origin);
}
.tooltip[data-instant] {
transition-duration: 0ms;
}
Enter states with @starting-style (Chrome-only as of 2026, not yet Baseline)
.toast {
opacity: 1;
transform: translateY(0);
transition: opacity 400ms ease-out, transform 400ms ease-out;
@starting-style {
opacity: 0;
transform: translateY(100%);
}
}
> Note: @starting-style and scroll-driven animations (animation-timeline: view(), animation-timeline: scroll()) are Chrome-only as of 2026. Use JavaScript fallbacks for cross-browser support.
Hold-to-delete pattern
.overlay {
clip-path: inset(0 100% 0 0);
transition: clip-path 200ms ease-out; /* release: fast */
}
.button:active .overlay {
clip-path: inset(0 0 0 0);
transition: clip-path 2s linear; /* press: slow and deliberate */
}
BUTTONS - Tactile Feedback
Every pressable element needs physical response.
.button {
transition: transform 160ms cubic-bezier(0.23, 1, 0.32, 1);
}
.button:active {
transform: scale(0.97);
}
The scale must be subtle (0.95-0.98). Applied to all pressable elements - buttons, cards, list items, tabs.
POPOVERS - Origin-Aware
Popovers scale from their trigger, not center. Modals stay centered.
/* Radix UI */
.popover {
transform-origin: var(--radix-popover-content-transform-origin);
}
/* Base UI */
.popover {
transform-origin: var(--transform-origin);
}
/* Modals (always centered) */
.modal {
transform-origin: center;
}
Whether the user notices individually does not matter. In the aggregate, unseen details compound.
Stagger Animations
When multiple elements enter together, stagger their appearance.
.item {
opacity: 0;
transform: translateY(8px);
animation: fadeIn 300ms ease-out forwards;
}
.item:nth-child(1) { animation-delay: 0ms; }
.item:nth-child(2) { animation-delay: 50ms; }
.item:nth-child(3) { animation-delay: 100ms; }
.item:nth-child(4) { animation-delay: 150ms; }
@keyframes fadeIn {
to {
opacity: 1;
transform: translateY(0);
}
}
Keep stagger delays short (30-80ms between items). Never block interaction while stagger plays.
CSS Animations (keyframes)
Use for predetermined, non-dynamic animations. Keyframes restart from zero on interruption - avoid for UI.
@keyframes enter-right {
from { opacity: 0; transform: translateX(16px); }
to { opacity: 1; transform: translateX(0); }
}
.enter {
animation: enter-right 300ms ease-out forwards;
}
Spring Animations
Springs feel more natural than duration-based animations because they simulate real physics. They maintain velocity when interrupted.
When to use springs
- Drag interactions with momentum
- Elements that feel "alive" (Apple Dynamic Island style)
- Gestures that can be interrupted mid-animation
- Decorative mouse-tracking interactions
Apple approach (recommended - easier to reason about)
{ type: "spring", duration: 0.5, bounce: 0.2 }
Traditional physics (more control)
{ type: "spring", stiffness: 100, damping: 10, mass: 1 }
Keep bounce subtle (0.1-0.3). Avoid bounce in most UI. Use for drag-to-dismiss and playful interactions only.
Spring-based mouse interactions
import { useSpring } from 'framer-motion'
const springX = useSpring(mouseX * 0.1, { stiffness: 100, damping: 10 })
Without spring: feels artificial, instant. With spring: feels natural, has momentum.
WEB ANIMATIONS API (WAAPI)
Programmatic CSS animations. Hardware-accelerated, interruptible, no library needed.
const element = document.querySelector('.card')
const animation = element.animate([
{ transform: 'scale(1)', opacity: 1 },
{ transform: 'scale(0.95)', opacity: 0.7 },
], {
duration: 200,
easing: 'cubic-bezier(0.23, 1, 0.32, 1)',
fill: 'both',
})
animation.pause()
animation.play()
animation.reverse()
animation.finish()
animation.finished.then(() => console.log('done'))
WAAPI beats Framer Motion under heavy load - it runs off the main thread. Use for dynamic, interruptible animations where CSS keyframes fall short.
SCROLL-DRIVEN ANIMATIONS
Browser support note: Scroll-driven animations (animation-timeline: view() and scroll()) are Chrome/Edge-only as of 2026, not yet Baseline. Provide JS fallbacks via IntersectionObserver for Firefox/Safari.
ViewTimeline (element enters/leaves viewport)
@keyframes fade-up {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.reveal {
animation: fade-up 600ms linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
ScrollTimeline (page scroll position)
@keyframes shrink {
from { height: 80px; }
to { height: 50px; }
}
.site-header {
animation: shrink 300ms linear both;
animation-timeline: scroll();
animation-range: 0 200px;
}
Scroll-linked parallax (vanilla JS)
const layers = document.querySelectorAll('[data-parallax]')
window.addEventListener('scroll', () => {
const scrollY = window.scrollY
layers.forEach(layer => {
const speed = parseFloat(layer.dataset.parallax) || 0.3
layer.style.transform = `translateY(${scrollY * speed}px)`
})
})
FLIP Technique
For layout animations - animating elements that change position/size.
function animateLayout(element, callback) {
const first = element.getBoundingClientRect()
callback()
const last = element.getBoundingClientRect()
const dx = first.left - last.left
const dy = first.top - last.top
const dw = first.width / last.width
const dh = first.height / last.height
element.animate([
{ transform: `translate(${dx}px, ${dy}px) scale(${dw}, ${dh})` },
{ transform: 'translate(0, 0) scale(1, 1)' },
], {
duration: 300,
easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
})
}
CLIP-PATH for Animation
One of the most powerful animation tools in CSS. Hardware-accelerated.
.overlay {
clip-path: inset(0 100% 0 0);
transition: clip-path 200ms ease-out;
}
.visible {
clip-path: inset(0 0 0 0);
}
Use for: hold-to-delete, image reveals on scroll, comparison sliders, seamless color transitions on tabs.
PERCEIVED PERFORMANCE
Speed in animation directly affects how users perceive your app:
| Effect | Mechanism | |--------|-----------| | Fast-spinning spinner | Makes loading feel faster (same load time, different perception) | | 180ms select vs 400ms select | Shorter animation = perceived responsiveness jump | | Instant tooltips after first one | Skip delay + animation on subsequent tooltips | | Fast exit animations | Exit must be faster than enter (200ms enter, 150ms exit) | | ease-out vs ease-in at same 200ms | ease-out feels faster because user sees immediate movement |
PERFORMANCE RULES
Only animate transform and opacity
These skip layout and paint, running on GPU.
CSS variables are inheritable - avoid for animation
// Bad: triggers recalculation on all children
element.style.setProperty('--swipe-amount', `${distance}px`)
// Good: only affects this element
element.style.transform = `translateY(${distance}px)`
CSS animations beat JS under load
When the browser is busy loading a new page, Framer Motion animations (using requestAnimationFrame) drop frames. CSS animations remain smooth - they run off the main thread.
Framer Motion: use transform string, not shorthand
// NOT hardware accelerated (requestAnimationFrame on main thread)
// Hardware accelerated (GPU, stays smooth even under load)
will-change: use sparingly
.will-animate {
will-change: transform, opacity;
}
Each will-change creates a new compositor layer, consuming GPU memory. Apply before animation, remove after.
ACCESSIBILITY
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
WCAG 2.1 Success Criterion 2.3.3 - Animation from Interactions.
For fine-grained control:
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const userPrefersReduced = localStorage.getItem('reduced-motion') === 'true'
co
…
## Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- **Author:** [EliasOulkadi](https://github.com/EliasOulkadi)
- **Source:** [EliasOulkadi/shokunin](https://github.com/EliasOulkadi/shokunin)
- **License:** MIT
- **Homepage:** https://eliasoulkadi.github.io/shokunin/
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.