Esc

Try “tilt”, “reveal”, “drag”, or “reduced motion”.

Guide

JavaScript

The same vocabulary as props, plus live overrides, bindings to signals, drag events, and scopes.

Every CSS property has a JavaScript prop. Use JavaScript when motion depends on application state, or when CSS can't describe it.

Every example on this site comes both ways. Open its code and switch Motion in to JavaScript to see it written with kinesis().

kinesis()

kinesis() takes an element, a selector, or a list of elements, plus props. Props are the CSS names in camelCase. Lengths are numbers in px, angles in degrees, times in ms.

import { kinesis } from "@amineyarman/kinesis"

kinesis(".card", { tilt: 8, area: "self", motion: "soft" })

kinesis(document.querySelector(".cta"), {
  magnetic: 0.35,
  radius: 160,
  when: "press",
  scale: 0.94,
})

Arrays are pairs: parallax: [30, 12], y: [24, 0], color: ["#999", "tomato"]. A single value means "from rest to this", as in CSS.

kinesis() works on any element, inside a [data-kinesis] scope or not. Outside a scope, the pointer area is the viewport.

set() and reset()

kinesis() returns a handle. set() changes props, which override CSS for that element. reset() hands them back to CSS.

const card = kinesis(document.querySelector(".card"))

card.set({ tilt: 14 })   // JavaScript owns tilt now
card.reset("tilt")       // back to whatever the stylesheet says
card.reset()             // back to CSS for everything

set() doesn't touch the DOM or re-read styles, so it's cheap enough for every input event or animation frame. Prefer it to inline --k-* styles for state-driven motion.

bind()

bind() drives an output from a signal, a number that changes over time. Bound values add to CSS effects. Scale and opacity multiply.

import { kinesis, pointer } from "@amineyarman/kinesis"

kinesis(".needle").bind({
  rotate: pointer.speed.map([0, 4000], [-90, 90]).spring("bouncy"),
})
Move your pointer fast, then stopEdit
.gauge-scene {
  display: grid;
  place-items: center;
  height: 100%;
}

.gauge {
  position: relative;
  width: 220px;
  height: 120px;
  overflow: hidden;
  border-radius: 120px 120px 0 0;
  background: conic-gradient(from -90deg at 50% 100%, #16c784, #ffb020, #ff5d6c 180deg, transparent 0);
}

.gauge__needle {
  position: absolute;
  bottom: 0;
  left: calc(50% - 3px);
  width: 6px;
  height: 100px;
  border-radius: 3px;
  background: #0c1324;
  transform-origin: 50% 100%;
}

Bindable channels: x, y, z, rotate, rotateX, rotateY, scale, opacity, blur, progress, and any CSS custom property starting with --. Binding progress drives the element's reaction outputs in place of its trigger.

kinesis(".meter").bind({ "--level": audio.volume })

unbind("rotate") removes one binding, unbind() removes all of them.

Signals

Signals update once per frame. Kinesis provides these:

SignalValue
pointer.x, pointer.yposition in the viewport, px
pointer.nx, pointer.nyposition from -1 to 1 across the viewport
pointer.velocityX, pointer.velocityY, pointer.speedpx per second, smoothed
pointer.present1 while a pointer is over the page
scroll.y, scroll.progress, scroll.velocitydocument scroll
timeseconds since the page loaded
handle.progressan element's reaction progress
handle.nearhow close the pointer is to that element, 1 at its center to 0 at its radius
handle.pointer.x, handle.pointer.ythe pointer's offset from that element's resting center, px

Make your own with value() and combine signals with computed():

import { computed, kinesis, value } from "@amineyarman/kinesis"

const volume = value(0)          // set it from anywhere: volume.set(0.7)
const loud = computed(() => volume.get() * 2)

kinesis(".bars").bind({ scale: loud.map([0, 1], [1, 1.5]) })

Every signal has:

  • map([inMin, inMax], [outMin, outMax]) maps linearly, clamped to the output range. A third argument { clamp: false } extrapolates. A function also works: map(v => v * v).
  • clamp(min, max)
  • spring(preset) follows the signal with a spring, from a preset name or { stiffness, damping }.
  • get() returns the current value.
  • subscribe(fn) calls fn with each new value, at most once per frame, and returns an unsubscribe function.

A signal is computed at most once per frame, however many elements read it. Signals nothing reads cost nothing.

Per-element signals keep custom motion short. Grass that bends behind a fast pointer, most strongly near it:

for (const el of document.querySelectorAll(".blade")) {
  const blade = kinesis(el, { radius: 220 })
  blade.bind({
    rotate: computed(() => (pointer.velocityX.get() / 45) * blade.near.get()).spring("bouncy"),
  })
}

The robot arm on the home page aims two joints the same way, from shoulder.pointer.x and shoulder.pointer.y.

Events

kinesis(".sheet").on("dragend", ({ x, y, velocityX, velocityY }) => { /* … */ })

Events: dragstart, drag, dragend. See Drag.

Cleaning up

destroy() removes the props and bindings a handle added. Motion declared in CSS keeps running. Elements removed from the page are released automatically.

Scopes

initKinesis() sets up every [data-kinesis] element and watches the page for more. For a scope you control, for example in a component, use createKinesis():

import { createKinesis } from "@amineyarman/kinesis"

const scope = createKinesis(element)   // adds data-kinesis if missing

scope.pause()     // freeze
scope.resume()
scope.refresh()   // re-read CSS after changes Kinesis cannot observe
scope.destroy()   // remove everything and restore styles

Options

import { configure, initKinesis } from "@amineyarman/kinesis"

initKinesis({
  reducedMotion: "user",   // "user" (default), "always", or "never"
  debug: false,            // console warnings for common mistakes
})

configure({ reducedMotion: "always" })   // change later

The full list is in the JavaScript API reference.

Edit this page on GitHub