GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

CSS Typography Playground: 9 Demos, No Webfonts

css typography is setting type with CSS: which family, how heavy, how far apart, how large.

Auto-plays · click a tile to jump to its section · all nine in one zip

The order walks outward from a single glyph to the whole page. First the faces side by side (01), then the weight axis (02), then tracking and leading as two knobs (03), then the measure against a baseline grid (04), then a stepped heading scale (05), then the anatomy under the faces (06), then heading and body as a pair (07), then the opening letter and the quotes (08), and finally size that follows the viewport (09). Each of these css typography examples is a working control panel rather than a static picture, and staying on system faces is the point: nothing to fetch, no flash of invisible text, no layout shift while a webfont arrives, and the file you paste runs the same on every machine. For motion on the glyphs themselves, the css typing text effect and css text animation in nine demos are the neighbors — this page stays with type at rest.

01System font stack comparer

One sentence sits in three panes wearing serif, sans, and mono stacks, and a single tap re-dresses all three at once — the font spec page of a design system.

System font stack comparer
Open in a new tab

01 · System font stack comparer — Measured area 23.762% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password

font-familysystem-uiui-monospace

The stacks the three panes actually wear

$serif: ui-serif, Georgia, "Times New Roman", "Noto Serif KR", serif;
$sans: system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
$mono: ui-monospace, "Cascadia Mono", Consolas, "Liberation Mono", monospace;

// The three sample panes each wear one of these — these declarations are the body of the demo
.fs__spec {
  margin: 0; color: $subject-cream;
  font-size: clamp(13px, 4.6vw, 20px); font-weight: 600; line-height: 1.45;
}
.fs__spec--serif { font-family: $serif; }
.fs__spec--sans { font-family: $sans; }
.fs__spec--mono { font-family: $mono; }

These three lists are the system font stack CSS gives you for free, and the computed values came back as ui-serif with Georgia, system-ui with Segoe UI, and ui-monospace with Cascadia Mono and Consolas.

One tap re-dresses all three panes

var SAMPLES = ['The quick brown fox jumps', 'Hello Type 0123', 'CSS Type 2026'];
// One tap swaps the sample sentence in all three panes at once — this is also where the preview loop is detached
tabs.forEach(function (tab, i) {
  tab.addEventListener('click', function () {
    fs.classList.remove('is-demo');
    tabs.forEach(function (t) { t.setAttribute('aria-pressed', String(t === tab)); });
    specs.forEach(function (spec) { spec.textContent = SAMPLES[i]; });
  });
});

The English tab rewrote all three panes to Hello Type 0123 with aria-pressed left true only on the pressed tab, and the mixed tab landed on CSS Type 2026 in every pane.

02Weight axis slider (100-900)

Dragging the slider moves font-weight from 100 to 900 while the readout follows the value the browser actually computed — the font settings panel of an editor or tool.

Weight axis slider (100-900)
Open in a new tab

02 · Weight axis slider (100-900) — Measured area 5.237% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password

font-weightfont-variation-settingsoninput

The readout reads the value back from the browser

// oninput — the readout follows every step while the handle is held.
// The number on screen is not an echo of the authored value: it re-reads the
// font-weight the browser computed, through getComputedStyle. A variable axis
// needs font-variation-settings 'wght' moved together — change only one side
// and the other declaration wins the render in a variable font.
range.oninput = function () {
  wt.classList.remove('is-demo');
  var v = range.value;
  var pct = (v - 100) / 8;
  big.style.fontWeight = v;
  big.style.setProperty('--w', v);
  hangul.style.fontWeight = v;
  hangul.style.setProperty('--w', v);
  range.style.setProperty('--fill', pct + '%');
  range.setAttribute('aria-valuenow', v);
  out.textContent = getComputedStyle(big).fontWeight;
};

Setting the slider to 100 and then to 900 returned a computed font-weight of exactly 100 and 900, and a single ArrowRight press put the value, the computed style, and the badge all on 600.

