Skip to main content
Zurück zum Blog
CSSContainer QueriesTypographyFrontendPerformance

Text mit reinem CSS an seinen Container anpassen — ohne JavaScript

Jeder schreibt einen JavaScript-Hook, der die font-size verkleinert, bis der Text in einen Container passt. Hier ist eine voll funktionsfähige Alternative ganz ohne JS — mit Container-Query-Einheiten, clamp() und sprachabhängiger Silbentrennung, samt der Kaskaden-Falle, an der viele aufgeben.

Veröffentlicht 24. Juli 202611 Min. Lesezeit

Große Display-Überschriften haben eine lästige Angewohnheit: Der Text, der in deinem Design perfekt passt, läuft über, sobald der Container schmaler wird oder der Text in einer anderen Sprache länger ausfällt. Jahrelang war die Standardlösung JavaScript — den gerenderten Text messen, mit seinem Container vergleichen und die Schrift verkleinern, bis sie passt. Fast jeder erfindet das neu: ein selbstgebauter useFitText-Hook, eine ResizeObserver-Schleife oder eine Bibliothek wie fitty, textFit oder react-textfit.

Dieser Artikel zeigt eine voll funktionsfähige Alternative mit null JavaScript: Schrift, die sich mit CSS-Container-Query-Einheiten an ihren Container anpasst, plus sauberer Zeilenumbruch mit Silbentrennung. Keine Hooks, kein Messen, kein Layout-Sprung nach der Hydration.

Das JavaScript, das jeder schreibt

Das Muster ist immer dasselbe. Den Text in einer Ausgangsgröße rendern, die Breite des Textes und die seines Elternelements auslesen, und wenn der Text breiter ist, die Schriftgröße verringern und es erneut versuchen. Eine minimale Version sieht so aus:

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 };
}

Es funktioniert, hat aber echte Kosten:

  • Es läuft nach dem Mounten der Komponente, sodass der Text zunächst in der falschen Größe gezeichnet wird und dann springt — ein sichtbarer Layout-Sprung und ein Schlag für den CLS.
  • In Frameworks mit Server-Rendering schickt der Server eine Größe, und der Client korrigiert sie nach der Hydration, was ein Aufblitzen verursachen kann.
  • Es liefert JavaScript für etwas aus, das im Kern eine Layout-Angelegenheit ist.
  • Die 1px-Schritt-für-Schritt-Schleife verursacht mehrere Re-Renders und erzwingt synchrone Layout-Lesevorgänge — genau die Art Arbeit, die man nicht auf dem Main-Thread haben will.

Warum JavaScript die Antwort war — bis vor Kurzem

Man sollte ehrlich sein, warum es diese Hooks gibt. Bis Container-Queries browserübergreifend verfügbar waren, konnte CSS eine Schrift schlicht nicht relativ zu einem Element dimensionieren. Man konnte relativ zum Viewport mit vw dimensionieren, aber eine Überschrift in einer 400px breiten Sidebar und dieselbe Überschrift in einem 1200px breiten Hero bekämen die identische vw-basierte Größe, weil vw nichts über den Container weiß.

Container-Query-Längeneinheiten haben das geändert. Sie wurden erst um 2023 herum browserübergreifend sicher nutzbar, sodass jeder vorher geschriebene Code zum Anpassen von Schrift keine CSS-Option hatte und aus Notwendigkeit zu JavaScript griff. Diese Einschränkung ist nun weg — und genau darum geht es in diesem Artikel.

Der Baustein: Container-Query-Einheiten

Eine Container-Query-Längeneinheit ist ein Prozentsatz der Größe eines Query-Containers. Markiere ein Element mit container-type: inline-size als Container, und seine Nachfahren können cqi verwenden — wobei 1cqi 1% der Inline-Größe des Containers entspricht (seiner Breite bei horizontalen Schreibrichtungen).

.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;
}

Jetzt ist derselbe Titel in einer 400px-Karte 40px groß und in einer 1200px-Karte 120px — automatisch, ohne Messen. Das ist der Baustein, der vor den Container-Queries fehlte: eine Schriftgröße, die auf das Elternelement reagiert.

Die Größe mit clamp() begrenzen

Rohes cqi würde auf riesigen Containern immer weiter wachsen und auf winzigen auf nichts schrumpfen. Verpacke es in clamp(), um eine Unter- und eine Obergrenze zu setzen — typischerweise deine Mobilgröße als Minimum und deine Desktop-Größe als 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  */
  );
}

Lies es als: sei fließend mit dem Container, aber niemals unter 2rem und niemals über 4.5rem. Der mittlere Term (die cqi-Steigung) steuert, wie schnell die Schrift skaliert; die beiden Grenzen sind die Mobil- und Desktop-Schriftgrößen deines Designs. Wenn diese Größen in Custom Properties liegen, kannst du alles darüber steuern:

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

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

