@relictombs/opentui-motion
v0.1.0
Published
Declarative motion and spring animations for OpenTUI
Maintainers
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 = falseThe 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.finishedNumbers 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
translateXandtranslateYfor movement. OpenTUI'sxandygetters 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 onMotionBoxRenderable. 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:packedLicense
MIT
