letter-spacing settling
Paragraph text enters from randomised letter-spacing and transitions to optical equilibrium. A page-load animation that feels typographic rather than decorative — lines staggered, motion purposeful. Nothing animates under prefers-reduced-motion, on slow-refresh screens, or with active set to false.
Live demo
How it works
The entrance
Each line of text starts from its own random letter-spacing offset, up to the spread you set: looser than its settled spacing with the default 'expand', tighter with 'compress'. The offset is different on every line, so the paragraph starts slightly uneven and reads as typographic texture rather than noise.
The resolution
A CSS transition carries each line to its natural letter-spacing baseline — the element's existing CSS tracking, if any, or zero if none is set. The stagger control spaces these transitions apart so lines settle in sequence rather than all at once. Lines are locked as the browser set them, so nothing re-wraps and nothing below the text moves while it settles.
Usage
Drop-in component
import { SettleText } from '@overpunch/typsettle'
<SettleText spread={0.04} duration={800} stagger={80}>
Your paragraph text here...
</SettleText>Hook
import { useSettle } from '@overpunch/typsettle'
const { ref, replay } = useSettle({ spread: 0.04, duration: 800, stagger: 80 })
return <p ref={ref}>{children}</p>Vanilla JS
import { applySettle, removeSettle, replaySettle, getCleanHTML } from '@overpunch/typsettle'
const el = document.querySelector('p')
const original = getCleanHTML(el)
applySettle(el, original, { spread: 0.04, duration: 800, stagger: 80 })
// Replay the animation on a settled element (e.g. on a button click):
// replaySettle(el)
// Restore original markup:
// removeSettle(el, original)Options
| Option | Default | Description |
|---|---|---|
| spread | 0.04 | Max initial letter-spacing offset in em. |
| duration | 800 | Transition duration in milliseconds. |
| easing | 'cubic-bezier(0.25, 0.1, 0.25, 1)' | CSS easing string. |
| stagger | 0 | Delay between lines in ms. 0 = all settle together. |
| active | true | Set false to skip animation entirely. |
| targetTracking | 0 | Extra letter-spacing (em) each line settles to, on top of the element's own. 0 = natural spacing. 'auto' measures per-line optical density and evens it out: dense lines settle slightly looser, sparse lines slightly tighter, within ±0.05em. A positive amount is limited to the room each line has, so the settled text never overflows. |
| direction | 'expand' | 'expand' — each line starts looser (by up to spread) and settles in. 'compress' — each line starts tighter (by up to spread) and settles out. |
| intersect | false | When true, re-runs the animation each time the element scrolls into view. |
| quietReplay | false | When true, a replay keeps the existing lines and offsets each one from its settled spacing, then eases back (staggered when stagger is set, all at once when it is 0), instead of rebuilding the element. |
| lineDetection | 'bcr' | 'bcr' reads actual browser layout — ground truth, works with any font and inline HTML. 'canvas' uses @chenglou/pretext for arithmetic line breaking with no forced reflow on resize. Install pretext separately. |
no-code
Use it in Webflow, Framer & Figma
The same effect, no build step — drop it straight into your design tool.
Webflow
One script tag, then mark any element with data-typsettle. Configure it with data-* attributes.
<!-- Site Settings → Custom Code → Footer, or an Embed element -->
<script src="https://cdn.jsdelivr.net/npm/@overpunch/typsettle/dist/typsettle.webflow.min.js"></script>
<!-- Then add data-typsettle to any text element -->
<h1 data-typsettle>Your headline</h1>Framer
Insert → Code → New Component, then paste Typsettle.tsx ↗. It imports the core from esm.sh and exposes every option in the property panel — no build step.
import { /* core */ } from "https://esm.sh/@overpunch/typsettle"Figma · beta
Part of the Type Tools Figma plugin ↗ — Plugins → Development → Import plugin from manifest, run Type Tools, and pick this tool. Here it bakes a single static frame of the animated effect (no live animation in Figma).