Eine Falle, die dich eine Stunde kostet: die Kaskade

Hier ist die Falle, an der viele aufgeben und schlussfolgern: "Container-Queries funktionieren nicht." Der Container wird korrekt aufgelöst, die Einheit stimmt, und trotzdem ändert die Schrift nie ihre Größe. Die Ursache ist fast immer die Kaskade, nicht die Container-Queries.

Das Symptom ist hinterhältig irreführend. Deine Schrift bleibt in jedem Container auf ihrem Maximum festgenagelt, egal wie schmal er ist — was genau dem Verhalten von cqi entspricht, wenn es keinen Container findet und auf den Viewport zurückfällt. Also prüfst du das Naheliegende: Ist der Container richtig eingerichtet? Du bestätigst, dass container-type: inline-size da ist, du gehst das DOM durch, um zu verifizieren, dass der Vorfahr wirklich der Container ist, du liest das berechnete container-type des Elements aus und es sagt inline-size, du misst die Breite des Containers und sie stimmt. Alles passt. Und die Schrift klebt immer noch fest. Du suchst an der falschen Stelle.

Der schnellste Ausweg ist, deiner eigenen Regel nicht mehr zu vertrauen und die Einheit unabhängig zu beweisen. Setze eine wegwerfbare Sonde in denselben Container, die nichts als einen rohen cqi-Wert enthält, und miss sie:

// 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();

Wenn die Sonde korrekt skaliert, dein echtes Element aber festgenagelt bleibt, ist das die ganze Diagnose: Die Container-Queries funktionieren einwandfrei, und etwas anderes überschreibt deine font-size. Es ist die Kaskade — und der Übeltäter ist meist ein breiterer Selektor, den du vergessen hast.

Design-Systeme setzen Typografie oft auf verschachtelten Elementen mit einer Regel wie .title p, .title span { font-size: … }. Dieser Selektor hat eine Spezifität von (0,1,1). Wenn deine Fit-Regel dasselbe Element mit einer einzelnen Klasse anspricht — .fit { … } mit (0,1,0) — gewinnt die Design-System-Regel und nagelt die Schriftgröße fest, sodass dein clamp() stillschweigend überschrieben wird. Beide können sogar in derselben Kaskadenschicht liegen, sodass Layer dich nicht retten. Schlimmer noch: Diese breitere Regel war wahrscheinlich viewport-basiert, weshalb die Größe wie ein Viewport-Fallback aussah — du hast buchstäblich gesehen, wie eine andere Regel gewinnt.

Die Lösung besteht darin, deiner Regel mindestens gleiche Spezifität zu geben. Sie unter der Container-Klasse zu verschachteln ist der einfachste Weg — .title .fit ist (0,2,0), was .title p mit (0,1,1) schlägt:

