Imported from vev-design/kopla-kits (
kits/_base/src/motion/AGENTS.md). Install upstream withnpx skills add vev-design/kopla-kits --skill motion. Copyright stays with the author.
Motion wrappers
Composable wrapper components that add animation behaviour to any child element, backed by CSS in motion.css — no JavaScript animation library. Motion is always applied by wrapping — never baked into base components.
The wrappers carry no hydration cost: a section using them still publishes as static HTML. The reveal is a TIME-PLAYED entrance triggered on viewport entry — animation-trigger natively where the browser has it (Chrome 151+), and a tiny IntersectionObserver the page build injects everywhere else (it feature-detects, so it's a no-op where the native path runs). That script belongs to the HOST's page build, not to this folder — from a section author's point of view the wrappers are still just CSS classes. Users with reduced motion, and any surface without the trigger or the script, see the content in its fully-visible resting state.
Wrappers
| Wrapper | Trigger | Effect |
|---|---|---|
Reveal |
Viewport entry | Plays a 0.8s fade from opacity: 0 + translate: 0 24px to rest, once, when the element is ~18% into the viewport |
Stagger |
Viewport entry | Same fade, each child's animation-delay a beat later (120ms default) — a visible left-to-right / top-to-bottom wave |
Hover |
Mouse hover | Lifts translate: 0 -2px, scales down on press |
Conventions
- Every wrapper renders a plain
divcarrying a class frommotion.css(kit-reveal,kit-hover) — pass any HTML prop, including TailwindclassName. Revealtakesdelay(seconds) to hold an element back behind a sibling;Staggerderives the same per-child delay fromstep.- Durations, easings and keyframes live in
motion.css. Update them there when the spec's motion timing changes. - Every animation's resting/fallback state is the fully-visible layout. Never author a state that needs JS or animation support to become visible — nothing in
motion.csshides content on its own, and.kit-hidden/.kit-inare applied only by the host's reveal script, never authored. - The class names
kit-reveal,kit-hiddenandkit-inare a KEEP-IN-SYNC contract with the host's injected reveal script (kopla'sbuild-runner/reveal-enhance.mjs). Rename them in both places or not at all.
Adding a new wrapper
- Add the class +
@keyframestomotion.css, inside the@media (prefers-reduced-motion: no-preference)guard — and if it should trigger on viewport entry, wire it through the same.kit-hidden/.kit-inpair the reveal uses. - Create
<Name>.tsxin this folder rendering a plaindivwith the class; acceptComponentPropsWithoutRef<'div'>when the wrapper maps directly to a single element. - Re-export from
index.ts.
Scroll-SCRUBBED motion (parallax, progress-tracked effects) still uses animation-timeline: view() directly — see the deck and annual-report kits' globals for the pattern. Mind its gotcha: view() reads the nearest ancestor scroll container, and overflow: hidden creates one that never scrolls, freezing the animation on its first frame. Crop with overflow-clip, never overflow-hidden, anywhere scrubbed motion can sit inside. (The reveal wrappers no longer have this constraint — a triggered animation doesn't read scroll containers the same way.)
Number rollups are the one motion pattern that genuinely needs JS — use useCountUp() from @/lib/count-up (not this folder) and tag the section @hydrate.