Skip to main content
Tillbaka till bloggen
CSSContainer QueriesTypographyFrontendPerformance

Anpassa text till sin container med ren CSS — utan JavaScript

Alla skriver en JavaScript-hook som krymper font-size tills texten får plats i en container. Här är ett fullt fungerande alternativ helt utan JS — med container query-enheter, clamp() och språkmedveten avstavning, inklusive kaskadfällan som får folk att ge upp.

Publicerad 24 juli 202611 min läsning

Stora display-rubriker har en irriterande vana: texten som passar perfekt i din design svämmar över så fort containern blir smalare eller texten blir längre på ett annat språk. I åratal har standardlösningen varit JavaScript — mäta den renderade texten, jämföra den med sin container och krympa typsnittet tills det får plats. Nästan alla uppfinner detta på nytt: en hemmagjord useFitText-hook, en ResizeObserver-loop eller ett bibliotek som fitty, textFit eller react-textfit.

Den här artikeln visar ett fullt fungerande alternativ med noll JavaScript: text som skalar till sin container med CSS container query-enheter, plus korrekt radbrytning med avstavning. Inga hooks, ingen mätning, ingen layoutförskjutning efter hydration.

JavaScriptet som alla skriver

Mönstret är alltid detsamma. Rendera texten i någon basstorlek, läs textens bredd och dess förälders bredd, och om texten är bredare — minska teckenstorleken och försök igen. En minimal version ser ut så här:

useFitText.ts
import { useLayoutEffect, useRef, useState } from "react";

// Shrinks font-size by 1px until the text fits its parent.
export function useFitText() {
  const parentRef = useRef<HTMLElement>(null);
  const textRef = useRef<HTMLElement>(null);
  const [fontSize, setFontSize] = useState<number>();

  useLayoutEffect(() => {
    const parent = parentRef.current;
    const text = textRef.current;
    if (!parent || !text) return;

    const current = parseFloat(getComputedStyle(text).fontSize);
    if (parent.offsetWidth < text.offsetWidth) {
      setFontSize(current - 1); // re-render, measure again
    }
  }, [fontSize]);

  return { parentRef, textRef, fontSize };
}

Det fungerar, men har verkliga kostnader:

  • Det körs efter att komponenten monterats, så texten ritas först i fel storlek och hoppar sedan — en synlig layoutförskjutning och ett slag mot CLS.
  • I ramverk med serverrendering skickar servern en storlek och klienten korrigerar den efter hydration, vilket kan orsaka en blinkning.
  • Det levererar JavaScript för något som i grunden är en layoutfråga.
  • Loopen som går 1px i taget orsakar flera omrenderingar och tvingar fram synkrona layoutläsningar — precis den sortens arbete man inte vill ha på huvudtråden.

Varför JavaScript var svaret — fram tills nyligen

Det är värt att vara ärlig om varför dessa hooks finns. Innan container queries kom i webbläsarna kunde CSS helt enkelt inte dimensionera ett typsnitt relativt ett element. Man kunde dimensionera relativt viewporten med vw, men en rubrik i en 400px bred sidopanel och samma rubrik i en 1200px bred hero skulle få identisk vw-baserad storlek, eftersom vw inte vet något om containern.

Container query-längdenheter ändrade det. De blev säkra över webbläsare först runt 2023, så all kod för typsnittsanpassning skriven innan dess hade inget CSS-alternativ och tog till JavaScript av nödvändighet. Den begränsningen är nu borta — vilket är hela poängen med den här artikeln.

Byggstenen: container query-enheter

En container query-längdenhet är en procentandel av en query-containers storlek. Markera ett element som container med container-type: inline-size, så kan dess ättlingar använda cqi — där 1cqi motsvarar 1% av containerns inline-storlek (dess bredd i horisontella skrivriktningar).

.card {
  container-type: inline-size; /* this element is now a query container */
}

.card .title {
  /* 10% of .card's width — scales with the container, not the viewport */
  font-size: 10cqi;
}

Nu är samma rubrik i ett 400px-kort 40px, och i ett 1200px-kort 120px — automatiskt, utan mätning. Det här är biten som saknades före container queries: en teckenstorlek som svarar på föräldern.

Begränsa storleken med clamp()

Rått cqi skulle fortsätta växa på enorma containrar och krympa till intet på små. Linda in det i clamp() för att sätta ett golv och ett tak — vanligtvis din mobilstorlek som minimum och din desktopstorlek som maximum:

.card {
  container-type: inline-size;
}

.card .title {
  font-size: clamp(
    2rem,   /* min — never smaller than the mobile size */
    10cqi,  /* fluid — scales with the container width   */
    4.5rem  /* max — never larger than the desktop size  */
  );
}

Läs det som: var flytande med containern, men aldrig under 2rem och aldrig över 4.5rem. Mittentermen (cqi-lutningen) styr hur snabbt typsnittet skalar; de två gränserna är designens mobil- och desktopstorlekar. Om de storlekarna finns i custom properties kan du styra allt utifrån dem:

