Interactive demo

Responsive typography lab

Compare viewport-driven type with a constrained article component as the same headline moves through narrow and wide slots.

Live demo

Responsive typography lab

HTML used in this demo
<main class="type-lab" data-type-lab data-copy="short" data-rhythm="relaxed">
  <header class="lab-header">
    <p class="eyebrow">Responsive typography</p>
    <h1>Fluid type still needs a layout boundary.</h1>
    <p>The left card follows the viewport. The right card responds to the space its article actually receives.</p>
  </header>

  <form class="control-panel" aria-label="Typography test controls">
    <label>
      <span>Article slot <strong data-slot-label>620px</strong></span>
      <input type="range" name="slot" min="320" max="840" value="620" />
    </label>

    <label>
      <span>Measure cap <strong data-measure-label>62ch</strong></span>
      <input type="range" name="measure" min="44" max="76" value="62" />
    </label>

    <label>
      <span>Headline copy</span>
      <select name="copy">
        <option value="short">Short production heading</option>
        <option value="long">Long editorial heading</option>
      </select>
    </label>

    <label>
      <span>Text rhythm</span>
      <select name="rhythm">
        <option value="relaxed">Relaxed reading</option>
        <option value="compact">Compact dashboard</option>
      </select>
    </label>
  </form>

  <section class="type-stage" data-stage aria-label="Typography comparison">
    <article class="sample sample--viewport">
      <p class="eyebrow">Viewport sized</p>
      <h2 data-headline>Readable CSS starts with constraints</h2>
      <p>The type scales with the browser window, even when this article is placed in a narrow component slot.</p>
      <a href="#preview">Read article</a>
    </article>

    <article class="sample sample--component">
      <p class="eyebrow">Component constrained</p>
      <h2 data-headline>Readable CSS starts with constraints</h2>
      <p>The article owns its measure, heading range, and rhythm, so the same content can survive different layout slots.</p>
      <a href="#preview">Read article</a>
    </article>
  </section>

  <aside class="diagnosis" aria-live="polite">
    <p class="eyebrow">Reading the result</p>
    <h2 data-verdict>The constrained card keeps the hierarchy usable.</h2>
    <p data-explain>Its heading responds to the article container and the measure cap protects body text from stretching too far.</p>
  </aside>
</main>
CSS used in this demo
.type-lab {
  --ink: #18202a;
  --muted: #526064;
  --line: #cad5d3;
  --surface: #fffdf8;
  --panel: #eef5f2;
  --accent: #0c6f75;
  --slot-width: 620px;
  --measure: 62ch;
  --leading: 1.62;
  display: grid;
  gap: 1rem;
  max-width: 70rem;
  margin: 0 auto;
  padding: 1.25rem;
  color: var(--ink);
}

.lab-header {
  display: grid;
  gap: 0.35rem;
  max-width: 56rem;
}

.eyebrow {
  margin: 0;
  color: var(--accent);
  font-size: 0.76rem;
  font-weight: 800;
  letter-spacing: 0;
  text-transform: uppercase;
}

h1,
h2,
p {
  margin-block: 0;
}

h1 {
  font-size: clamp(1.8rem, 5vw, 3.15rem);
  line-height: 1.02;
}

.control-panel {
  display: grid;
  gap: 0.85rem;
  grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
  border: 1px solid var(--line);
  border-radius: 8px;
  background: var(--surface);
  padding: 0.9rem;
}

.control-panel label {
  display: grid;
  gap: 0.45rem;
  color: var(--muted);
  font-weight: 700;
}

.control-panel strong {
  color: var(--ink);
}

.control-panel input,
.control-panel select {
  width: 100%;
}

.control-panel input {
  accent-color: var(--accent);
}

.control-panel select {
  min-height: 2.5rem;
  border: 1px solid var(--line);
  border-radius: 6px;
  background: #ffffff;
  padding-inline: 0.65rem;
}

.type-stage {
  display: grid;
  gap: 0.85rem;
  grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
  width: min(100%, var(--slot-width));
  margin-inline: auto;
  border: 1px dashed #9eb0ad;
  border-radius: 8px;
  background: #f7faf8;
  padding: 0.85rem;
}

