npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@relictombs/opentui-motion

v0.1.0

Published

Declarative motion and spring animations for OpenTUI

Readme

@relictombs/opentui-motion

Motion-inspired tweens, springs, and declarative animation for OpenTUI.

@relictombs/opentui-motion animates the properties OpenTUI already owns, so layout, transforms, opacity, and colors continue to go through their normal setters. It works imperatively with any renderable and provides an optional <motion> box for React and Solid.

It does not require HTML/SVG refs, browser globals, or requestAnimationFrame; playback runs on OpenTUI's own animation engine and renderer lifecycle.

Install

bun add @relictombs/opentui-motion

@opentui/core 0.4.5 or newer is required. React and Solid are optional peers.

Ready-to-use entrances

fadeIn() and slideIn() return typed props that can be spread onto <motion> or passed to MotionBoxRenderable. stagger() computes list delays without creating timers:

import { slideIn, stagger } from "@relictombs/opentui-motion"
import { registerMotion } from "@relictombs/opentui-motion/react"

registerMotion()

export function Results({ items }: { items: string[] }) {
  return items.map((item, index) => (
    <motion {...slideIn("up", { distance: 2, delay: stagger(index, { each: 70 }) })}>
      <text>{item}</text>
    </motion>
  ))
}

Directions describe travel: slideIn("up") starts below and moves upward into place. The other directions are down, left, and right. Presets return fresh ordinary objects, so application props can override any field.

The same preset works without a framework:

import { MotionBoxRenderable, fadeIn } from "@relictombs/opentui-motion"

const card = new MotionBoxRenderable(renderer, {
  width: 30,
  height: 3,
  ...fadeIn({ delay: 120 }),
})

For center-out or end-first lists, provide the list length:

stagger(index, { each: 60, from: "center", total: items.length })
stagger(index, { each: 60, from: "last", total: items.length })

Activity indicator

MotionSpinnerRenderable is a renderer-native text component driven by OpenTUI's shared animation engine. It cleans up when removed or destroyed and resumes safely when reattached:

import { MotionSpinnerRenderable } from "@relictombs/opentui-motion"

const spinner = new MotionSpinnerRenderable(renderer, {
  frames: "dots",
  interval: 80,
  label: "Loading",
  fg: "#94e2d5",
})

renderer.root.add(spinner)
spinner.playing = false

The built-in frame sets are dots, line, and arc; a non-empty string array supplies custom frames. After registerMotion(), React and Solid can use the same component as an intrinsic:

<motionSpinner frames="arc" interval={90} label="Fetching" />

Animate any renderable

Use the curated Effect entrypoint when application code owns the animation. Closing the scope stops an active timeline, and setup or callback failures remain in the typed error channel:

import { Effect } from "effect"
import { Motion } from "@relictombs/opentui-motion/effect"

const program = Effect.gen(function* () {
  const motion = yield* Motion.animate(box, { translateX: 24, opacity: 1 }, { duration: 600 })
  const result = yield* motion.finished()
  yield* Effect.logInfo("animation settled", result)
}).pipe(Effect.scoped)

await Effect.runPromise(program)

The handle exposes Effect operations for play, pause, finish, stop, cancel, state, and finished. Use the plain animate() adapter at an OpenTUI or framework boundary that already owns cleanup:

import { animate } from "@relictombs/opentui-motion"

const controls = animate(
  box,
  {
    translateX: [0, 24, 18],
    opacity: [0, 1],
    borderColor: ["#89b4fa", "#f38ba8"],
  },
  {
    duration: 600,
    ease: "outQuad",
  },
)

const result = await controls.finished

Numbers and OpenTUI colors can be animated. A scalar starts at the property's current value; an array supplies explicit keyframes. Use null as the first keyframe to capture the current value:

animate(
  box,
  { translateY: [null, 8, 0] },
  {
    duration: 450,
    times: [0, 0.3, 1],
  },
)

Durations and delays are milliseconds, matching OpenTUI's timeline API.

Springs

animate(
  box,
  { translateX: 30 },
  {
    type: "spring",
    stiffness: 170,
    damping: 18,
    mass: 1,
  },
)

Springs are evaluated analytically from absolute elapsed time, so their result does not change with frame partitioning. They are bounded by maxDuration (10 seconds by default, with a 60-second safety ceiling) and finish on the exact requested value. Spring properties accept a scalar or exactly two keyframes.

Playback controls

| Method | Result | | -------------------- | ------------------------------------------------------------ | | pause() / play() | Pause and resume at the same elapsed time | | stop() | Stop and keep the current values | | cancel() | Stop and restore values captured when animate() was called | | finish() | Apply exact final values and run onComplete |

controls.finished always resolves. Its status is finished, cancelled, stopped, replaced, destroyed, or error, so interruption does not create unhandled promise rejections. The Effect handle instead reports callback errors as Motion.OperationError.

