Skip to content

aannoo/cool-text-fit

Repository files navigation

CoolTextFit

Single-line text fitting! Variable fonts, ink-box bounds, height-aware fitting, and an algebraic solver that makes resize smooth.

new CoolTextFit().fit(element)

CoolTextFit demo


Why CoolTextFit?

Other text-fitting libraries loop through font sizes until the text fits. CoolTextFit doesn't.

Instead of brute-forcing font-size, it uses a 4-lever optimization hierarchy: fontSize, then fontWidth (the variable font wdth axis), then letterSpacing, then scaleX as a last resort. Text compresses and expands through the font's own design, not geometric distortion.

A model-cached algebraic solver means the first fit takes ~7-15 measurements, and every subsequent refit (window resize, content change) uses math and one verification measurement. No layout thrashing.

8 KB gzipped. Zero dependencies.

Demo

https://aannoo.github.io/cool-text-fit/

Installation

npm install cool-text-fit

Or via CDN:

<script src="https://unpkg.com/cool-text-fit"></script>
<script>
  const { CoolTextFit: CTF } = CoolTextFit
  const ctf = new CTF()
  ctf.fit(document.querySelector('#title'))
</script>

TypeScript declarations are included (dist/cool-text-fit.d.ts).

Quick start

<div class="card">
  <div id="title" style="font-family: InterVariable, system-ui; font-weight: 700;">
    Variable width axis fit
  </div>
</div>
import { CoolTextFit } from 'cool-text-fit'

const ctf = new CoolTextFit()
ctf.fit(document.querySelector('#title'))

That single call auto-detects your font's variable width range, picks the right measurement strategy, infers alignment from layout, sets up resize/mutation observers, and handles font loading.

CoolTextFit fits the element's text into its parent element's content box (padding and border are excluded).

Features

Variable font width

Most libraries only touch font-size. CoolTextFit manipulates the wdth variation axis so text narrows or widens natively, not the squished-text look you get from transform: scaleX(). It auto-detects the font's supported width range.

Three fitting modes

Mode Behavior
width Maximise font size to fill container width. Height may overflow.
height Maximise font size to fill container height. Width may overflow.
balanced Largest font size that fits both width and height.

Ink-box measurement

textBounds: 'ink-box' measures actual glyph bounds (actualBoundingBoxAscent/Descent), not the line box. Text fills the container edge-to-edge without phantom whitespace above caps or below the baseline.

Canvas/DOM strategy switching

Feature-detects whether Canvas can handle variable fonts and letter-spacing for the current browser + font combination. Falls back to DOM measurement only when necessary. No browser sniffing.

O(1)-ish performance

Three-point interpolation seeds a narrow binary search. Results are cached as a linear model (k = width / fontSize), so refits resolve algebraically with a single verification measurement.

Auto observers

ResizeObserver and MutationObserver are wired up automatically. Resize the container or change the text and CoolTextFit refits, with configurable debouncing.

Font loading

Detects when a web font finishes loading and refits all affected elements with correct metrics.

CSS text-transform

Respects text-transform on the element (uppercase, lowercase, capitalize). Text is measured using the transformed value so the fit matches what the browser renders.

Configuration

Pass options to the constructor (instance-wide defaults) or to .fit() (per-element overrides):

const ctf = new CoolTextFit({
  mode: 'balanced',           // 'width' | 'height' | 'balanced'
  textBounds: 'line-box',     // 'line-box' | 'ink-box'
  fontSize:  { min: 14, max: 200 },
  scaleX:    { min: 0.5, max: 2 },
  letterSpacing: { max: 60 },
  fontWidth: 'auto',          // 'auto' | { min: 75, max: 125 }
  alignment: 'auto',          // 'auto' | 'left' | 'center' | 'right'
  observe: true,              // attach resize/mutation observers
  debounceMs: 0,              // observer debounce (0 = next rAF)
  waitForFonts: false         // delay first fit until font loads
})

// Per-element override
ctf.fit(el, { fontSize: { max: 120 }, mode: 'width' })

directFit() returns a FitResult object with the computed values (fontSize, fontWidth, scaleX, letterSpacing, finalWidth, finalHeight, fits, error). fit() does not return a result.

Options reference

Option Type Default Description
mode string 'balanced' Fitting constraint mode.
textBounds string 'line-box' Use 'ink-box' for glyph-tight bounds.
fontSize { min, max } { min: 14, max: 200 } Font size search range in px.
scaleX { min, max } { min: 0.5, max: 2 } Horizontal scale limits.
letterSpacing { max } { max: 60 } Maximum letter spacing in px. Minimum is always 0.
fontWidth 'auto' | { min, max } 'auto' Variable font wdth axis range. 'auto' probes the font.
alignment string 'auto' Transform origin alignment. 'auto' infers from layout.
observe boolean true Auto-refit on resize/content change.
debounceMs number 0 Debounce delay for observer-triggered refits.
waitForFonts boolean false Wait for font load before first fit.

