Index

Fluid Text

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.

Double-click to edit; Enter commits, Escape reverts. Every control maps to a single number in the component — range is the radius of influence in pixels, spacing is how far letters move apart, in em.

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.

The weight axis, requested per character, driven by the cursor. Nothing happens. The file carries no axis, so the browser drops the request and the marker never leaves the end of the word — no fallback, no warning, no console entry.

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.

Fluid Text, in the same font and at the same weight as the line above. scaleX does the width axis, an outline stroke does the weight axis. The line box is fixed for the whole animation — the characters move by transform, so there is nothing to lay out.

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 axisFluid Text
Fonts it works onOnes that carry the axisAny font, including static ones
Widthwdth, if it existsscaleX, always
Weightwght, drawn by the designerStroke on the outline
Cost per frameLayout, paintPaint
FidelityExactConvincing

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.

Range 60 — tight, per-letter. The effect stays local to the cursor.

Range 320 — the entire line responds at once, more like a wave than a spotlight.

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 in em, 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.

scaleX 1.6, scaleY 0.88, stroke 0.2 — pushed well past the defaults. This is where the fake starts showing: the counters in 'e' and 'r' are closing, because the stroke grows inward as well as outward.

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.

Colour is mixed in CSS with color-mix(), not computed in JavaScript. Switch the site theme with the toggle in the header and the text follows.

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. \n starts 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.