All docs
Concepts · Motion

Motion

Caret motion is bounded, intentional, and gated. Every animation has a designed start and a designed end — spinners resolve into checkmarks, selections slide, progress pulses, errors reveal. The rules below apply to every component that animates.

Hard rules

  1. Duration is bounded — no animation runs longer than 300ms unless it's representing actual ongoing work (spinner while a deploy runs).
  2. Inline-safe — animations don't push surrounding characters around between frames. If a frame rotates, it's locked to a single character cell.
  3. Reduced motion respected — all animation is disabled when prefers-reduced-motion is on, when CARET_REDUCED_MOTION=1, or when stdout is not a TTY.
  4. Token-based — durations and frame rates come from the theme, not magic numbers in components.

Duration tokens

TokenDefault (ms)Used by
duration.instant60Cursor-blink window, sub-frame timing
duration.fast120Color/border transitions
duration.default200Spinner morph, prompt resolve, selection slide
duration.slow300Reveal, modal open, boot step

Frame rates

Stepped animations (spinner, typewriter) tick on a fixed interval. Frame rates are also tokens, so an entire CLI's animation feel can be tuned in one spot.

TokenDefault (ms)Used by
spinnerFrameMs80Braille spinner step interval
blinkMs1050Block cursor blink cycle
typewriterMs24Character-by-character reveal

Easing

Caret exports an easing namespace with the standard curves used internally. Custom components are encouraged to consume them rather than inline cubic-bezier strings.

ts
import { easing } from '../caret/lib/motion.js'

easing.linear     // (t) => t
easing.easeOut    // (t) => 1 - Math.pow(1 - t, 3)
easing.easeInOut  // smoothstep

frameLoop helper

For custom timed animation, use frameLoop. It wraps setInterval with reduced-motion gating and a clean cancel signature.

ts
import { frameLoop } from '../caret/lib/motion.js'

const cancel = frameLoop(80, (frame) => {
  // 80ms ticks. Returns false to stop, anything else to continue.
})

// Later — bail out
cancel()
When in doubt, don't animate
The manifesto rule: motion has meaning. If a transition isn't communicating something — state change, progress, attention — it shouldn't exist. Static is fine. Static is the default.

Reduced motion behavior

When reduced motion is active, components skip the animation but keep the semantic transition. Examples:

ComponentNormalReduced motion
spinnerBraille rotation, then morph to ✓Static · then ✓
typewriterCharacter-by-character revealWhole text appears at once
revealEach line fades in sequentiallyAll lines appear at once
modalFade in over 300msAppears immediately
progressPercent animates from current to newPercent jumps directly

Capability detection — including reduced-motion sources — is documented at Capability detection.