.sample {
  display: grid;
  gap: 0.85rem;
  align-content: start;
  min-width: 0;
  border: 1px solid var(--line);
  border-radius: 8px;
  background: var(--surface);
  padding: clamp(0.9rem, 3cqi, 1.35rem);
}

.sample h2 {
  overflow-wrap: anywhere;
}

.sample p:not(.eyebrow) {
  max-width: var(--measure);
  color: var(--muted);
  line-height: var(--leading);
}

.sample a {
  justify-self: start;
  border: 1px solid var(--ink);
  border-radius: 6px;
  color: var(--ink);
  padding: 0.45rem 0.65rem;
  text-decoration: none;
}

.sample--viewport h2 {
  font-size: clamp(2rem, 7vw, 4.6rem);
  line-height: 0.98;
}

.sample--component {
  container-type: inline-size;
}

.sample--component h2 {
  max-width: 13ch;
  font-size: clamp(1.7rem, 13cqi, 3.2rem);
  line-height: 1.04;
}

@container (max-width: 24rem) {
  .sample--component h2 {
    max-width: 15ch;
    font-size: clamp(1.55rem, 16cqi, 2.25rem);
  }
}

[data-rhythm='compact'] {
  --leading: 1.38;
}

.diagnosis {
  display: grid;
  gap: 0.5rem;
  border-inline-start: 4px solid var(--accent);
  background: var(--panel);
  padding: 0.95rem 1rem;
}

.diagnosis h2 {
  font-size: 1.25rem;
}

.diagnosis p:not(.eyebrow) {
  color: var(--muted);
  line-height: 1.5;
}
JavaScript used in this demo
const lab = document.querySelector('[data-type-lab]');
const form = lab.querySelector('form');
const stage = lab.querySelector('[data-stage]');
const slotLabel = lab.querySelector('[data-slot-label]');
const measureLabel = lab.querySelector('[data-measure-label]');
const headlines = Array.from(lab.querySelectorAll('[data-headline]'));
const verdict = lab.querySelector('[data-verdict]');
const explain = lab.querySelector('[data-explain]');

const copy = {
  short: 'Readable CSS starts with constraints',
  long: 'Readable CSS starts with constraints that survive narrow cards, long words, and real editorial headlines',
};

function render() {
  const data = new FormData(form);
  const slot = Number(data.get('slot') || 620);
  const measure = Number(data.get('measure') || 62);
  const copyMode = data.get('copy') || 'short';
  const rhythm = data.get('rhythm') || 'relaxed';

  lab.dataset.copy = copyMode;
  lab.dataset.rhythm = rhythm;
  stage.style.setProperty('--slot-width', slot + 'px');
  stage.style.setProperty('--measure', measure + 'ch');
  slotLabel.textContent = slot + 'px';
  measureLabel.textContent = measure + 'ch';
  headlines.forEach((headline) => {
    headline.textContent = copy[copyMode];
  });

  if (slot < 430 && copyMode === 'long') {
    verdict.textContent = 'The viewport-sized card breaks down first.';
    explain.textContent = 'The heading is still tied to the browser, so the narrow article slot gets oversized type and aggressive wrapping.';
    return;
  }

  if (measure > 68 && slot > 700) {
    verdict.textContent = 'The measure cap is now the risk.';
    explain.textContent = 'Wide text can look calm while becoming harder to scan. A fluid system still needs a readable maximum.';
    return;
  }

  if (rhythm === 'compact') {
    verdict.textContent = 'Compact rhythm only works when the content is short.';
    explain.textContent = 'Reduced line height can suit dense UI, but long-form copy needs more breathing room to stay readable.';
    return;
  }

  verdict.textContent = 'The constrained card keeps the hierarchy usable.';
  explain.textContent = 'Its heading responds to the article container and the measure cap protects body text from stretching too far.';
}

form.addEventListener('input', render);
render();

What this demo is for

Use this demo when typography looks fluid but still fails because heading scale, measure, and line height are not owned by the component.

Try this

  • Set the slot width near 340px with the long headline and compare the two cards.
  • Increase the measure cap and watch when readable line length becomes less important than the surrounding layout slot.
  • Switch between relaxed and compact rhythm to see why line height is part of the system, not a decorative tweak.

Related guides