.title {
  --size-desktop: 72;
  --size-mobile: 32;
}

.title .fit {
  font-size: clamp(
    calc(var(--size-mobile) * 1px),
    10cqi,
    calc(var(--size-desktop) * 1px)
  );
}

En fälla som kostar dig en timme: kaskaden

Här är fällan som får folk att ge upp och dra slutsatsen: "container queries fungerar inte". Containern löses ut korrekt, enheten är rätt, och ändå ändrar typsnittet aldrig storlek. Orsaken är nästan alltid kaskaden, inte container queries.

Symptomet är lömskt vilseledande. Ditt typsnitt sitter fastnaglat vid sitt maximum i varje container, hur smal den än är — vilket är exakt hur cqi beter sig när det inte hittar någon container och faller tillbaka på viewporten. Så du går och kollar det uppenbara: är containern rätt uppsatt? Du bekräftar att container-type: inline-size finns, du går igenom DOM för att verifiera att förfadern verkligen är containern, du läser elementets beräknade container-type och det säger inline-size, du mäter containerns bredd och den stämmer. Allt stämmer. Och typsnittet sitter fortfarande fast. Du letar på fel ställe.

Den snabbaste vägen ut är att sluta lita på din egen regel och bevisa enheten oberoende. Släpp in en engångssond i samma container med inget annat än ett rått cqi-värde, och mät den:

// Inject a bare probe as a child of the same container:
const probe = document.createElement("span");
probe.style.fontSize = "10cqi";
container.append(probe);

getComputedStyle(probe).fontSize;
// 90px inside a 900px container, 36px inside 360px → cqi works fine.

probe.remove();

Om sonden skalar korrekt men ditt riktiga element förblir fastnaglat, är det hela diagnosen: container queries fungerar perfekt, och något annat skriver över din font-size. Det är kaskaden — och boven är oftast en bredare selektor du glömt bort.

Designsystem sätter ofta typografi på nästlade element med en regel som .title p, .title span { font-size: … }. Den selektorn har en specificitet på (0,1,1). Om din fit-regel riktar sig mot samma element med en enda klass — .fit { … } med (0,1,0) — vinner designsystemets regel och naglar fast teckenstorleken, så att din clamp() tyst skrivs över. Båda kan till och med ligga i samma kaskadlager, så layers räddar dig inte. Värre än så: den bredare regeln var förmodligen viewport-baserad, vilket är varför storleken såg ut som en viewport-fallback — du såg bokstavligen en annan regel vinna.

Lösningen är att ge din regel minst lika hög specificitet. Att nästla den under container-klassen är enklast — .title .fit är (0,2,0), vilket slår .title p(0,1,1):

/* Loses to `.title p { font-size: … }` — same layer, higher specificity */
.fit { font-size: clamp(2rem, 10cqi, 4.5rem); }

/* Wins: (0,2,0) > (0,1,1) */
.title .fit { font-size: clamp(2rem, 10cqi, 4.5rem); }

Om din container query-teckenstorlek verkar ignoreras, inspektera elementet och kolla vilken regel som faktiskt sätter <code>font-size</code>. Nio gånger av tio vinner en bredare designsystem-selektor kaskaden — själva container queryn är helt okej.

Problemet med långa ord — och varför det inte är ett container query-fel

Det finns en sak cqi verkligen inte kan: det skalar teckenstorleken efter containerns bredd, inte efter längden på en specifik sträng. Ett enda obrytbart ord kan fortfarande vara bredare än containern vid den beräknade storleken. Tyskan är den klassiska boven — ett ord som Fremdsprachenkenntnisse svämmar gladeligen över en smal ruta även vid en container-lämplig teckenstorlek.

Det här är inte en bugg i metoden; det är dess ärliga gräns. Den gamla JavaScript-hooken hanterade fallet genom att mäta just det ordet och krympa vidare. I CSS hanterar du det annorlunda — du låter ordet brytas i stället för att krympa hela rubriken. Två egenskaper gör jobbet:

.title .fit {
  font-size: clamp(2rem, 10cqi, 4.5rem);

  /* Break a too-long word instead of overflowing the box */
  overflow-wrap: break-word;

  /* Balance line lengths for nicer multi-line headings */
  text-wrap: balance;
}

overflow-wrap: break-word är skyddsnätet: om ett ord inte får plats på en rad bryter webbläsaren det i stället för att låta det svämma över. text-wrap: balance är finputsningen — det jämnar ut radlängderna så att en bruten rubrik inte slutar med ett ensamt ord.

Bryta med bindestreck — automatiskt, per språk

Att bryta ett ord mitt itu utan bindestreck ser klumpigt ut. CSS kan infoga ett riktigt bindestreck vid en språkligt giltig punkt med hyphens: auto. Haken värd att förstå: avstavning är språkmedveten. Webbläsaren använder avstavningsordlistan för elementets språk, som den läser från lang-attributet.