Both declarations of one variable weight

// The top line rides the variable axis, the line below snaps to the nearest weight
.wt__big {
  margin: 0; color: $subject-ink;
  font-family: $sans; font-weight: 700;
  font-size: clamp(38px, 15vw, 64px); line-height: 1.05;
  font-variation-settings: 'wght' var(--w, 700);
}
.wt__hangul {
  margin: 0; color: rgba($subject-ink, .72);
  font-family: $sans; font-weight: 700; font-size: 13px; line-height: 1.5;
  font-variation-settings: 'wght' var(--w, 700);
}

The Latin sample rides the variable axis smoothly while the second sample, set in a system face with no such axis, snaps to the nearest weight it has — the split this panel exists to show.

03Tracking and leading tuner

A letter-spacing slider in em and a line-height slider in unitless multiples print their values beside the text — the body-text settings panel of a blog.

Tracking and leading tuner
Open in a new tab

03 · Tracking and leading tuner — Measured area 13.345% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

letter-spacingline-heighttextContent

em tracking against unitless leading

// Values go in through textContent only — no innerHTML, nothing to forge.
// letter-spacing reaches the text in em, line-height as a unitless multiple.
track.oninput = function () {
  st.classList.remove('is-demo');
  var v = Number(track.value).toFixed(2);
  text.style.letterSpacing = v + 'em';
  track.style.setProperty('--fill', ((track.value + 0.04) / 0.34 * 100) + '%');
  trackVal.textContent = v + 'em';
};
lead.oninput = function () {
  st.classList.remove('is-demo');
  var m = Number(lead.value).toFixed(2);
  text.style.lineHeight = m;
  lead.style.setProperty('--fill', ((lead.value - 1) / 1 * 100) + '%');
  leadVal.textContent = m;
};

On the 13px sample, tracking of 0.3em computed to 3.9px and -0.04em to -0.52px, while leading of 2.0 spread the same lines to 26px with every step readable in the badges.

A fixed box for a breathing paragraph

// The sample box keeps a fixed height — even leading of 2.0 stays inside the stage
.st__body {
  height: 130px; overflow: hidden;
  padding: $sp-2; border: 1px solid rgba($subject-ink, .14); border-radius: $r-control;
}
.st__text {
  margin: 0; color: $subject-ink;
  font-family: $sans; font-weight: 500;
  font-size: clamp(10px, 3.2vw, 13px); line-height: 1.7; letter-spacing: 0.02em;
  overflow-wrap: break-word;
}

With the box pinned at 130px and overflow hidden, the paragraph breathes inside the stage at leading 2.0 instead of pushing the panel taller, and pressing Home on the leading slider snaps the lines back to a computed 13px.

04Paragraph rhythm preview

Set the measure with a ch slider, switch on the baseline grid, and watch body copy land on grid rows — a news or magazine body preview.

Paragraph rhythm preview
Open in a new tab

04 · Paragraph rhythm preview — Measured area 17.881% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

chrepeating-linear-gradienttext-wrap: pretty

A measure in ch on a baseline grid

// The sheet's width is a ch multiple — swap the typeface and the same 44ch is a different pixel width
.tr__sheet {
  --measure: 44;
  box-sizing: border-box; width: calc(var(--measure) * 1ch); max-width: 100%;
  padding: $sp-2; border-radius: $r-sm;
}
// Grid rows repeat at the same $lh interval as the line height
.tr__sheet.is-grid {
  background-image: repeating-linear-gradient(
    to bottom,
    rgba($subject-blue, .26) 0, rgba($subject-blue, .26) 1px,
    transparent 1px, transparent $lh
  );
  background-position: 0 $sp-2;
}
.tr__p {
  margin: 0; color: $subject-ink;
  font-family: $serif; font-weight: 400; font-size: 13px; line-height: $lh;
  text-wrap: pretty;
}

Measured on the serif sample, 44ch came out to 326.6px wide, and pulling the slider to 28ch shrank the sheet to 241.5px, with every grid row one $lh tall and text-wrap: pretty tidying the orphan word at the paragraph's end.

