Vitus Labs
Kinetic

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/kinetic

Peer 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)
StageDescription
hiddenElement is unmounted (or display: none if unmount={false})
enteringEnter animation is playing — start styles applied, then end styles on next frame
enteredEnter animation complete, element is fully visible
leavingLeave animation is playing, then transitions back to hidden

How a Transition Runs Internally

  1. Enter: On mount (or when show flips to true), kinetic applies enterStyle / enterFrom class. On the next animation frame it applies enterToStyle / enterTo class. The browser's CSS transition engine animates between the two states on the compositor thread.
  2. Detection: Kinetic listens for transitionend / animationend on the element and ignores bubbled events from children (event.target === element). A safety timeout (default 5000 ms) completes the transition if the browser never fires the event.
  3. Leave: Removes enter classes/styles, applies leaveStyle / leaveFrom, then on the next frame applies leaveToStyle / leaveTo. After completion the element unmounts (or hides if unmount={false}).
  4. Unmount behaviour: Default is unmount={true} — element is removed from the DOM. Set unmount={false} to keep it with display: 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.

ModeHow to enter itWhat it does
transitiondefault (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>
CallbackWhen it fires
onEnterEnter phase begins (start styles applied)
onAfterEnterEnter animation completes
onLeaveLeave phase begins
onAfterLeaveLeave 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 — wraps transitionend / animationend detection 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.

On this page