.title .fit {
  font-size: clamp(2rem, 10cqi, 4.5rem);
  hyphens: auto;              /* insert a real hyphen at valid break points */
  overflow-wrap: break-word;  /* last-resort break for words with no valid point */
  text-wrap: balance;
}

Det eleganta: om ditt dokument redan sätter språket på roten — <html lang="de"> för en tysk sida — fungerar avstavningen bara, per språk, utan extra kabeldragning. Tyska sidor avstavas med den tyska ordlistan, engelska med den engelska. Du får korrekta bindestreck på varje språk gratis, så länge lang speglar innehållet.

Behåll overflow-wrap: break-word bredvid. hyphens: auto hanterar ord den vet hur man avstavar; fallbacken fångar allt övrigt (URL:er, varumärkesnamn, påhittade sammansättningar) så att inget någonsin svämmar över.

Att sätta ihop allt

Här är den kompletta, beroendefria komponenten. Notera tvåelementsstrukturen: ett yttre element som är containern, och ett inre element som läser cqi från den. Ett element kan inte dimensionera sitt eget typsnitt utifrån sin egen container, så du behöver uppdelningen förälder/barn.

<h2 class="title" style="--size-desktop: 72; --size-mobile: 32">
  <span class="fit">Learn anything, beautifully</span>
</h2>
title.css
.title {
  /* The container the inner text scales against */
  container-type: inline-size;
}

/* Nested selector so it out-specifies design-system typography rules */
.title .fit {
  display: block;

  /* Fluid between the mobile and desktop sizes, driven by container width */
  font-size: clamp(
    calc(var(--size-mobile) * 1px),
    10cqi,
    calc(var(--size-desktop) * 1px)
  );

  /* Never overflow: break long words, hyphenate by language, balance lines */
  overflow-wrap: break-word;
  hyphens: auto;
  text-wrap: balance;
}

Det är hela funktionen: en rubrik som skalar med sin container, respekterar ett mobilgolv och ett desktoptak, aldrig svämmar över och bryts med korrekta bindestreck på vilket språk som helst — och den levererar inte en enda byte JavaScript.

CSS mot JavaScript, ärligt talat

AspektJS fit-text-hook / -bibliotekRen CSS (cqi + clamp)
Svarar på containerns breddJa (via mätning)Ja (via cqi)
Svarar på exakt stränglängdJaNej — begränsas i stället av overflow-wrap / hyphens
Layoutförskjutning efter laddningVanligt (mät sedan skala)Ingen — korrekt vid första paint
Fungerar vid SSR före hydrationNejJa
Levererad JavaScriptJaIngen
Kostnad på huvudtrådenReflows + omrenderingarNoll
Språkmedveten avstavningManuellInbyggd via hyphens: auto

Den enda kolumnen där JavaScript fortfarande vinner är exakt anpassning per sträng — att tvinga en specifik rubrik till exakt en rad i den största storlek som får plats. Om det precisa beteendet är ett hårt krav är mätning (JS, eller en SVG-<text> som skalar till en viewBox) fortfarande enda vägen. Men för den stora majoriteten av display-rubriker ser containerrelativ dimensionering lika bra eller bättre ut — utan någon av kostnaderna.

Webbläsarstöd

Container query-längdenheter (cqi med släktingar) stöds i alla nuvarande evergreen-webbläsare, och har gjort det sedan 2023. clamp(), overflow-wrap, hyphens och text-wrap: balance är också brett tillgängliga (text-wrap: balance är nyast och degraderas elegant till normal radbrytning där det saknas). För en fallback på mycket gamla webbläsare räcker en enkel font-size deklarerad före clamp()-raden.

Sammanfattning

  • Typsnittsanpassnings-JavaScriptet som alla skriver finns mest för att CSS inte kunde dimensionera typografi per container förrän container queries kom.
  • container-type: inline-size + font-size: clamp(min, Ncqi, max) ger containerrelativ typografi med ett mobilgolv och ett desktoptak.
  • Om storleken verkar ignoreras är det kaskaden — överträffa specificiteten hos breda designsystem-selektorer genom att nästla din regel.
  • cqi skalar efter containerns bredd, inte stränglängd; använd overflow-wrap: break-word och hyphens: auto så att långa ord bryts snyggt i stället för att svämma över.
  • hyphens: auto är språkmedvetet gratis när dokumentets lang är korrekt satt.
  • Ta till JavaScript bara när du verkligen behöver exakt anpassning med en rad per sträng.

Det är nu publicerat — två pyttesmå, beroendefria paket: @oleksiimazurenko/react-fit-text för React och @oleksiimazurenko/fit-text för den ramverksagnostiska CSS-kärnan (källkod på GitHub):

npm install @oleksiimazurenko/react-fit-text
import { FitText } from '@oleksiimazurenko/react-fit-text'
import '@oleksiimazurenko/fit-text/style.css'

// No props needed — scales to its container with sensible defaults.
<FitText>Learn anything, beautifully</FitText>