pyreon

@pyreon/kinetic is a CSS-transition animation engine for Pyreon. A single factory — kinetic(tag) — produces a renderable, chainable component that animates an element as it enters and leaves the DOM. There are no JavaScript animation loops: kinetic applies CSS classes or inline styles at the right frames of the transition lifecycle and lets the browser do the work.

@pyreon/kineticstable

Installation

npm install @pyreon/kinetic
bun add @pyreon/kinetic
pnpm add @pyreon/kinetic
yarn add @pyreon/kinetic

Quick Start

kinetic(tag) returns a component. Configure it by chaining, then render it with a reactive show accessor:

import { kinetic, fade } from '@pyreon/kinetic'
import { signal } from '@pyreon/reactivity'

// Define once — chaining is immutable, so FadeBox is reusable.
const FadeBox = kinetic('div').preset(fade)

function App() {
  const visible = signal(true)

  return (
    <div>
      <button onClick={() => visible.update((v) => !v)}>Toggle</button>
      <FadeBox show={() => visible()}>
        <p>I fade in and out.</p>
      </FadeBox>
    </div>
  )
}
Animated transition — opacity + transform

Why Kinetic?

Most animation libraries ship a keyframe runtime, a timeline scheduler, or a requestAnimationFrame loop. Kinetic ships none of that — it leans entirely on native CSS transitions:

// ❌ JS-driven animation — a rAF loop ticking every frame, allocating per instance
let raf
function animate() {
  el.style.opacity = String(progress)
  raf = requestAnimationFrame(animate)
}

// ✅ kinetic — apply the start + end CSS, let the compositor interpolate
const FadeBox = kinetic('div').preset(fade)
;<FadeBox show={() => visible()}></FadeBox>

What kinetic does instead:

  • Drives visibility from a reactive show: () => boolean accessor — flip the signal, kinetic runs the enter or leave phase.

  • Applies your enter/leave classes (utility-class workflows) or inline style objects (zero-CSS workflows) across a precise double-requestAnimationFrame so the transition actually triggers.

  • Listens for transitionend / animationend to know when the animation is done, with a safety timeout so a never-firing event can't strand the element forever.

  • Unmounts (or hides) the element after the leave phase completes — no manual cleanup.

  • Renders structural content on the server even when show is false at SSR time (see SSR Behavior).

The Four Modes

kinetic(tag) starts in transition mode. Chain methods switch it into one of the other three. Each mode renders tag as the container and accepts a different prop set.

ModeCreated viaAnimatesRequired props
transitionkinetic('div') (default)A single element entering/leavingshow, children (one child)
collapse.collapse()Height between 0 and autoshow, children
stagger.stagger()A list of children, sequencedshow, children (array)
group.group()A keyed list — adds/removes animatechildren (keyed array) — no show
import { kinetic, fade, slideUp } from '@pyreon/kinetic'

const FadeBox = kinetic('div').preset(fade) //              → transition
const Accordion = kinetic('div').collapse() //              → collapse
const StaggerList = kinetic('ul').preset(slideUp).stagger() // → stagger
const AnimatedList = kinetic('ul').preset(fade).group() //   → group

Transition Mode

The default mode wraps a single child and runs an enter animation when show becomes true, a leave animation when it becomes false.

Style-based transitions

Inline style objects are the zero-CSS path — no stylesheet or utility classes required. Provide a start state, an end state, and a CSS transition shorthand for each phase:

const FadeBox = kinetic('div')
  .enter({ opacity: 0 })
  .enterTo({ opacity: 1 })
  .enterTransition('opacity 300ms ease-out')
  .leave({ opacity: 1 })
  .leaveTo({ opacity: 0 })
  .leaveTransition('opacity 200ms ease-in')

;<FadeBox show={() => visible()}>
  <div>Fading content</div>
</FadeBox>

The chain methods map directly onto the transition fields:

MethodField setApplied
.enter(styles)enterStyleFirst frame of the enter phase
.enterTo(styles)enterToStyleSecond frame of enter, kept until complete
.enterTransition(value)enterTransitionCSS transition shorthand during enter
.leave(styles)leaveStyleFirst frame of the leave phase
.leaveTo(styles)leaveToStyleSecond frame of leave, kept until complete
.leaveTransition(value)leaveTransitionCSS transition shorthand during leave

Class-based transitions

For utility-class CSS (Tailwind, UnoCSS, or hand-written classes), use .enterClass() / .leaveClass(). Each takes { active, from, to }:

const FadeBox = 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',
  })

;<FadeBox show={() => visible()}>
  <div>Animated content</div>
</FadeBox>

The { active, from, to } shape maps onto the underlying class fields:

