One function. That’s the API.
lunato is the motion layer for AI interfaces. Bind an element once, and whenever its text changes, streamed, rewritten or corrected, only the words, digits and icons that changed will move.
With an agent
Pick your agent, copy the prompt and paste it in. It installs lunato, finds the text in your app that changes in place and binds it. Agents can read the whole reference as plain text at llms.txt.
morphChanges(target)
Starts watching an element. Change its content however you like, with textContent, innerHTML or a framework render, and the change morphs.
- target
- An element, a CSS selector, or
null, which is ignored so it can sit in a ref. - returns
- A function that stops watching and puts the element back as it was.
import { morphChanges } from "lunato";
const stop = morphChanges("#price");
price.textContent = "$24"; // the 0 rolls to a 4
stop(); // back to a plain elementWord roll
Mark the element data-lunato="roll" and it rolls by word instead: words pair by position, and each word that changed rises away as its replacement bubbles up from below. Made for thinking states and statuses. The mark is read at every change, so it can be set or cleared at any time. Text on more than one line always morphs, and reduced motion crossfades either way.
<span ref={morphChanges} data-lunato="roll">{status}</span>Feel
Playful is the default: the roll bubbles, leans and bobs, and the morph lands with a small overshoot. Add data-lunato-feel="calm" for the same motion without the play: words rise in and out whole, at their own size and upright, with no overshoot. It works with either.
<span ref={morphChanges} data-lunato="roll" data-lunato-feel="calm">{status}</span>In your framework
There’s no wrapper component and nothing to call on update. Render into an element as usual and bind it once.
React 18
React 18 never calls a ref’s cleanup, so bind in an effect there.
const ref = useRef(null);
useEffect(() => morphChanges(ref.current), []);
<span ref={ref}>{price}</span>Next.js
In the App Router, bind from a client component, one that starts with "use client". A ref is a function, and a server component can’t pass one.
Svelte 4
Svelte 4 has no attachments, so bind with an action.
<script>
import { morphChanges } from "lunato";
const morph = (node) => ({ destroy: morphChanges(node) });
</script>
<span use:morph>{price}</span>Anything else
Import lunato/element and wrap the text in <lunato-text>. With no build step, load it from a CDN.
<script type="module" src="https://unpkg.com/lunato/dist/element.js"></script> <lunato-text>$240</lunato-text>
Recipes for AI interfaces
Every recipe is the same move: render into an element as you already do, and bind it once. They are written in React; in Vue, Svelte, Solid or plain JS, bind the same element the way your framework does. The icons are yours.
A streamed answer
Bind the element the answer streams into. Each new word rises in as one piece, and when the model revises what it already wrote, only the revised words move.
<p ref={morphChanges}>{answer}</p>What the model is doing
One line the model keeps rewriting. The words it keeps stay still, a count rolls, and the dots can reshape into a check when it is done.
<span ref={morphChanges}>
{done ? <CheckIcon /> : <DotsIcon />} {status}
</span>
// "Reading 4 sources" → "Reading 9 sources": only the 4 rollsSend becomes stop
Swap the icon inside a bound element and it morphs instead of popping.
<button onClick={busy ? stop : send}>
<span ref={morphChanges}>{busy ? <StopIcon /> : <SendIcon />}</span>
</button>A token count
Digits roll like an odometer, so a count climbing while the answer streams stays readable.
<span ref={morphChanges} style={{ fontVariantNumeric: "tabular-nums" }}>
{tokens.toLocaleString()} tokens
</span>A chat that names itself
The title moves from its placeholder to the name the model gives it.
<h1 ref={morphChanges}>{chat.title ?? "New chat"}</h1>Regenerate and rewrite
Render the new version into the same element. The words both versions share hold their place, so the reader sees what changed.
<p ref={morphChanges}>{versions[current]}</p>
<span ref={morphChanges}>{current + 1} / {versions.length}</span>Live captions
Speech recognition revises its guess as someone talks. Write each guess into a bound element and the corrections move, not the whole line. Plain JS, with the browser's own recogniser.
const caption = document.querySelector("#caption");
morphChanges(caption);
const listen = new (window.SpeechRecognition ?? window.webkitSpeechRecognition)();
listen.continuous = true;
listen.interimResults = true;
listen.onresult = (e) => {
caption.textContent = [...e.results].map((r) => r[0].transcript).join(" ");
};
listen.start();What moves
- Words. Unchanged words stay still, however many edits sit between them. Inside a changed word, the letters it shares stay too. A word pushed onto the next line fades out and back in where it lands, instead of flying across the text.
- Numbers. Digits roll like an odometer, lined up on the decimal point, so 9.9 to 10 rolls the 9 and brings in the 1. Falling numbers roll down.
- Emoji and icons. An emoji or an element with no text shrinks and blurs into the next one.
- Line icons. An svg of up to three
<line>s reshapes into another, and turns when it is the same drawing rotated. - Outline icons. An svg of up to four simple shapes reshapes outline to outline, so a play triangle becomes two pause bars.
Styling
The element’s own CSS is the look, and the animation copies its font, colour and position. The one choice is how it moves: the morph, or the word roll.
- Give numbers
font-variant-numeric: tabular-nums, so a digit lands in the column it left. - An element sized by its content eases to its new width, so whatever sits beside it slides.
- Nothing draws outside the element. Motion fades out at its edges instead of spilling over.
Accessibility
The real text never leaves the page, so screen readers, search, find and selection all see a plain element. The animation is drawn in an aria-hidden layer on top. With reduced motion turned on, changes simply fade. Add aria-live="polite" to the element if its changes should be announced. A visually hidden label inside the element stays hidden, and a print shows the real text.
Limits
- Text is drawn in the element’s own font and colour. A
<b>or a coloured<span>inside it loses its style while bound, so bind the styled element itself. text-transform: capitalizecapitalises every letter, because each one is drawn on its own.uppercaseis fine.- Joined scripts such as Arabic draw letter by letter, unjoined, and brackets in mixed-direction text can face the wrong way.
::beforeand::aftertext on the element hides with its glyphs. Give the pseudo-element-webkit-text-fill-color: currentcolor.- A bound
<span>is givendisplay: inline-block. Its ownclass,styleandhiddencan still hide it; a parent’s state or a media query can’t. Hide a wrapper there. - An element bound inside a bound element is drawn twice. Bind one or the other.
- A rotation around the element skews the morph. A scale doesn’t.
- The width only eases on one-line text.
text-shadowandtext-decorationpaint under the animation.- Text painted with
background-clip: textshows twice. Animatecolorinstead.
Browsers
Chrome 113, Safari 17.2 and Firefox 112, or later: the ones with linear() easing. Anywhere else, the server and jsdom included, the text shows as written and nothing moves, so there is nothing to guard in your own code.