One toggle swaps a background

// The real-use path — the slider value goes straight in as a ch multiple.
// The preview loop shakes the same value with CSS keyframes instead.
function applyMeasure() {
  sheet.style.setProperty('--measure', range.value);
  val.textContent = range.value + 'ch';
}
function stopLoop() { tr.classList.remove('is-demo'); }
range.addEventListener('input', function () { stopLoop(); applyMeasure(); });
grid.addEventListener('click', function () {
  stopLoop();
  var on = grid.getAttribute('aria-pressed') === 'true';
  grid.setAttribute('aria-pressed', on ? 'false' : 'true');
  sheet.classList.toggle('is-grid', !on);
});
applyMeasure();

The grid toggle is one background-image and nothing more: switched on, it computed to a repeating-linear-gradient; switched off, to none, with aria-pressed carrying the state.

05Heading scale ladder

Pick a ratio of 1.125, 1.25, or 1.333, and every rung of the ladder recalculates with calc() — the heading system page of a style guide.

Heading scale ladder
Open in a new tab

05 · Heading scale ladder — Measured area 48.553% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

calc()--ratiotoFixed

One registered number drives five rungs

// A ratio must be registered as a number before an animation can interpolate or cut it
@property --ratio {
  syntax: "<number>";
  inherits: true;
  initial-value: 1.25;
}

// Every rung's size is the base times the ratio to a power
.ts__rung--1 .ts__glyph { font-size: calc(var(--base) * 1px); }
.ts__rung--2 .ts__glyph { font-size: calc(var(--base) * var(--ratio) * 1px); }
.ts__rung--3 .ts__glyph { font-size: calc(var(--base) * var(--ratio) * var(--ratio) * 1px); }
.ts__rung--4 .ts__glyph { font-size: calc(var(--base) * var(--ratio) * var(--ratio) * var(--ratio) * 1px); }
.ts__rung--5 .ts__glyph { font-size: calc(var(--base) * var(--ratio) * var(--ratio) * var(--ratio) * var(--ratio) * 1px); }

With the base at 11px, the top rung measured 17.62px at ratio 1.125, 26.86px at 1.25, and 34.73px at 1.333 — one custom property moves all five rungs together.

Labels solved from the same formula

// Rung sizes are a calc() chain in CSS; the px labels solve the same formula
// once more in JS and print it with toFixed. All three ratios' labels exist
// from the start, so the preview loop can swap ratios without the numbers
// ever lagging behind the ladder.
function buildLabels() {
  boxes.forEach(function (box) {
    var step = Number(box.getAttribute('data-step'));
    Object.keys(RATIOS).forEach(function (key) {
      var v = document.createElement('span');
      v.className = 'ts__v ts__v--' + key;
      v.textContent = (BASE * Math.pow(RATIOS[key], step)).toFixed(1) + 'px';
      box.appendChild(v);
    });
  });
}

The three label sets are stacked in one slot with only the current ratio's set visible, so at 1.333 the top rung reads 34.7px right beside the glyph it belongs to.

06Font anatomy cards

Baseline, x-height, and cap-height rules layer over oversized glyphs, with the x-height rule drawn in ex units — typography guides and course material.

Font anatomy cards
Open in a new tab

06 · Font anatomy cards — Measured area 41.664% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

::beforelinear-gradientex

Only the x-height rule is a true measure

$desc: .21em;
$cap: .70em;