.enterClass() keyFieldWhen it is applied
activeenterWhole enter phase
fromenterFromFirst frame, removed on the next frame
toenterToSecond frame, kept until the animation completes

.leaveClass() maps active/from/to onto leave / leaveFrom / leaveTo the same way.

Presets

.preset(p) merges a preset's fields into the config in one call. It is equivalent to spreading the preset's enterStyle/enterToStyle/enterTransition/… onto the chain, so you can override individual phases afterward:

import { kinetic, fade, slideUp } from '@pyreon/kinetic'

const FadeBox = kinetic('div').preset(fade)

// Start from a preset, then customize one phase
const SlowSlide = kinetic('section').preset(slideUp).enterTransition('opacity 600ms ease, transform 600ms ease')

Six presets ship from @pyreon/kinetic directly — fade, scaleIn, slideUp, slideDown, slideLeft, slideRight — plus the aggregate presets object (presets.fade, presets.slideUp, …). For the full library of parameterized factories and composition helpers, see @pyreon/kinetic-presets (120+ presets); a @pyreon/kinetic-presets factory result is just a Preset object you pass to .preset(...).

import { kinetic } from '@pyreon/kinetic'
import { fadeUp } from '@pyreon/kinetic-presets'

const Hero = kinetic('div').preset(fadeUp())

Lifecycle callbacks

.on() attaches callbacks fired across the transition lifecycle (callbacks can also be passed as props on the rendered component):

const Notice = kinetic('div')
  .preset(slideUp)
  .on({
    onEnter: () => console.log('entering'),
    onAfterEnter: () => console.log('entered'),
    onLeave: () => console.log('leaving'),
    onAfterLeave: () => console.log('left'),
  })
CallbackFires
onEnterImmediately when the enter phase begins
onAfterEnterWhen the enter animation completes
onLeaveImmediately when the leave phase begins
onAfterLeaveWhen the leave animation completes (right before unmount)

Unmount vs. hide

By default a transition-mode component unmounts its child after the leave animation finishes. Pass unmount={false} (or .config({ unmount: false })) to keep the element in the DOM with display: none instead — useful when you need to preserve scroll position or focus state across a hide/show cycle:

const Panel = kinetic('div').preset(fade).config({ unmount: false })

Collapse Mode

.collapse() switches to a height animation between 0 and auto, ideal for accordions and disclosure widgets. The tag becomes an overflow: hidden wrapper; kinetic measures the content's scrollHeight to compute the target height, animates to it, then sets height: auto so the content stays responsive.

const Accordion = kinetic('div').collapse()

function FAQ() {
  const open = signal(false)
  return (
    <div>
      <button onClick={() => open.update((v) => !v)}>Toggle answer</button>
      <Accordion show={() => open()}>
        <p>Collapsible content that can be any height.</p>
      </Accordion>
    </div>
  )
}

Tune the height transition either on the chain or as a prop:

// On the chain
const SlowAccordion = kinetic('div').collapse({ transition: 'height 500ms ease-in-out' })

// As a prop
;<Accordion show={() => open()} transition="height 500ms ease-in-out">

</Accordion>
Prop / optionTypeDefaultDescription
show() => booleanReactive accessor for expanded/collapsed state
transitionstring"height 300ms ease"CSS transition for the height animation
appearbooleanfalseAnimate open on initial mount
timeoutnumber5000Safety timeout (ms) for the animation-end fallback

Stagger Mode

.stagger() sequences a list of children — each child's animation starts interval ms after the previous one. The tag wraps the children as a container; each child is animated individually.

const StaggerList = kinetic('ul')
  .preset(slideUp)
  .stagger({ interval: 80, reverseLeave: true })

function Menu() {
  const open = signal(true)
  return (
    <StaggerList show={() => open()}>
      <li>Item 1</li>
      <li>Item 2</li>
      <li>Item 3</li>
    </StaggerList>
  )
}

interval and reverseLeave can be set on the chain (.stagger({ interval, reverseLeave })) or as props on the rendered component:

Prop / optionTypeDefaultDescription
show() => booleanReactive accessor controlling all children
intervalnumber50Delay (ms) between each child's animation start
reverseLeavebooleanfalseReverse the stagger order on leave (last in, first out)
appearbooleanfalseAnimate on initial mount
timeoutnumber5000Safety timeout (ms) per child

Each child also receives the CSS custom properties --stagger-index and --stagger-interval, plus a computed transition-delay, so you can reference the stagger position from your own CSS if needed.

Group Mode

.group() animates a keyed list as entries are added and removed. Unlike the other modes there is no show prop — visibility is driven by which keys are present. A child with a new key animates in; a child whose key disappears stays mounted through its leave animation, then unmounts.

