A variable font is one file holding a continuous range of shapes. Ask for weight 637 and you get weight 637 — drawn, not approximated, because the designer defined where every outline sits at every point along the axis. Type on the web has not had a better decade.
It comes with two conditions, and they are both easy to miss until you try to animate something. The font has to actually carry the axis you want. And moving along an axis is a layout operation: change a glyph’s weight and you change its advance width, so the browser re-lays out the line, every frame, for as long as the motion lasts.
Fluid Text is what I built to get the behaviour without either condition. Characters near the cursor stretch horizontally, compress a little vertically, thicken, and push their neighbours aside — then settle back on a spring when you leave. None of it asks anything of the font, and none of it touches layout.
Move your cursor across it. Double-click the words to type your own. The floating panel carries the same controls the Framer version puts in its property panel — drag it by the header, collapse it, or reset it.
It began as a Framer component, where the property panel gave it sliders for free. Moving it to a plain website meant giving up that panel and rebuilding the motion without React or a motion library. What follows is what it does, why it does it that way, and how it is put together.
The axes you actually have
This site is set in Suisse Intl. It is a static font — two weights, drawn as separate files, no axes at all. That is not an unusual position to be in. It is the position most brand guidelines put you in.
The line below asks it for a weight axis anyway. It calls font-variation-settings per character, with the same distance falloff and the same spring the rest of this page uses. The vertical line in the accent colour marks where the text ends when nothing is hovered.
When a font does carry the axis, that is the best version of this effect, and it is worth saying plainly: nothing in this post beats a real weight axis for fidelity. A type designer decided how much each stem grows and how much each counter gives up; a browser interpolating between their masters is not guessing. It is also a layout operation — change a glyph’s weight and you change its advance width, so that marker would be moving on every frame of the motion, each one a new line box, measured and laid out again.
But weight is the common axis. Width is not — very few families ship one, and the ones that do tend to be the expensive ones. Optical size, slant, grade, and the rest are rarer still. So the practical shape of the problem is: you have a design that wants type to move along two axes, and the font in the brand guidelines has one of them, or none. This page has none.
Fluid Text answers with the two things CSS can do to a glyph without asking the font’s permission.
Set that against a real axis and the difference is smaller than it ought to be. Look closely and the axis wins on craft — Fluid Text’s stroke is centred on the outline, so half of it grows inward and the counters tighten at high values, which is precisely what a designer spends their time preventing. At the amounts this effect uses, in motion, under a cursor, nobody is reading counters.
The trade is the whole point:
| Real axis | Fluid Text | |
|---|---|---|
| Fonts it works on | Ones that carry the axis | Any font, including static ones |
| Width | wdth, if it exists | scaleX, always |
| Weight | wght, drawn by the designer | Stroke on the outline |
| Cost per frame | Layout, paint | Paint |
| Fidelity | Exact | Convincing |
If your font has the axis and you are animating one word in one place, use the axis. Fluid Text is for everything else — which, in client work, is most things.
This page is the everything else. Every demo on it is running on that static two-weight file, the same one you are reading this sentence in. Nothing here loads a second font to make the motion work, and the only thing on the page that failed to move is the one that asked the font for permission.
The browser already does this
None of it is a new trick. When you ask for bold and the family has no bold face, the browser does not refuse — it synthesises one, thickening the outline until it reads heavier. Faux italic is a skew. CSS has had font-synthesis for years so you can turn that behaviour off when you would rather have the honest shape.
Fluid Text is that same synthesis with the switch replaced by a dial — and with the one synthesis CSS refuses to perform. There is no faux width. The spec synthesises weight, style, and small caps; it will not stretch a glyph for you, on the reasonable grounds that stretched type is a crime when it is permanent. Under a cursor, at 1.3, for 200 milliseconds, it is not permanent.
Getting started
The component is a single .astro file. No dependencies, no client framework.
---
import FluidText from '@/components/demos/fluid-text/FluidText.astro'
---
<FluidText text="Fluid Text" tag="h1" />Everything else is optional. The defaults are tuned to be the version you ship.
Proximity
range is the only spatial control: the radius, in pixels, inside which a character responds. Intensity falls off linearly from the centre of each glyph, so a character directly under the cursor sits at 1 and one at the edge of the radius sits at 0.
A short range reads as a spotlight. A long one makes the whole line breathe together.
Shape
Three numbers control what a character does at full intensity:
scaleX— horizontal stretch. Above 1 widens. This is the width axis.scaleY— vertical scale. Slightly below 1 keeps the stretch from reading as a zoom.strokeWidth— an outline inem, painted in the text colour. This is the weight axis.
The default pairing of scaleX: 1.3 with scaleY: 0.94 matters more than either number alone. Scaling only on X looks like a font stretching badly; taking a little off the height at the same time makes it read as pressure, as if the letter were being pushed into the surface. It is also, not by accident, what a real width axis does — condensed and expanded cuts are not pure horizontal scales, and the vertical give is part of why.
-webkit-text-stroke is the prefixed property everyone ships and nobody standardised. It reads as extra weight rather than as an outline because the stroke is painted in the same colour as the fill, so what you see is the glyph growing, not a border appearing around it.
Spacing
padding opens a gap around each active character, in em. Without it, a stretching letter overlaps its neighbours and the line turns to mud.
This is where the rebuild diverged most from the original, and it is the same argument as the one against the axis. The Framer version applied the gap as padding-inline, which is correct and also expensive: padding is layout, so every frame reflowed the whole line — the exact cost the transform approach exists to avoid, reintroduced through the back door.
Here the same displacement is a translateX, computed as a running total across the line. Each character carries the sum of the space its predecessors opened up, plus half its own. Visually identical, and nothing touches layout.
Colour
hoverColor interpolates from the base colour as intensity rises.
Doing the mix in CSS instead of JavaScript is what lets this work with a theme toggle. The original resolved var(--text-primary) once, at startup, by writing a probe element into the document and reading its computed colour back. That value then never changed — flipping to dark mode left the text at its light-mode colour. Handing both colours to color-mix() and animating only the mix amount means the custom properties re-resolve on their own.
Threshold
An optional SVG filter — a blur followed by a high-contrast alpha ramp — that makes adjacent characters fuse as they approach each other, the way two drops of liquid merge. This is the one effect here that no font axis will ever give you, because it happens between glyphs rather than inside them.
It is off by default because it is the most expensive thing on the page: an SVG filter forces the text to rasterise, which costs a repaint of the whole block every frame and drops subpixel antialiasing.
thresholdIntensity sets how aggressive the cut is. Lower values keep more of the blur; higher values snap harder to a solid edge. Turn it on in the panel to see it — Intensity stays hidden until you do, the same way the Framer control does.
Props
Content
- textstringrequired
What to set.
\nstarts a new line.- tagh1–h6 | p | span'p'
Element rendered.
Deformation
- scaleXnumber1.3
Horizontal scale at full intensity.
- scaleYnumber0.94
Vertical scale at full intensity.
- rangenumber120
Radius of influence, in pixels.
- paddingnumber0.08
Space opened around an active character, in em.
- strokeWidthnumber0.12
Outline thickness at full intensity, in em.
Colour
- colorstringvar(--text-primary)
Base colour. Any CSS colour, including a variable.
- hoverColorstring
Colour at full intensity. Omit to keep one colour.
Threshold filter
- thresholdbooleanfalse
Enable the fuse-together SVG filter.
- thresholdIntensitynumber100
Alpha cutoff for the filter. Higher is harder.
Spring
- stiffnessnumber600
Spring stiffness.
- dampingnumber50
Spring damping.
- massnumber1
Spring mass.
Convenience
- fontSizestring
Passthrough. Any CSS length.
- fontWeightnumber | string700
Passthrough.
- userSelectbooleanfalse
Allow selecting the text.
The defaults sit just past critical damping — with stiffness: 600 and mass: 1, critical is around 49, and the default damping: 50 is a hair above it. The result returns quickly with no visible overshoot. Drop damping to the low thirties if you want it to wobble.
React version
The demos on this page are the Astro build, because this site does not ship a client framework. The same component exists for React, and it is the version to take if you want to build on it.
It is one file with a single import — React itself. No motion library, no stylesheet, no build step, nothing to install. Copy it in and use it:
import FluidText from './FluidText'
export default function Hero() {
return <FluidText text="Fluid Text" as="h1" hoverColor="#ff6000" />
}Two differences from the props above. tag is called as, following React convention. fontSize and fontWeight are gone — pass style or className instead, since a React project already has a way to style things.
Everything else matches, including the defaults. Both versions run the same engine: rest positions measured once, one shared animation loop and one pointer listener for the whole page, spacing applied as a transform rather than as padding.
/** @jsxImportSource react */
import { createElement, useEffect, useId, useMemo, useRef } from 'react'
import type { CSSProperties, ElementType } from 'react'
/**
* Fluid Text — React
*
* Text that reacts to the pointer: characters near the cursor stretch, thicken,
* and push their neighbours aside, then settle back on a spring.
*
* Drop this file into any React project. The only import is React itself — no
* motion library, no stylesheet, no build step.
*
* @author Arsyad CC Fadly
* x.com/arsycc | arsyad.cc
* @version 2.0.0
*/
export type FluidTextProps = {
/** Text to render. `\n` starts a new line. */
text: string
/** Element to render. Defaults to `p`. */
as?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p' | 'span' | 'div'
/** Base colour. Any CSS colour, including a custom property. */
color?: string
/** Colour at full intensity. Omit to keep a single colour. */
hoverColor?: string
/** Horizontal scale at full intensity. */
scaleX?: number
/** Vertical scale at full intensity. */
scaleY?: number
/** Radius of influence, in pixels. */
range?: number
/** Space opened around an active character, in em. */
padding?: number
/** Outline thickness at full intensity, in em. */
strokeWidth?: number
/** Fuse neighbouring characters together with an SVG filter. */
threshold?: boolean
/** Alpha cutoff for the threshold filter. Higher snaps harder. */
thresholdIntensity?: number
stiffness?: number
damping?: number
mass?: number
/** Allow the text to be selected. */
userSelect?: boolean
className?: string
style?: CSSProperties
}
type Options = Required<
Pick<
FluidTextProps,
| 'color'
| 'scaleX'
| 'scaleY'
| 'range'
| 'padding'
| 'strokeWidth'
| 'threshold'
| 'thresholdIntensity'
| 'stiffness'
| 'damping'
| 'mass'
>
> & { hoverColor?: string }
type Char = {
el: HTMLElement
s: number
v: number
cx: number
cy: number
lastTransform: string
lastStroke: string
lastColor: string
}
type Instance = {
root: HTMLElement
lines: Char[][]
chars: Char[]
options: { current: Options }
blur: SVGElement | null
matrix: SVGElement | null
ts: number
tv: number
}
/* ------------------------------------------------------------------ engine */
/* One pointer listener and one animation loop are shared by every instance on
the page. Measuring a character's position is what costs — reading a rect
forces the browser to flush layout — so rest positions are measured once and
re-measured only when the line could have moved. */
const instances = new Set<Instance>()
const client = { x: NaN, y: NaN }
const pointer = { x: NaN, y: NaN }
let rafId: number | null = null
let lastTime = 0
let listening = false
let resizeTimer: ReturnType<typeof setTimeout> | undefined
function schedule() {
if (rafId == null) rafId = requestAnimationFrame(frame)
}
function syncPointer() {
if (Number.isNaN(client.x)) {
pointer.x = NaN
pointer.y = NaN
return
}
pointer.x = client.x + window.scrollX
pointer.y = client.y + window.scrollY
}
function onPointerMove(event: PointerEvent) {
if (event.pointerType === 'touch') return
client.x = event.clientX
client.y = event.clientY
syncPointer()
schedule()
}
function onPointerLeave() {
client.x = NaN
client.y = NaN
syncPointer()
schedule()
}
function onScroll() {
if (Number.isNaN(client.x)) return
syncPointer()
schedule()
}
function onResize() {
clearTimeout(resizeTimer)
resizeTimer = setTimeout(() => {
instances.forEach(measure)
schedule()
}, 150)
}
function startListening() {
if (listening) return
listening = true
window.addEventListener('pointermove', onPointerMove, { passive: true })
document.addEventListener('pointerleave', onPointerLeave, { passive: true })
window.addEventListener('scroll', onScroll, { passive: true })
window.addEventListener('resize', onResize, { passive: true })
}
function stopListening() {
if (!listening || instances.size > 0) return
listening = false
window.removeEventListener('pointermove', onPointerMove)
document.removeEventListener('pointerleave', onPointerLeave)
window.removeEventListener('scroll', onScroll)
window.removeEventListener('resize', onResize)
}
function measure(inst: Instance) {
for (const ch of inst.chars) {
ch.s = 0
ch.v = 0
ch.lastTransform = ''
ch.el.style.transform = ''
}
const scrollX = window.scrollX
const scrollY = window.scrollY
for (const ch of inst.chars) {
const rect = ch.el.getBoundingClientRect()
ch.cx = rect.left + scrollX + rect.width / 2
ch.cy = rect.top + scrollY + rect.height / 2
}
}
function integrate(value: number, velocity: number, target: number, o: Options, dt: number) {
const steps = Math.max(1, Math.ceil(dt / (1 / 120)))
const h = dt / steps
let s = value
let v = velocity
for (let i = 0; i < steps; i++) {
const a = (-o.stiffness * (s - target) - o.damping * v) / o.mass
v += a * h
s += v * h
}
return [s, v] as const
}
function step(inst: Instance, dt: number) {
const o = inst.options.current
const hasPointer = !Number.isNaN(pointer.x)
let alive = false
let peak = 0
for (const ch of inst.chars) {
let target = 0
if (hasPointer) {
const distance = Math.hypot(ch.cx - pointer.x, ch.cy - pointer.y)
if (distance < o.range) target = 1 - distance / o.range
}
if (target > peak) peak = target
const [s, v] = integrate(ch.s, ch.v, target, o, dt)
ch.s = s < 0 ? 0 : s > 1 ? 1 : s
ch.v = v
if (Math.abs(ch.s - target) > 1e-4 || Math.abs(ch.v) > 1e-4) alive = true
}
// Spacing is a translate, not padding. Padding would reflow the whole line
// every frame; a transform never touches layout. Each character carries the
// running total of the room its predecessors opened up, plus half its own.
for (const line of inst.lines) {
let acc = 0
for (const ch of line) {
const pad = o.padding * ch.s
const tx = acc + pad
acc += pad * 2
const sx = 1 + ch.s * (o.scaleX - 1)
const sy = 1 + ch.s * (o.scaleY - 1)
const transform = `translateX(${tx.toFixed(4)}em) scaleX(${sx.toFixed(4)}) scaleY(${sy.toFixed(4)})`
if (transform !== ch.lastTransform) {
ch.el.style.transform = transform
ch.lastTransform = transform
}
const stroke = `${(ch.s * o.strokeWidth).toFixed(4)}em`
if (stroke !== ch.lastStroke) {
ch.el.style.webkitTextStrokeWidth = stroke
ch.lastStroke = stroke
}
// Mixed in CSS rather than JavaScript so a custom property in either
// colour keeps resolving — a theme switch updates the text on its own.
if (o.hoverColor) {
const color = `color-mix(in oklab, ${o.hoverColor} ${(ch.s * 100).toFixed(2)}%, ${o.color})`
if (color !== ch.lastColor) {
ch.el.style.color = color
ch.lastColor = color
}
}
}
}
if (inst.blur && inst.matrix && o.threshold) {
const [ts, tv] = integrate(inst.ts, inst.tv, peak, { ...o, stiffness: 150, damping: 25, mass: 1 }, dt)
inst.ts = ts < 0 ? 0 : ts > 1 ? 1 : ts
inst.tv = tv
if (Math.abs(inst.ts - peak) > 1e-4 || Math.abs(inst.tv) > 1e-4) alive = true
inst.blur.setAttribute('stdDeviation', (0.5 + 2.5 * inst.ts).toFixed(3))
const cutoff = 50 + (o.thresholdIntensity - 50) * inst.ts
inst.matrix.setAttribute('values', `1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 255 -${cutoff.toFixed(2)}`)
}
return alive
}
function frame(now: number) {
rafId = null
const dt = lastTime ? Math.min((now - lastTime) / 1000, 1 / 20) : 1 / 60
lastTime = now
let alive = false
for (const inst of instances) {
if (step(inst, dt)) alive = true
}
if (alive) schedule()
else lastTime = 0
}
/* --------------------------------------------------------------- component */
const DEFAULTS = {
as: 'p',
color: 'currentColor',
scaleX: 1.3,
scaleY: 0.94,
range: 120,
padding: 0.08,
strokeWidth: 0.12,
threshold: false,
thresholdIntensity: 100,
stiffness: 600,
damping: 50,
mass: 1,
userSelect: false
} as const
export default function FluidText({
text,
as = DEFAULTS.as,
color = DEFAULTS.color,
hoverColor,
scaleX = DEFAULTS.scaleX,
scaleY = DEFAULTS.scaleY,
range = DEFAULTS.range,
padding = DEFAULTS.padding,
strokeWidth = DEFAULTS.strokeWidth,
threshold = DEFAULTS.threshold,
thresholdIntensity = DEFAULTS.thresholdIntensity,
stiffness = DEFAULTS.stiffness,
damping = DEFAULTS.damping,
mass = DEFAULTS.mass,
userSelect = DEFAULTS.userSelect,
className,
style
}: FluidTextProps) {
const rootRef = useRef<HTMLElement | null>(null)
// The filter is referenced by id, so two instances on one page must not share
// one. useId is unique per component instance; the colons it contains are not
// valid in a CSS selector, so they come out.
const filterId = `fluid-text-${useId().replace(/[^a-zA-Z0-9-]/g, '')}`
// Read live on every frame, so changing a prop takes effect without
// tearing the instance down and re-measuring.
const options = useRef<Options>({
color,
hoverColor,
scaleX,
scaleY,
range,
padding,
strokeWidth,
threshold,
thresholdIntensity,
stiffness,
damping,
mass
})
options.current = {
color,
hoverColor,
scaleX,
scaleY,
range,
padding,
strokeWidth,
threshold,
thresholdIntensity,
stiffness,
damping,
mass
}
const structure = useMemo(
() =>
text.split('\n').map((line) =>
line
.split(/(\s+)/)
.filter((token) => token.length > 0)
.map((token) => Array.from(token))
),
[text]
)
useEffect(() => {
const root = rootRef.current
if (!root) return
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
const lineEls = Array.from(root.querySelectorAll<HTMLElement>('[data-ft-line]'))
const lines = lineEls.map((lineEl) =>
Array.from(lineEl.querySelectorAll<HTMLElement>('[data-ft-char]')).map((el) => ({
el,
s: 0,
v: 0,
cx: 0,
cy: 0,
lastTransform: '',
lastStroke: '',
lastColor: ''
}))
)
const chars = lines.flat()
if (chars.length === 0) return
const filter = root.querySelector(`#${CSS.escape(filterId)}`)
const inst: Instance = {
root,
lines,
chars,
options,
blur: filter ? filter.querySelector('feGaussianBlur') : null,
matrix: filter ? filter.querySelector('feColorMatrix') : null,
ts: 0,
tv: 0
}
instances.add(inst)
measure(inst)
startListening()
let cancelled = false
if (document.fonts && document.fonts.status !== 'loaded') {
document.fonts.ready.then(() => {
if (cancelled) return
measure(inst)
schedule()
})
}
return () => {
cancelled = true
instances.delete(inst)
stopListening()
}
}, [structure, filterId])
const Tag = as as ElementType
return createElement(
Tag,
{
ref: rootRef,
'aria-label': text,
className,
style: {
position: 'relative',
margin: 0,
lineHeight: 1.1,
cursor: 'default',
color,
userSelect: userSelect ? 'auto' : 'none',
...style
} as CSSProperties
},
// One element per character is what the effect needs, which would otherwise
// make a screen reader spell the sentence out. The label above carries the
// real string and the nest below is hidden from assistive technology.
<span aria-hidden="true" style={{ display: 'block', filter: threshold ? `url(#${filterId})` : undefined }}>
{structure.map((line, lineIndex) => (
<span key={lineIndex} data-ft-line style={{ display: 'block' }}>
{line.map((word, wordIndex) => (
<span key={wordIndex} style={{ display: 'inline-block', whiteSpace: 'nowrap' }}>
{word.map((character, charIndex) => (
<span
key={charIndex}
data-ft-char
style={{ display: 'inline-block', whiteSpace: 'pre', WebkitTextStrokeColor: 'currentColor' }}
>
{character}
</span>
))}
</span>
))}
</span>
))}
</span>,
threshold ? (
<svg
key="defs"
aria-hidden="true"
focusable="false"
style={{ position: 'absolute', width: 0, height: 0, visibility: 'hidden', pointerEvents: 'none' }}
>
<defs>
<filter id={filterId}>
<feGaussianBlur in="SourceGraphic" stdDeviation="0.5" result="ft-blur" />
<feColorMatrix in="ft-blur" type="matrix" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 255 -50" />
</filter>
</defs>
</svg>
) : null
)
} How it moves
Three decisions do most of the work, and all three are downstream of refusing to touch layout.
Rest positions are measured once. Each character’s centre is measured when the component initialises, and again only on resize or when the webfont finishes loading. A cursor crossing the screen never triggers a measurement. The naive version reads getBoundingClientRect() per character per frame, which forces the browser to flush layout each time — on a forty-character headline that is eighty forced layouts per mouse move, and it is the single most expensive thing such a component can do.
The trade is that positions are the resting ones, not the live animated ones. Since the effect displaces characters as it runs, a live measurement would feed the animation back into its own input. Measuring at rest is both cheaper and steadier. It is also only available because nothing here changes the resting layout — an axis-driven version cannot make this optimisation, because with wght the resting layout is what moves.
One loop for the page. Every instance shares a single requestAnimationFrame loop and a single pointermove listener. The loop stops when every spring has settled and restarts on the next pointer move, so an idle page runs no animation frames at all.
Only transforms and paint. Per frame, each character gets two custom properties written to it — the spring value and its horizontal offset. CSS turns those into a transform, a stroke width, and a colour mix. No layout is read and none is invalidated.
Instances also register with an IntersectionObserver and leave the active set when scrolled out of view.
Reduced motion and reading
If prefers-reduced-motion: reduce is set, the script never initialises and CSS zeroes the transform. The text renders as plain type.
Because the effect needs one element per character, the markup is a nest of spans. The container carries aria-label with the original string and the whole nest is aria-hidden, so assistive technology reads the sentence rather than spelling it. Selection is off by default — userSelect turns it back on, and copied text comes out intact.
The effect is pointer-driven, so it does nothing on touch. That is deliberate: the original tracked mousemove from an initial position of 0, 0, which on a phone left the characters nearest the top-left corner permanently distorted, since no mouse event ever arrived to correct it. Here there is no state until a real pointer appears, and pointerType: 'touch' is ignored outright.
Notes
Text is split on whitespace before it is split into characters, and each word is an inline-block, so lines still wrap on word boundaries.
The SVG filter is referenced by id. Each instance generates its own at build time — two components sharing one filter id would otherwise fight over the same element, which is easy to miss until the second demo lands on the page.
The two axis demos above are a separate, much smaller component built for this post. It runs the same falloff and the same spring as Fluid Text and differs in one line: where Fluid Text writes a transform, it writes font-variation-settings. That is the whole distance between a real axis and a convincing one.