# lunato
> The motion layer for AI interfaces. Morph an element's text in place so only what changed moves: words that stay hold still, changed words rise in as one piece, digits roll like an odometer, emoji and svg icons morph. Made for streamed answers, model status, send and stop buttons, token counts, chat titles, rewrites and live captions. One function, zero dependencies, works with any framework.
## Install
npm i lunato
## API
morphChanges(target: string | Element | null): () => void
- target: an element or a CSS selector. null is ignored, so the function can be passed straight to a ref.
- returns: a function that stops watching and restores the element.
Bind once. After that, change the element's content any way you like (textContent, innerHTML, a framework render) and the change morphs. Binding the same element twice replaces the first binding.
## Frameworks
- Plain JS: morphChanges("#price")
- React 19: {price}
- React 18: const ref = useRef(null); useEffect(() => morphChanges(ref.current), []); {price}
- Next.js App Router: bind from a client component ("use client"); a server component cannot pass a ref.
- Svelte 5: {price}
- Svelte 4: const morph = (node) => ({ destroy: morphChanges(node) }); {price}
- Vue: import { vMorphChanges } from "lunato"; {{ price }}
- Solid: import { morphChanges } from "lunato/solid"; {price()}
- Anything else: import "lunato/element"; $240
- No build step: , then $240
## Browsers
Chrome 113, Safari 17.2 and Firefox 112, or later. Anywhere else (older browsers, the server, jsdom) morphChanges does nothing and the text shows as written, so it needs no guard.
## What moves
- Words: unchanged words stay still however many edits sit between them; inside a changed word, shared letters stay. A word pushed onto another line fades out and back in rather than gliding across the text. Built for live captions, AI replies and status lines that rewrite themselves.
- Numbers: digits pair by place value from the decimal point, so 9.9 to 10 rolls the 9 and brings in the 1. Falling numbers roll down.
- Emoji and textless elements: shrink and blur into the next one.
- Line icons: an svg of up to three elements reshapes into another, and turns when it is the same drawing rotated.
- Outline icons: an svg of up to four simple shapes (path, rect, circle, ellipse, line, polyline, polygon) reshapes outline to outline.
## Use it well
- Give numbers font-variant-numeric: tabular-nums.
- Use it on values that change in place: prices, counts, timers, status labels, button labels, icons. Not on text replaced wholesale.
- There are no options. The element's own CSS is the look.
- The real text stays in the DOM; the animation is an aria-hidden layer. Reduced motion turns every change into a fade. Add aria-live="polite" if changes should be announced.
## Limits
- Text is drawn in the element's own font and colour: a or a coloured inside it loses its style while bound. Bind the styled element itself.
- text-transform: capitalize capitalises every letter. uppercase is fine.
- Joined scripts such as Arabic draw unjoined; brackets in mixed-direction text can face the wrong way.
- ::before and ::after text on the element hides with its glyphs; give the pseudo-element -webkit-text-fill-color: currentcolor.
- A bound is given display: inline-block. Its own class, style and hidden can still hide it; a parent's state or a media query cannot. Hide a wrapper there.
- Do not bind an element inside a bound element.
- A rotation around the element skews the morph; a scale does not.
- The width only eases on one-line text.
- text-shadow and text-decoration paint under the animation.