Because Pyreon components run once, pass the children as a reactive accessor () => VNode[] so the group can re-evaluate and diff the keys when your data changes:

const AnimatedList = kinetic('ul').preset(fade).group()

function TodoList() {
  const todos = signal([
    { id: 1, text: 'Buy milk' },
    { id: 2, text: 'Walk the dog' },
  ])

  return (
    <AnimatedList>
      {() => todos().map((t) => <li key={t.id}>{t.text}</li>)}
    </AnimatedList>
  )
}

Every child must carry a unique key — group mode keys its enter/exit diff on it. Children without a key are skipped.

Prop / optionTypeDefaultDescription
childrenVNode[] | (() => VNode[])Keyed children, or an accessor returning them
appearbooleanfalseAnimate the initial children on first mount
timeoutnumber5000Safety timeout (ms) per child

Reactive show and Reduced Motion

Across transition, collapse, and stagger modes, show is a reactive accessorshow={() => signal()} — not a static boolean. kinetic subscribes to it and runs the enter phase when it flips true, the leave phase when it flips false.

kinetic also respects the user's motion preference. When (prefers-reduced-motion: reduce) matches, enter and leave skip the transition and jump to the final state immediately — your lifecycle callbacks (onEnteronAfterEnter, onLeaveonAfterLeave) still fire so dependent logic stays correct. This is automatic; you don't configure it.

SSR Behavior

kinetic follows the ecosystem norm (Framer Motion, react-transition-group, react-spring): content is structural, animation is visual. A transition that is hidden at server-render time still emits its children into the HTML.

Concretely, a component whose show evaluates to false during SSR renders its children with the hidden-state class/style inlinedleaveTo if you defined it, otherwise enterFrom (which covers the common scroll-reveal pattern that only configures the enter side). This matters for the canonical reveal-on-scroll setup, where an IntersectionObserver can't fire on the server:

// `revealed` is false during SSR (no IntersectionObserver on the server),
// so without structural SSR the section would be ABSENT from the prerendered
// HTML — bad for SEO, social scrapers, and no-JS users.
const Reveal = kinetic('section').preset(slideUp)

;<Reveal show={() => revealed()}>
  <h2>Section heading</h2>
  <p>Important indexable content.</p>
</Reveal>

The prerendered HTML contains the heading and paragraph (with the hidden-state class/style applied); when the client hydrates and show flips true, the enter animation plays on the same element. The internal applyEnter clears the SSR-baked hidden class first so it can't fight the enter-to state.

Reactive Children and the Compiler

The Pyreon compiler may rewrite <KineticList>{children}</KineticList> so children arrives as a deferred accessor function rather than a plain VNode[]. kinetic handles this internally (via resolveChildren) — its renderers unwrap the accessor before iterating, so the documented APIs above work whether the compiler wraps children or not. kinetic deliberately snapshots children at render time for stagger and transition modes (animation state is built once per item); for a list that changes at runtime, use group mode with an accessor as shown above.

Hooks

These two low-level hooks power the renderers and are exported for advanced use — building a custom animated primitive, or driving an animation lifecycle by hand. Most apps never need them.

useTransitionState

Owns the enter/leave state machine that the transition renderer consumes. It returns the current stage (a signal), a ref callback to attach to the transitioning element, a reactive shouldMount accessor, and a complete() function to call when the current animation finishes.

import { useTransitionState } from '@pyreon/kinetic'

const { stage, ref, shouldMount, complete } = useTransitionState({
  show: () => visible(),
  appear: true,
})

// stage()        → 'hidden' | 'entering' | 'entered' | 'leaving'
// shouldMount()  → false only while 'hidden'
// ref            → attach to your element so appear can fire once wired
// complete()     → entering → entered, leaving → hidden
OptionTypeDefaultDescription
show() => booleanReactive accessor controlling visibility
appearbooleanfalseRun the enter animation on initial mount

useAnimationEnd

Listens for transitionend / animationend on an element while active is true, and calls onEnd exactly once when the animation finishes — or after timeout ms as a safety fallback if the event never fires. It ignores events bubbling from child elements.

import { useAnimationEnd } from '@pyreon/kinetic'

useAnimationEnd({
  ref: elementRef, // a Ref<HTMLElement>
  active: () => stage() === 'entering' || stage() === 'leaving',
  timeout: 5000,
  onEnd: () => complete(),
})
OptionTypeDefaultDescription
refRef<HTMLElement>Ref to the element to watch
active() => booleanReactive accessor — listen only while true
onEnd() => voidCalled once when the animation finishes (or on timeout)
timeoutnumber5000Safety timeout (ms) — guards against a never-firing event