/* 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); }

Wenn deine Container-Query-Schriftgröße ignoriert zu werden scheint, inspiziere das Element und prüfe, welche Regel tatsächlich <code>font-size</code> setzt. In neun von zehn Fällen gewinnt ein breiterer Design-System-Selektor die Kaskade — die Container-Query selbst ist in Ordnung.

Das Problem mit langen Wörtern — und warum es kein Versagen der Container-Queries ist

Es gibt eine Sache, die cqi wirklich nicht kann: Es skaliert die Schriftgröße nach der Breite des Containers, nicht nach der Länge einer bestimmten Zeichenkette. Ein einzelnes untrennbares Wort kann bei der berechneten Größe immer noch breiter sein als der Container. Das Deutsche ist der klassische Übeltäter — ein Wort wie Fremdsprachenkenntnisse läuft selbst bei container-angemessener Schriftgröße bereitwillig über eine schmale Box hinaus.

Das ist kein Fehler im Ansatz; es ist seine ehrliche Grenze. Der alte JavaScript-Hook behandelte diesen Fall, indem er genau dieses Wort maß und weiter verkleinerte. In CSS behandelst du es anders — du lässt das Wort umbrechen, statt die gesamte Überschrift zu verkleinern. Zwei Eigenschaften erledigen das:

.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 ist das Sicherheitsnetz: Wenn ein Wort nicht auf eine Zeile passt, bricht der Browser es um, statt es überlaufen zu lassen. text-wrap: balance ist der Feinschliff — es gleicht die Zeilenlängen aus, damit eine umgebrochene Überschrift nicht mit einem einsamen einzelnen Wort endet.

Umbrechen mit Bindestrich — automatisch, pro Sprache

Ein Wort mittendrin ohne Bindestrich umzubrechen, wirkt grob. CSS kann mit hyphens: auto einen echten Bindestrich an einer sprachlich gültigen Stelle einfügen. Der Haken, den man verstehen sollte: Silbentrennung ist sprachabhängig. Der Browser verwendet das Silbentrennungswörterbuch für die Sprache des Elements, die er aus dem lang-Attribut ausliest.

.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;
}

Das Elegante daran: Wenn dein Dokument die Sprache bereits am Wurzelelement setzt — <html lang="de"> für eine deutsche Seite — funktioniert die Silbentrennung einfach, pro Sprache, ohne zusätzliche Verdrahtung. Deutsche Seiten trennen mit dem deutschen Wörterbuch, englische mit dem englischen. Du bekommst in jeder Sprache korrekte Silbentrennung gratis, solange lang den Inhalt widerspiegelt.

Behalte overflow-wrap: break-word daneben. hyphens: auto behandelt Wörter, die es zu trennen weiß; der Fallback fängt alles Übrige ab (URLs, Markennamen, erfundene Zusammensetzungen), sodass nichts jemals überläuft.

Alles zusammensetzen

Hier ist die vollständige, abhängigkeitsfreie Komponente. Beachte die Zwei-Element-Struktur: ein äußeres Element, das der Container ist, und ein inneres Element, das cqi daraus ausliest. Ein Element kann seine eigene Schrift nicht aus seinem eigenen Container dimensionieren, daher brauchst du die Eltern-Kind-Aufteilung.

<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;
}

Das ist das gesamte Feature: eine Überschrift, die mit ihrem Container skaliert, eine Mobil-Untergrenze und eine Desktop-Obergrenze respektiert, niemals überläuft und in jeder Sprache mit korrekter Silbentrennung umbricht — und sie liefert kein einziges Byte JavaScript aus.

CSS vs. JavaScript, ehrlich betrachtet

AspektJS-fit-text-Hook / -BibliothekReines CSS (cqi + clamp)
Reagiert auf Container-BreiteJa (per Messung)Ja (per cqi)
Reagiert auf exakte ZeichenkettenlängeJaNein — stattdessen durch overflow-wrap / hyphens begrenzt
Layout-Sprung nach dem LadenHäufig (messen, dann skalieren)Keiner — korrekt beim ersten Paint
Funktioniert bei SSR vor der HydrationNeinJa
Ausgeliefertes JavaScriptJaKeins
Kosten auf dem Main-ThreadReflows + Re-RendersNull
Sprachabhängige SilbentrennungManuellEingebaut via hyphens: auto

Die einzige Spalte, in der JavaScript weiterhin gewinnt, ist das exakte Anpassen pro Zeichenkette — eine bestimmte Schlagzeile auf genau eine Zeile in der größten passenden Größe zu zwingen. Wenn dieses präzise Verhalten eine harte Anforderung ist, bleibt Messung (JS oder ein SVG-<text>, das auf eine viewBox skaliert) der einzige Weg. Für die überwältigende Mehrheit der Display-Überschriften sieht container-relative Dimensionierung jedoch genauso gut oder besser aus — ohne all die Kosten.

Browser-Unterstützung

Container-Query-Längeneinheiten (cqi und Verwandte) werden von allen aktuellen Evergreen-Browsern unterstützt, und das seit 2023. clamp(), overflow-wrap, hyphens und text-wrap: balance sind ebenfalls weithin verfügbar (text-wrap: balance ist das Neueste und fällt anmutig auf normalen Umbruch zurück, wo es fehlt). Für einen Fallback auf sehr alten Browsern genügt eine einfache font-size-Deklaration vor der clamp()-Zeile.

Fazit

  • Das Schrift-Anpassungs-JavaScript, das jeder schreibt, existiert vor allem, weil CSS die Schrift nicht nach dem Container dimensionieren konnte, bis Container-Queries verfügbar wurden.
  • container-type: inline-size + font-size: clamp(min, Ncqi, max) liefert container-relative Schrift mit einer Mobil-Untergrenze und einer Desktop-Obergrenze.
  • Wenn die Größe ignoriert zu werden scheint, ist es die Kaskade — übertreffe die Spezifität breiter Design-System-Selektoren, indem du deine Regel verschachtelst.
  • cqi skaliert nach Container-Breite, nicht nach Zeichenkettenlänge; verwende overflow-wrap: break-word und hyphens: auto, damit lange Wörter sauber umbrechen, statt überzulaufen.
  • hyphens: auto ist gratis sprachabhängig, wenn das lang des Dokuments korrekt gesetzt ist.
  • Greife nur dann zu JavaScript, wenn du wirklich exaktes Anpassen mit einer Zeile pro Zeichenkette brauchst.

Es ist jetzt veröffentlicht — zwei winzige, abhängigkeitsfreie Pakete: @oleksiimazurenko/react-fit-text für React und @oleksiimazurenko/fit-text für den framework-agnostischen CSS-Kern (Quellcode auf 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>