How it works

  1. Sample — Measure text at three font sizes (min, mid, max) to establish a width-to-fontSize ratio (k).
  2. Interpolate — Estimate the optimal font size from the ratio and container width (plus height, in balanced/height modes).
  3. Refine — Binary search in a ±5 px window around the estimate.
  4. Optimise — For the winning font size, solve fontWidthletterSpacingscaleX algebraically, each closing the remaining gap.
  5. Cache — Store the k model. On refit, skip to step 4 with one measurement to verify.

Architecture

Three layers, so you can use exactly as much as you need:

CoolTextFit          ← Full auto: observers, font detection, alignment inference
  └─ CoolTextFitBase ← Manual API: explicit config, no observers
       └─ CoolTextFitCore  ← Pure math: zero DOM, portable to workers/Node
// Layer 1 — Pure algorithm (no DOM)
import { CoolTextFitCore } from 'cool-text-fit'

// Layer 2 — Manual control (requires explicit fontWidth + alignment)
import { CoolTextFitBase } from 'cool-text-fit'

// Layer 3 — Full auto (default)
import { CoolTextFit } from 'cool-text-fit'

// Default export (same as CoolTextFit)
import CoolTextFit from 'cool-text-fit'

Using CoolTextFitBase

CoolTextFitBase requires explicit fontWidth and alignment, no "auto".

init() triggers font loading via document.fonts.load() and waits for the font to be ready. Call it before directFit() if your font hasn't loaded yet. It accepts an array of { element } objects and deduplicates by font family.

import { CoolTextFitBase } from 'cool-text-fit'

const base = new CoolTextFitBase()

// Optional: pre-load fonts before fitting
await base.init([{ element: el }])

base.directFit(el, {
  fontWidth: { min: 75, max: 125 },
  alignment: 'center',
  mode: 'balanced',
  textBounds: 'line-box',
  fontSize: { min: 12, max: 120 },
  scaleX: { min: 0.8, max: 1.2 },
  letterSpacing: { max: 10 },
})

Framework usage

The pattern is the same everywhere: get a ref to the DOM element, call fit() after mount, call disconnect() on unmount.

React
import { useRef, useEffect } from 'react'
import { CoolTextFit } from 'cool-text-fit'

const ctf = new CoolTextFit()

function FitTitle({ text }) {
  const ref = useRef(null)

  useEffect(() => {
    ctf.fit(ref.current)
    return () => ctf.disconnect(ref.current)
  }, [text])

  return (
    <div className="card">
      <div ref={ref}>{text}</div>
    </div>
  )
}
Vue
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { CoolTextFit } from 'cool-text-fit'

const el = ref(null)
const ctf = new CoolTextFit()

onMounted(() => ctf.fit(el.value))
onBeforeUnmount(() => ctf.disconnect(el.value))
</script>

<template>
  <div class="card">
    <div ref="el">{{ text }}</div>
  </div>
</template>
Svelte
<script>
  import { onMount } from 'svelte'
  import { CoolTextFit } from 'cool-text-fit'

  let el
  const ctf = new CoolTextFit()

  onMount(() => {
    ctf.fit(el)
    return () => ctf.disconnect(el)
  })
</script>

<div class="card">
  <div bind:this={el}>{text}</div>
</div>
Solid
import { onMount, onCleanup } from 'solid-js'
import { CoolTextFit } from 'cool-text-fit'

const ctf = new CoolTextFit()

function FitTitle(props) {
  let el

  onMount(() => ctf.fit(el))
  onCleanup(() => ctf.disconnect(el))

  return (
    <div class="card">
      <div ref={el}>{props.text}</div>
    </div>
  )
}

CoolTextFit's built-in MutationObserver handles text changes automatically, so you don't need to refit manually when props update (unless observe: false).

What CoolTextFit does to the DOM

CoolTextFit applies several changes to your element:

  • Sets white-space: nowrap on the element.
  • Wraps content in a span.cool-text-fit-wrapper (display: inline-block).
  • Applies font-size, font-variation-settings (merging in wdth), letter-spacing, text-indent + padding-left (letter-spacing compensation), text-align, transform (translateX/Y + scaleX), and transform-origin to the wrapper.
  • In ink-box mode, also sets height on the element and margin-left/margin-right on the wrapper for glyph-tight horizontal trimming, and applies translateY for vertical centering.
  • disconnect() restores the element's original inline white-space, height, and visibility values.
  • For contenteditable elements, orphan text nodes are adopted back into the wrapper, direct child div and p elements are flattened, and placeholder br nodes are removed on each fit.

If you style the element's text directly, target the wrapper too:

.your-title,
.your-title .cool-text-fit-wrapper {
  /* your typography */
}

Cleanup

// Stop observers for one element
ctf.disconnect(element)

// Stop all observers
ctf.disconnectAll()

// Full teardown (removes internal DOM nodes, clears caches)
ctf.cleanup()

Gotchas

  • Parent must have a real size. If the parent container is display: none or has zero width/height, fitting will fail.
  • Variable font required for wdth. If the font doesn't support wdth, CoolTextFit operates as if fontWidth is { min: 100, max: 100 }.
  • Ink-box is tighter. Use 'ink-box' for edge-to-edge visual alignment; use 'line-box' for standard typographic line behavior.

Browser support

Works in all modern browsers. Canvas-based measurement is preferred where supported; DOM fallback handles edge cases automatically.

License

MIT