// The three rules sit at heights measured up from the glyph box's floor
.fa__rule {
  position: absolute; left: $sp-2; right: $sp-2;
  font-size: 52px;                     // so 1ex resolves against this size's face
  pointer-events: none;
}
.fa__rule--base { bottom: calc(#{$sp-4} + #{$desc}); }
.fa__rule--x    { bottom: calc(#{$sp-4} + #{$desc} + 1ex); }
.fa__rule--cap  { bottom: calc(#{$sp-4} + #{$desc} + #{$cap}); }
// Only the x-height rule is a real typeface measure (ex) — a solid line, while the estimates stay dashed
.fa__rule--x::before {
  height: 2px;
  background: linear-gradient(90deg, $subject-orange-deep 0 100%);
}

Because 1ex resolves against each face, the measured x-height rule sat at 176.0px in the serif card and 174.4px in the sans card — a 1.6px gap between faces set at the same font size.

The glyph box floor and the dashed layer

// The glyph sits at line-height 1, so the box floor is the end of its descenders
.fa__sample {
  position: absolute; left: $sp-3; bottom: $sp-4;
  color: $subject-ink; font-weight: 400; font-size: 52px; line-height: 1;
}
// Each rule is one pseudo layer with a 10px dashed gradient — no extra elements
.fa__rule::before {
  content: ""; display: block; height: 1px;
  background: linear-gradient(90deg, $color 0 6px, transparent 6px 10px);
  background-size: 10px 1px;
}

The sample sits at line-height: 1, so the box floor is exactly where its descenders end, and nothing extra is added for the lines themselves — one pseudo layer per rule, a 10px gradient for the dashes, and the card still holds just one sample span, three rule spans, and a caption.

07Heading-body pairing preview

Choose a heading family and a body family separately, and the pair renders instantly, with italic, tracking, and leading knobs in one panel — font pairing review before building a landing page.

Heading-body pairing preview
Open in a new tab

07 · Heading-body pairing preview — Measured area 26.822% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

font-familyfont-styleline-height

Chips set in their own faces

.pp {
  --f-sans: system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
  --f-serif: Georgia, "Times New Roman", ui-serif, serif;
  --f-mono: ui-monospace, "Cascadia Mono", Consolas, "Courier New", monospace;
  --f-wide: Verdana, "Segoe UI", "Noto Sans KR", sans-serif;
}

// Each chip's label is set in that chip's own typeface — the name and the Aa specimen wear the face together
.pp__chip--sans { font-family: var(--f-sans); }
.pp__chip--serif { font-family: var(--f-serif); }
.pp__chip--mono { font-family: var(--f-mono); }
.pp__chip--wide { font-family: var(--f-wide); }

Four families on each row make sixteen possible pairs, and the measured walk through the chips put Georgia, ui-monospace, and Verdana on the heading row and system-ui, Georgia, ui-monospace, and Verdana on the body row.

The computed family moves to the sample

// Each chip is set in its own typeface — the computed font-family is copied verbatim onto the sample
function stack(btn) { return getComputedStyle(btn).fontFamily; }

function pick(btn) {
  pp.classList.remove('is-demo');
  var target = btn.getAttribute('data-row') === 'head' ? head : body;
  target.style.fontFamily = stack(btn);
  var row = btn.parentNode;
  var chips = row.querySelectorAll('.pp__chip');
  for (var i = 0; i < chips.length; i++) {
    var on = chips[i] === btn;
    chips[i].classList.toggle('is-on', on);
    chips[i].setAttribute('aria-pressed', on ? 'true' : 'false');
  }
}

The italic toggle flipped the measured heading between italic and normal, and tracking of 0.08em with leading of 1.9 computed to 1.92px letter-spacing and 24.7px line-height on the sample pair.

08Drop cap and quote styling

::first-letter floats large to open the paragraph while browsers that understand initial-letter take that path instead — magazine-style dressing for a blog body, with a short quote and a long quote side by side.

Drop cap and quote styling
Open in a new tab

08 · Drop cap and quote styling — Measured area 48.323% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

::first-letterfloatinitial-letter

Two paths split by a supports check

// Registered as a number, so the preview loop can grow the cap from 2 to 3 to 4 lines smoothly
@property --cap-lines {
  syntax: "<number>";
  inherits: true;
  initial-value: 3;
}

// The two drop cap paths: float by default (every browser), initial-letter where it is understood.
// Both read the same --cap-lines — the float cap sizes itself with calc(1.65em * lines)
.dq.is-cap .dq__text::first-letter {
  float: left;
  padding-right: $sp-1; padding-top: $sp-1;
  line-height: .82;
  font-weight: 700;
  font-size: calc(1.65em * var(--cap-lines));
  color: $subject-blue-deep;
}
@supports (initial-letter: 3) {
  .dq.is-cap .dq__text::first-letter {
    initial-letter: var(--cap-lines);
    float: none;
    padding-top: 0;
  }
}

The probe confirmed CSS.supports('(initial-letter: 3)') as true in this build of Chrome, the badge switched to the initial-letter path, and float computed to none on the first letter.

A number property grows the cap

// Browsers that know initial-letter take that path — the badge says which side is live
if (window.CSS && CSS.supports && CSS.supports('(initial-letter: 3)')) {
  badge.textContent = 'initial-letter path';
}

var sizes = document.querySelectorAll('.dq__size');
for (var i = 0; i < sizes.length; i++) {
  sizes[i].addEventListener('click', function () {
    dq.classList.remove('is-demo');
    dq.style.setProperty('--cap-lines', this.getAttribute('data-lines'));
    for (var j = 0; j < sizes.length; j++) {
      var on = sizes[j] === this;
      sizes[j].classList.toggle('is-on', on);
      sizes[j].setAttribute('aria-pressed', on ? 'true' : 'false');
    }
  });
}

With the cap off, the first letter measured a plain 12.5px in a 12 by 16px box; three lines grew it to 61.875px in a 62 by 83px box, and four lines to 82.5px in an 86 by 110px box.

09Responsive clamp() scale

Drag the frame's edge, and the window width reads out in rem while the heading scales inside its clamp() range — designing a responsive heading scale with no media query in sight.

Responsive clamp() scale
Open in a new tab

09 · Responsive clamp() scale — Measured area 30.844% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

clamp()innerWidthmin()

A container the cqw unit can see

// This frame stands in for a narrow viewport: cqw units read this box's width
.cf__frame {
  position: relative;
  container-type: inline-size;
  width: min(62%, 30rem);
  box-sizing: border-box;
  padding: $sp-3 $sp-3 0;
  border-radius: $r-card;
  background: $stage-paper; box-shadow: $shadow-raised;
}
.cf__inner { min-height: 92px; }
.cf__title {
  margin: 0; color: $subject-ink;
  font-weight: 800; font-size: clamp(1.125rem, 13cqw, 3rem); line-height: 1.15;
}
.cf__lede { margin: 0; margin-top: $sp-1; color: rgba(23, 20, 26, .78); font-weight: 400; font-size: clamp(.75rem, 3.4cqw, .875rem); line-height: 1.5; }

Measured at frame widths of 176, 240, 336, and 432px, the title computed to 19.76, 28.08, 40.56, and finally 48px — the 13cqw middle term tracks the frame's content box, and 432px is where the 3rem ceiling takes over.

Numbers read from real measurements

// Rewrites the frame width (rem), the window width (innerWidth in rem),
// and the ratio against the stage. A ResizeObserver catches the preview
// loop's width moves too, so the numbers follow either path.
function update() {
  var w = frame.offsetWidth;
  if (w === lastW) return;
  lastW = w;
  var winRem = window.innerWidth / 16;
  var ratio = Math.round(w / cf.clientWidth * 100);
  big.textContent = 'Panel ' + (w / 16).toFixed(1) + 'rem';
  sub.textContent = 'Window ' + winRem.toFixed(1) + 'rem · ratio ' + ratio + '%';
}

if ('ResizeObserver' in window) {
  new ResizeObserver(update).observe(frame);
  new ResizeObserver(update).observe(cf);
}
window.addEventListener('resize', update);

The readouts divide offsetWidth and window.innerWidth by 16 themselves, so a real 480px window printed 30.0rem beside a panel reading 27.0rem at the 432px frame.

How far does css fluid typography go without media queries?

Far enough that most projects never need a typography breakpoint, with two honest ceilings.

Sixteen pairings from four families

On the pairing panel, four families crossed as heading and body give sixteen pairs before the italic, tracking, and leading knobs multiply the variants inside each one — the classic heavy sans over a serif body and the magazine mono over serif are both within reach, while a display face with a loud personality is the first thing system faces cannot give you, and nudging one side's size by the x-height gap measured in 06 is the last refinement they can offer.

One clamp per heading and no breakpoints

The clamp in 09 carried its title from 19.76px to a 48px ceiling with zero media queries, and the ladder in 05 gives that same approach a stepped scale — the honest limit is that clamp answers size alone, so the moment a layout must reflow around the type, a breakpoint returns for the layout's sake rather than the type's.

Where it breaks — the trap

All three below turned up while measuring rather than being invented for this section.

The loop polluted the computed readout

The preview loop in 02 interpolates font-weight from 100 to 900, and reading the computed value mid-flight returned 135.25, so the readout stays at the HTML's initial 700 until the first pointer contact detaches the loop — only then does the computed style tell the truth.

The stage track that quietly overflowed at 320px

The 44ch sheet in 04 once stretched the stage grid's auto track wide enough to spill off-screen at 320px with no error anywhere, and pinning the track to minmax(0, 1fr) brought the 320px re-measure back to a clean scrollWidth of 320 — the zip carrying all nine sources and their React twins opens with the eight-character password rdv7unke, and the fix is already inside every file in it.

A title at the ceiling stops growing

On 09, growing the frame past 432px — 27.0rem — left the title at exactly 48px, and another 120px of drag to a 27.6rem frame moved the panel numbers while the title never followed, which is the clamp ceiling doing its job rather than a fault.

Three variants and accessibility

name value changed feel
Ladder ratio --ratio 1.25 → 1.333 the top rung climbs from 26.86px to 34.73px
Drop cap depth --cap-lines 3 → 4 the opening letter grows from 61.875px to 82.5px
Heading ceiling clamp() max 3rem → 2.5rem the widest frame stops the title at 40px

Every control is a native range input or button, so keyboard and screen-reader support arrives without a patch: 02 refreshes aria-valuenow on every step, 01 and 04 through 08 press aria-pressed for their toggles, and 09's grip is a button that moves 1rem per arrow key. Under prefers-reduced-motion: reduce, all nine stop their loops and keep a chosen still frame — 01 rests on the default three panes, 02 holds the 700 weight, 03 pins tracking to 0.02em and leading to 1.7, 04 pins the sheet to 44ch, 05 hides the sweep beam and shows only the 1.25 labels, 06 rests on the serif card, 07 resets italic and tracking, 08 keeps the three-line cap, and 09 parks the frame. Contrast was measured from rendered pixels across 59 text lines in the nine panels with none below AA — the drop cap's first letter reads 5.98:1 over #fff7e6 and the x-height tag 4.91:1 over #f0f5fe.

Next door, blockquote and pull quote design dresses whole quotations rather than the opening letter; the references are MDN font-family, MDN clamp(), and MDN ::first-letter.

FAQ

Should I use a css typography generator instead of tuning by hand

A generator is handy for a first pass at a scale, but it hands you numbers to paste. These nine panels show the numbers as they happen — you watch 0.3em tracking compute to 3.9px on real text before committing anything.

Why does a thin weight like 100 render in one sample and not the other

The Latin sample in 02 rides a variable wght axis that renders every hundred-step value. A system face without that axis snaps to the nearest weight it actually has, so 100 never appears there — the two samples on one screen make the split visible.

How do I choose the floor and ceiling of a clamp

Pick the floor at the narrowest width where the text must stay readable and the ceiling where the layout stops tolerating the type. Item 09 measured its title growing from 19.76px to the 48px ceiling, and dragging that frame is the quickest way to get a feel for your own pair.

Enter the archive password

The password is inside this article. You will find it as you read.