Primitives / Motion

Start with one: a tween

ts
import { Schema } from 'effect'
import type { HtmlBuilder } from 'foldkit/html'
import { Bundle } from 'foldkit-bundle'
import { Tween, TweenMessage } from 'foldkit-primitives/motion'

const Page = Bundle.compose({}).pipe(
  Bundle.withChild('slide', Tween, { args: { from: 0, to: 1, ms: 200 } }),
)
type Model = typeof Page.Model.Type
type Message = typeof Page.Message.Type
const { placements } = Page

const config = placements.complete({
  init: () => placements.initial({}),
  update: placements.update(model => ({ model })),
  view: (model: Model, h: HtmlBuilder<Message>) =>
    h.div(
      [
        h.Style({ opacity: String(model.slide.value) }),
        h.OnClick(Page.Message.GotSlideMessage({ message: TweenMessage.Started() })),
      ],
      ['Fade in'],
    ),
  subscriptions: placements.subscriptions(),
})

The tween starts at from, stopped. Started runs it; progress comes from Effect's clock, so a test advances it with TestClock. The stream ends with Finished carrying the exact end value, and the value rests at to either way. Stopped holds the current value; a new Started runs from to to again. Linear interpolation only. A duration that is not positive and finite is rejected at placement.

Spring

Pulls one number toward to with { stiffness, damping } physics (positive and finite). A fixed 16 ms semi-implicit Euler step makes the trajectory identical on the live clock and TestClock; the stream ends with Finished carrying the exact end value even when an underdamped spring overshoots on the way. Tween is a fixed duration; Spring settles. Pick the motion, not both.

Presence: let content leave

Holds mount-transition state for an exit animation. phase moves shown → hiding → hidden: Hide starts the timed hiding phase, and the Hidden fact it schedules carries its generation, so a Show in between wins and the late fact is ignored. isVisible(model) reads whether content renders (shown or mid-exit); the view keeps drawing while it does, with a class for the exit. The timeout Command is the owner; a transitionend Mount is a future opt-in, not a second timer.

Reduced motion is a service

Whether motion should be reduced is read when a transition starts, not sniffed once. Provide Motion.live (the user's prefers-reduced-motion) through the assembly's resources, or Motion.reduced and Motion.full in a test or for a setting the application owns. Under reduced motion Presence exits at once and Tween and Spring jump to to, through the same Messages, so the Model sees the same transitions. With no service provided, motion is full. Motion.reducedMotion is the Effect the bundles read, for a transition of your own.

Failure

A non-positive or non-finite duration, stiffness, or damping is rejected at placement. A stopped stream is empty; Finished still rests the value exactly.