Transition Stages

Every transition moves through four stages, exposed as the stage signal from useTransitionState:

StageDescription
hiddenElement is not mounted (or has display: none when unmount={false})
enteringEnter class/style applied, animation in progress
enteredEnter animation complete, element fully visible
leavingLeave class/style applied, animation in progress

API Reference

kinetic(tag)

Creates a renderable, chainable kinetic component in transition mode.

function kinetic<Tag extends string>(tag: Tag): KineticComponent<Tag, 'transition'>

tag is the HTML tag rendered as the container ('div', 'ul', 'section', …). The returned value is both a component (render it with JSX) and a chain object (call the methods below — each returns a new component).

Chain methods

MethodReturnsDescription
.preset(preset)same modeMerge a Preset's style/class fields into the config
.enter(styles)same modeSet the enter start style (enterStyle)
.enterTo(styles)same modeSet the enter end style (enterToStyle)
.enterTransition(value)same modeSet the enter CSS transition shorthand
.leave(styles)same modeSet the leave start style (leaveStyle)
.leaveTo(styles)same modeSet the leave end style (leaveToStyle)
.leaveTransition(value)same modeSet the leave CSS transition shorthand
.enterClass({ active, from, to })same modeSet enter classes (enter / enterFrom / enterTo)
.leaveClass({ active, from, to })same modeSet leave classes (leave / leaveFrom / leaveTo)
.on(callbacks)same modeAttach lifecycle callbacks
.config(opts)same modeSet mode-specific options (appear, unmount, timeout, transition, interval, reverseLeave)
.collapse(opts?)'collapse' modeSwitch to collapse mode; opts.transition sets the height transition
.stagger(opts?)'stagger' modeSwitch to stagger mode; opts.interval, opts.reverseLeave
.group()'group' modeSwitch to keyed-list group mode

.config(opts) accepts only the options valid for the current mode — { appear, unmount, timeout } in transition mode, { appear, timeout, transition } in collapse, { appear, timeout, interval, reverseLeave } in stagger, { appear, timeout } in group.

Component props

The props a kinetic component accepts depend on its mode:

ProptransitioncollapsestaggergroupDescription
showReactive () => boolean visibility accessor
childrenone childcontentarraykeyed array / accessorThe content to animate
appearAnimate on initial mount (default false)
timeoutSafety timeout in ms (default 5000)
unmountUnmount on leave vs. display:none (default true)
transitionHeight transition (default "height 300ms ease")
intervalDelay between children in ms (default 50)
reverseLeaveReverse stagger order on leave (default false)
onEnter / onAfterEnter / onLeave / onAfterLeaveLifecycle callbacks

Props passed on the rendered component override the same field set on the chain. Any other prop (class, style, id, event handlers, …) is forwarded to the rendered tag element with full reactivity preserved.

Presets

import { fade, scaleIn, slideUp, slideDown, slideLeft, slideRight, presets } from '@pyreon/kinetic'
import type { Preset } from '@pyreon/kinetic'
ExportTypeDescription
fadePresetOpacity 0 → 1
scaleInPresetOpacity + scale(0.95)scale(1)
slideUpPresetOpacity + translateY(16px)0
slideDownPresetOpacity + translateY(-16px)0
slideLeftPresetOpacity + translateX(16px)0
slideRightPresetOpacity + translateX(-16px)0
presetsRecord<string, Preset>Object of all six (presets.fade, …)
PresettypeStyleTransitionProps & ClassTransitionProps

For the full library see @pyreon/kinetic-presets.

Hooks

ExportSignatureDescription
useTransitionState({ show, appear? }) => { stage, ref, shouldMount, complete }Enter/leave state machine
useAnimationEnd({ ref, active, onEnd, timeout? }) => voidOne-shot transition/animation-end listener with timeout fallback

Types

TypeDescription
KineticComponent<Tag, Mode>A renderable + chainable kinetic component
PresetA preset object (StyleTransitionProps & ClassTransitionProps)
TransitionStage'hidden' | 'entering' | 'entered' | 'leaving'
ClassTransitionPropsThe enter / enterFrom / enterTo / leave / leaveFrom / leaveTo class fields
StyleTransitionPropsThe enterStyle / enterToStyle / enterTransition / leaveStyle / leaveToStyle / leaveTransition style fields
TransitionCallbacksonEnter / onAfterEnter / onLeave / onAfterLeave
TransitionStateResultReturn shape of useTransitionState
UseTransitionStateSignature type of useTransitionState
UseAnimationEndSignature type of useAnimationEnd
Kinetic