Animations own individual properties. Starting a new translateX animation replaces only translateX; other properties from the previous animation continue without jumping. Destroying an OpenTUI renderable automatically unregisters all work and listeners.

React

Register the motion intrinsics once during application setup:

import { registerMotion } from "@relictombs/opentui-motion/react"

registerMotion()

export function Greeting() {
  return (
    <motion
      initial={{ opacity: 0, translateY: 2 }}
      animate={{ opacity: 1, translateY: 0 }}
      whileHover={{ backgroundColor: "#89b4fa", translateY: -1 }}
      whilePress={{ opacity: 0.65, translateY: 1 }}
      transition={{ type: "spring", stiffness: 180, damping: 22 }}
    >
      <text>Hello</text>
    </motion>
  )
}

Solid

import { registerMotion } from "@relictombs/opentui-motion/solid"

registerMotion()

export function Greeting() {
  return (
    <motion
      initial={{ opacity: 0, translateX: -4 }}
      animate={{ opacity: 1, translateX: 0 }}
      whileHover={{ backgroundColor: "#94e2d5", translateX: 1 }}
      whilePress={{ opacity: 0.6 }}
      transition={{ duration: 250, ease: "outQuad" }}
    >
      <text>Hello</text>
    </motion>
  )
}

<motion> is a MotionBoxRenderable. It intentionally adds a Box/Yoga node and animates itself, so opacity and transforms affect its child subtree. Prop changes are coalesced into OpenTUI's next lifecycle pass, which prevents framework rerenders or Solid's prop-by-prop assignment from creating duplicate controllers. Use imperative animate() when a wrapper node is undesirable.

Mouse interaction

whileHover and whilePress retarget the same MotionBoxRenderable when OpenTUI dispatches mouse events. Interaction properties layer over the base animate state; releasing or leaving animates back to that state. They also work without animate, in which case the renderable returns to the values captured when the interaction began. These props are scalar target states; use imperative animate() for interaction keyframe sequences.

Base and interaction playback use separate property-scoped controllers. Hovering a color therefore does not restart an unrelated base keyframe sequence. motionControls exposes base playback and interactionControls exposes the latest hover/press transition.

Normal OpenTUI handlers still run:

const button = new MotionBoxRenderable(renderer, {
  width: 24,
  height: 3,
  backgroundColor: "#18181b",
  whileHover: { backgroundColor: "#89b4fa", translateY: -1 },
  whilePress: { backgroundColor: "#cba6f7", translateY: 1 },
  transition: { type: "spring", stiffness: 220, damping: 18 },
  onMouseDown(event) {
    // Call event.preventDefault() here to opt out of the motion interaction.
  },
})

Only the primary mouse button activates whilePress. preventDefault() opts out of activation, but never suppresses release, leave, or renderer-blur cleanup. The original half-open hit rectangle remains registered while an interaction settles, which keeps self-translated hover and press targets addressable at their starting cells. Mouse events stay renderer-native: the package installs no process, DOM, or global input listeners.

Direct core setup

The OpenTUI React and Solid renderers already attach the shared animation engine. A direct @opentui/core application must attach it to its renderer once:

import { createCliRenderer, engine } from "@opentui/core"

const renderer = await createCliRenderer()
engine.attach(renderer)

Defaults

// Tween
{ duration: 300, delay: 0, ease: "outQuad", autoplay: true }

// Spring
{
  stiffness: 170,
  damping: 26,
  mass: 1,
  restDelta: 0.005,
  restSpeed: 0.01,
  maxDuration: 10_000,
}

All easing names supported by OpenTUI's Timeline are accepted, including linear, inQuad, outQuad, inOutQuad, inOutSine, outBounce, outElastic, inBack, outBack, and inOutBack.

Runnable examples

The repository includes three focused programs that use the same APIs shown above:

| Command | Demonstrates | | ---------------------------- | --------------------------------------------------------------- | | bun run example:imperative | Tweens, bounce, springs, colors, spinner, and playback controls | | bun run example:react | Presets, stagger, <motion>, hover/press, and spinner | | bun run example:solid | The same primitives through Solid, including keyed replay |

See the examples directory for controls and copying notes. The larger, recordable tour lives in the @relictombs/opentui-motion-showcase.

Scope of 0.1

  • Prefer translateX and translateY for movement. OpenTUI's x and y getters are screen-relative while their setters affect local layout, which can make retargeting surprising.
  • Dragging, exit/presence, shared-layout transitions, and variants are not included yet. stagger() coordinates entrance delays but does not own child lifecycles. Hover and primary-button press are supported directly on MotionBoxRenderable. Exit animation needs a presence protocol because the current framework reconcilers destroy removed renderables immediately.
  • Color mixing is RGBA in sRGB channel space. Exact keyframe endpoints preserve OpenTUI indexed/default color intent.

Development

From a repository checkout, install dependencies at the workspace root and run package commands from packages/opentui/motion:

cd packages/opentui/motion
bun run test
bun run check
bun run build
bun run test:packed

License

MIT