Kinetic
CSS-first animation primitives for React — a chainable factory with four modes (transition, collapse, stagger, group).
@vitus-labs/kinetic delegates animation interpolation to the browser's CSS transition engine — transform and opacity run on the GPU compositor thread — and only handles orchestration: mount/unmount lifecycle, stagger timing, height measurement, and key-based list reconciliation. The result is GPU-composited 60/120 FPS animations at ~5 KB gzipped.
The library exposes a single chainable factory, kinetic(tag), that returns a renderable React component with chain methods attached. There are no <Transition> / <Collapse> / <Stagger> components to import — those are internal renderers that the factory dispatches to based on which chain method you called.
Installation
npm install @vitus-labs/kineticPeer dependencies: react >= 19
Try It Live
function KineticDemo() { const [show, setShow] = React.useState(true) const [mode, setMode] = React.useState('fade') const presets = { fade: { enterStyle: { opacity: 0 }, enterToStyle: { opacity: 1 }, enterTransition: 'opacity 300ms ease-out', leaveStyle: { opacity: 1 }, leaveToStyle: { opacity: 0 }, leaveTransition: 'opacity 200ms ease-in', }, slideUp: { enterStyle: { opacity: 0, transform: 'translateY(16px)' }, enterToStyle: { opacity: 1, transform: 'translateY(0)' }, enterTransition: 'all 300ms ease-out', leaveStyle: { opacity: 1, transform: 'translateY(0)' }, leaveToStyle: { opacity: 0, transform: 'translateY(16px)' }, leaveTransition: 'all 200ms ease-in', }, scaleIn: { enterStyle: { opacity: 0, transform: 'scale(0.85)' }, enterToStyle: { opacity: 1, transform: 'scale(1)' }, enterTransition: 'all 300ms ease-out', leaveStyle: { opacity: 1, transform: 'scale(1)' }, leaveToStyle: { opacity: 0, transform: 'scale(0.85)' }, leaveTransition: 'all 200ms ease-in', }, } const preset = presets[mode] const ref = React.useRef(null) const [stage, setStage] = React.useState(show ? 'entered' : 'hidden') React.useEffect(() => { if (show && (stage === 'hidden' || stage === 'leaving')) { setStage('entering') const el = ref.current if (el) { Object.assign(el.style, preset.enterStyle) el.style.transition = preset.enterTransition requestAnimationFrame(() => { requestAnimationFrame(() => { Object.assign(el.style, preset.enterToStyle) }) }) const timer = setTimeout(() => setStage('entered'), 350) return () => clearTimeout(timer) } } else if (!show && (stage === 'entered' || stage === 'entering')) { setStage('leaving') const el = ref.current if (el) { Object.assign(el.style, preset.leaveStyle) el.style.transition = preset.leaveTransition requestAnimationFrame(() => { requestAnimationFrame(() => { Object.assign(el.style, preset.leaveToStyle) }) }) const timer = setTimeout(() => setStage('hidden'), 250) return () => clearTimeout(timer) } } }, [show]) return ( <div style={{ fontFamily: 'system-ui' }}> <div style={{ display: 'flex', gap: 8, marginBottom: 16, alignItems: 'center' }}> <button onClick={() => setShow(!show)} style={{ padding: '8px 16px', borderRadius: 6, border: '1px solid #ccc', cursor: 'pointer', fontSize: 14, background: '#fff', }}> {show ? 'Hide' : 'Show'} </button> {['fade', 'slideUp', 'scaleIn'].map(m => ( <button key={m} onClick={() => setMode(m)} style={{ padding: '6px 12px', borderRadius: 6, border: 'none', cursor: 'pointer', fontSize: 13, fontWeight: 500, background: mode === m ? '#0d6efd' : '#e9ecef', color: mode === m ? '#fff' : '#333', }}> {m} </button> ))} <span style={{ fontSize: 12, color: '#888', marginLeft: 8 }}> stage: {stage} </span> </div> {stage !== 'hidden' && ( <div ref={ref} style={{ padding: 20, borderRadius: 12, background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', color: 'white', fontWeight: 500, fontSize: 15, }}> Animated content with {mode} preset </div> )} </div> ) } render(<KineticDemo />)
The demo above mirrors what kinetic's transition mode does internally (and exposes via the lifecycle callbacks documented below).
Quick Start
Create animated components at module level by chaining onto kinetic(tag):
import { kinetic, fade, slideUp } from '@vitus-labs/kinetic'
const FadeDiv = kinetic('div').preset(fade)
const SlideSection = kinetic('section').preset(slideUp)
function App() {
const [show, setShow] = useState(true)
return (
<>
<button onClick={() => setShow(!show)}>Toggle</button>
<FadeDiv show={show}>Hello, world!</FadeDiv>
</>
)
}FadeDiv is a real React component. The tag argument flows through the type system, so <FadeDiv> accepts all the HTML attributes of a <div> plus kinetic's animation props (show, appear, unmount, lifecycle callbacks).
Core Concepts
Two Animation Approaches
Kinetic supports two ways to define animations. You can mix and match both on the same component.
Style-based — inline CSSProperties objects, zero external CSS needed:
const Panel = kinetic('aside')
.enter({ opacity: 0, transform: 'translateX(-100%)' })
.enterTo({ opacity: 1, transform: 'translateX(0)' })
.enterTransition('all 300ms ease-out')
.leave({ opacity: 1, transform: 'translateX(0)' })
.leaveTo({ opacity: 0, transform: 'translateX(-100%)' })
.leaveTransition('all 200ms ease-in')Class-based — CSS class names (works with Tailwind, CSS modules, or any class-based approach):
const TailwindFade = kinetic('div')
.enterClass({
active: 'transition-opacity duration-300',
from: 'opacity-0',
to: 'opacity-100',
})
.leaveClass({
active: 'transition-opacity duration-200',
from: 'opacity-100',
to: 'opacity-0',
})Lifecycle Stages
Every transition moves through four stages:
hidden ─── show=true ──→ entering ──→ entered
│
┌── show=false ─┘
▼
leaving ──→ hidden (unmount)| Stage | Description |
|---|---|
hidden | Element is unmounted (or display: none if unmount={false}) |
entering | Enter animation is playing — start styles applied, then end styles on next frame |
entered | Enter animation complete, element is fully visible |
leaving | Leave animation is playing, then transitions back to hidden |
How a Transition Runs Internally
- Enter: On mount (or when
showflips totrue), kinetic appliesenterStyle/enterFromclass. On the next animation frame it appliesenterToStyle/enterToclass. The browser's CSS transition engine animates between the two states on the compositor thread. - Detection: Kinetic listens for
transitionend/animationendon the element and ignores bubbled events from children (event.target === element). A safetytimeout(default 5000 ms) completes the transition if the browser never fires the event. - Leave: Removes enter classes/styles, applies
leaveStyle/leaveFrom, then on the next frame appliesleaveToStyle/leaveTo. After completion the element unmounts (or hides ifunmount={false}). - Unmount behaviour: Default is
unmount={true}— element is removed from the DOM. Setunmount={false}to keep it withdisplay: none(useful for preserving scroll position or form state).
The Four Modes
kinetic(tag) returns a transition-mode component by default. Switch modes by chaining one of the mode methods.
| Mode | How to enter it | What it does |
|---|---|---|
| transition | default (no method) | Single element enter/leave with CSS transitions |
| collapse | .collapse(opts?) | Height animation — measures scrollHeight automatically |
| stagger | .stagger(opts?) | Sequential enter/exit of child elements with configurable delay |
| group | .group() | Key-based list reconciliation — adding a child triggers enter, removing triggers leave |
Transition (default)
import { kinetic, fade } from '@vitus-labs/kinetic'
const FadeDiv = kinetic('div').preset(fade)
<FadeDiv show={isOpen}>Modal content</FadeDiv>Props: show, appear?, unmount?, timeout?, lifecycle callbacks, plus all attributes of the chosen tag.
Collapse
Height-based expand/collapse. Kinetic measures the natural scrollHeight of the children, animates height from 0 to that value on enter, and back to 0 on leave. overflow: hidden is applied automatically during the animation.
const Accordion = kinetic('div').collapse()
const FancyAccordion = kinetic('section').collapse({
transition: 'height 400ms cubic-bezier(0.4, 0, 0.2, 1)',
})
<Accordion show={isExpanded}>
<p>Expandable content of any height.</p>
<p>Height is measured automatically.</p>
</Accordion>Props: show, appear?, timeout?, transition?, lifecycle callbacks.
Stagger
Sequential enter / exit for child elements. Each child's animation starts interval ms after the previous one. The component itself follows the chained preset / inline config — children inherit the same animation but with cascading delays.
import { kinetic, slideUp } from '@vitus-labs/kinetic'
const StaggerList = kinetic('ul').preset(slideUp).stagger({ interval: 75 })
<StaggerList show={isVisible}>
<li key="1">Item 1</li>
<li key="2">Item 2</li>
<li key="3">Item 3</li>
</StaggerList>Props: show, appear?, interval? (default 50 ms), reverseLeave? (reverses stagger order on leave), timeout?, lifecycle callbacks.
Group
Key-based enter / exit for dynamic lists. There is no show prop — adding a child to the array triggers its enter animation, removing one triggers leave + unmount. Each child must have a stable key.
import { kinetic, fade } from '@vitus-labs/kinetic'
const AnimatedList = kinetic('ul').preset(fade).group()
<AnimatedList>
{items.map(item => <li key={item.id}>{item.text}</li>)}
</AnimatedList>Props: appear?, timeout?, lifecycle callbacks. The appear flag controls whether the initial set of children animates in on mount (default false).
Inline Configuration
You can build animations without a preset by using the style or class chain methods directly:
const SlidePanel = kinetic('aside')
.enter({ opacity: 0, transform: 'translateX(-100%)' })
.enterTo({ opacity: 1, transform: 'translateX(0)' })
.enterTransition('all 300ms ease-out')
.leave({ opacity: 1, transform: 'translateX(0)' })
.leaveTo({ opacity: 0, transform: 'translateX(-100%)' })
.leaveTransition('all 200ms ease-in')Behaviour Defaults via .config()
Mode-agnostic and mode-specific defaults are configured via .config(opts). The accepted shape narrows based on the mode you're in.
// Transition mode: appear, unmount, timeout
const Tooltip = kinetic('div')
.preset(fade)
.config({ appear: true, unmount: false, timeout: 8000 })
// Collapse mode: appear, timeout, transition
const Accordion = kinetic('div').collapse().config({
transition: 'height 250ms ease-in-out',
})
// Stagger mode: appear, timeout, interval, reverseLeave
const Menu = kinetic('ul').preset(slideUp).stagger().config({
interval: 60,
reverseLeave: true,
})Defaults can also be overridden per render via the same-named props (<FadeDiv timeout={10000} />).
Lifecycle Callbacks
Attach callbacks via .on() (component-level default) or as props (per-render override):
const Card = kinetic('div').preset(fade).on({
onEnter: () => console.log('entering'),
onAfterEnter: () => console.log('entered'),
onLeave: () => console.log('leaving'),
onAfterLeave: () => console.log('left'),
})
<Card
show={isOpen}
onAfterEnter={() => trackImpression()} // overrides on() for this instance
>
Content
</Card>| Callback | When it fires |
|---|---|
onEnter | Enter phase begins (start styles applied) |
onAfterEnter | Enter animation completes |
onLeave | Leave phase begins |
onAfterLeave | Leave animation completes (just before unmount) |
In prefers-reduced-motion: reduce mode, the visible animation is skipped but callbacks still fire — your tracking and side-effects keep working.
Type Inference
The tag argument flows through the chain so the resulting component types its HTML attributes correctly:
const FadeDiv = kinetic('div').preset(fade)
<FadeDiv show className="x" onClick={fn} /> // div attributes ✓
const FadeInput = kinetic('input').preset(fade)
<FadeInput show type="text" value="x" /> // input attributes ✓
const FadeCustom = kinetic(MyComponent).preset(fade)
<FadeCustom show customProp="x" /> // MyComponent props ✓When you switch modes the prop shape narrows automatically — e.g. .group() removes the show prop, .stagger() requires children to be an array.
Accessibility
Kinetic detects prefers-reduced-motion: reduce via the useReducedMotion hook from @vitus-labs/hooks. When reduced motion is requested the visual animation is skipped instantly, but lifecycle callbacks still fire so analytics, focus management, and other side-effects remain consistent. No configuration needed.
Composition with Rocketstyle
Kinetic and rocketstyle compose naturally — pass the rocketstyle component as the tag:
import rocketstyle from '@vitus-labs/rocketstyle'
import { kinetic, fade } from '@vitus-labs/kinetic'
const Button = rocketstyle()({ component: 'button', name: 'Button' })
.theme({ primaryColor: 'blue' })
const AnimatedButton = kinetic(Button).preset(fade)
<AnimatedButton show={isVisible} primary size="large">
Click me
</AnimatedButton>AnimatedButton accepts both rocketstyle dimension props (primary, size) and kinetic animation props (show).
Built-in Presets
Six presets ship in the core package:
import {
fade,
scaleIn,
slideUp,
slideDown,
slideLeft,
slideRight,
} from '@vitus-labs/kinetic'
const Panel = kinetic('aside').preset(slideRight)For 122 presets plus factory generators (createSlide, createScale, createRotate, createFlip, createBounce) and composition helpers, install @vitus-labs/kinetic-presets.
Hooks
Two low-level hooks are exported for cases the chain factory doesn't cover:
import { useTransitionState, useAnimationEnd } from '@vitus-labs/kinetic'useTransitionState— the state machine that powers transition mode. Returns{ stage, ref, shouldMount, complete }. Drive enter/leave manually when you need behaviour the chain factory doesn't expose (e.g. coordinating animations with imperative APIs).useAnimationEnd— wrapstransitionend/animationenddetection with a safety timeout fallback. Filters bubbled events from children automatically.
See the API Reference for full hook signatures.
Exports
import {
// Factory + presets
kinetic,
presets,
fade,
scaleIn,
slideUp,
slideDown,
slideLeft,
slideRight,
// Hooks
useTransitionState,
useAnimationEnd,
} from '@vitus-labs/kinetic'
import type {
// Component types
KineticComponent,
// Preset types
Preset,
// Animation prop shapes
ClassTransitionProps,
StyleTransitionProps,
TransitionCallbacks,
TransitionStage,
TransitionStateResult,
// Hook return types
UseTransitionState,
UseAnimationEnd,
} from '@vitus-labs/kinetic'There is no Transition, Collapse, Stagger, or TransitionGroup component to import. Those are internal renderers — the public surface is kinetic(tag).<chain> and the hooks above.