Anurag
← All Posts

Typography as Interface: Why Most Web Apps Look the Same

Anurag Sharma
Anurag Sharma
6 min read

If you were to blur out the logos of the top 50 B2B SaaS applications today, you would be hard-pressed to tell them apart. We are living in a monoculture of design, characterized by a sea of Inter, Roboto, and San Francisco typefaces, suspended in #F3F4F6 backgrounds with an obligatory 8px border radius.

The industry has optimized for "cleanliness" to the point of complete sterility. We have forgotten that typography is not just a vessel for content; it is the interface. In this post, I want to explore the technical and architectural decisions that lead to this homogenization, and how we can use modern CSS and variable fonts to break out of it while maintaining accessibility and performance.

The Tyranny of the Default

The root cause of the UI monoculture is the uncritical adoption of design systems and component libraries. Tailwind CSS, MUI, and Chakra have democratized decent design, but they have also established a rigid baseline.

When you use the default configuration of these libraries, you inherit a typographic scale that is perfectly math-driven but emotionally bankrupt.

Consider a typical typography configuration in a modern frontend application:

// The standard, boring Tailwind config module.exports = { theme: { fontFamily: { sans: ['Inter', 'ui-sans-serif', 'system-ui'], serif: ['Georgia', 'serif'], mono: ['JetBrains Mono', 'monospace'], }, // ... } }

There is nothing technically wrong with this. Inter is a phenomenal typeface (thanks, Rasmus). The problem is that when Inter is used for headings, body text, buttons, and captions, the typographic texture of the page is entirely flat. The hierarchy relies entirely on font weight and size, ignoring the expressive potential of contrast in letterforms.

Typography as Structural Architecture

When typography acts as the interface, we rely less on borders, shadows, and background colors to separate information. We use typographic contrast.

During a redesign for a complex financial terminal in 2025, we faced a screen densely packed with numbers, tickers, and charts. The previous team had tried to organize it using hundreds of little gray bounding boxes. It looked like a spreadsheet that was slowly dying.

We removed almost all the borders and re-architected the layout using a deeply considered typographic scale, mixing a high-contrast serif for narrative data and a heavily customized variable monospaced font for tabular data.

Implementing Variable Fonts for Dynamic UI

Variable fonts (.woff2 files that contain a continuous range of design axes like weight, width, and slant) are the secret weapon for complex interfaces. Instead of loading 6 different font files, you load one, and you can animate and adjust it dynamically based on the state.

Here is how we implemented a "data heat" system using a variable font. When a stock ticker was volatile, we didn't just change the color to red; we dynamically increased the font weight and slightly widened the characters to command attention.

/* CSS Implementation of Data Heat */ @font-face { font-family: 'Fraunces'; src: url('/fonts/Fraunces-VariableFont_SOFT,WONK,opsz,wght.woff2') format('woff2'); font-weight: 100 900; font-display: swap; } .ticker-value { font-family: 'Fraunces', serif; /* Custom property that JS will update */ --ticker-weight: 400; --ticker-width: 100; font-variation-settings: 'wght' var(--ticker-weight), 'wdth' var(--ticker-width); transition: font-variation-settings 0.2s ease-out, color 0.2s ease-out; } .ticker-value[data-trend="up-volatile"] { --ticker-weight: 800; --ticker-width: 110; color: var(--color-success-bold); }
// React hook to update font variations based on data velocity function TickerNode({ symbol, price, velocity }) { // Map velocity (0-100) to font weight (400-900) const dynamicWeight = Math.min(400 + (velocity * 5), 900); return ( <div className="ticker-value" style={{ '--ticker-weight': dynamicWeight }} > ${price.toFixed(2)} </div> ); }

This approach creates an interface that breathes with the data. It feels alive, not just like a static rendering of a database row.

The Performance Trade-offs

The immediate counter-argument from any Senior Frontend Engineer will be: "Custom typography is bad for performance. It causes Layout Shifts (CLS) and increases Time to Interactive (TTI)."

They are right, if you do it poorly. If you load massive static font files and don't manage the font-display property, you will fail your Core Web Vitals.

To use typography as an interface element without degrading performance, you must master subsetting and font loading strategies.

Aggressive Subsetting

If you are using a display font solely for large numerical headers, you don't need the Cyrillic alphabet, and you don't need obscure ligatures. You can strip the font down from 200kb to 15kb using tools like pyftsubset.

# Subsetting a font to only include numbers, basic punctuation, and a few symbols pyftsubset MyHeavyFont.woff2 \ --unicodes="U+0030-0039, U+0024, U+0025, U+002E" \ --flavor="woff2" \ --output-file="MyHeavyFont-Numbers.woff2"

Advanced Font Loading

We also heavily utilize the CSS Font Loading API to ensure the UI doesn't render in a broken state, avoiding the dreaded Flash of Unstyled Text (FOUT) that shifts the layout.

// Wait for critical UI fonts before removing the preloader async function bootApplication() { const coreFont = new FontFace('Interface Mono', 'url(/fonts/InterfaceMono-Var.woff2)'); try { const loadedFont = await coreFont.load(); document.fonts.add(loadedFont); // Now that the font is locked in, we can render the app // knowing that layout calculations (like canvas rendering or complex grids) // will be accurate to the pixel. renderReactApp(); } catch (err) { console.error('Font failed to load, falling back to system fonts', err); // Render anyway with system fonts renderReactApp(); } }

Fluid Typography Algorithms

Finally, if typography is your interface, it must scale flawlessly across viewports. Media queries with hardcoded font sizes are a maintenance nightmare. We use CSS clamp() combined with viewport units to create a continuous fluid scale.

:root { /* Fluid typography formula: min_size + (max_size - min_size) * ((100vw - min_viewport) / (max_viewport - min_viewport)) */ --font-size-base: clamp(1rem, 0.875rem + 0.625vw, 1.25rem); --font-size-h1: clamp(2.5rem, 1.5rem + 5vw, 4.5rem); } h1 { font-size: var(--font-size-h1); /* The line height must tighten as the text gets larger */ line-height: calc(1.1 + (1.5 - 1.1) * ((var(--font-size-h1) - 2.5rem) / (4.5rem - 2.5rem) * -1)); }

Note: You usually do this with a PostCSS plugin or a Sass mixin, writing it out manually as shown above is just to illustrate the math.

Conclusion

We have to stop treating text as just the stuff we pour into the UI containers. The text is the container. The text is the interface. By breaking away from the safe, sterile system defaults and leaning into variable fonts, fluid scaling, and deep CSS integration, we can build software that actually has a soul, without sacrificing a single millisecond of performance.

Newsletter

Occasional essays on engineering, design, and building resilient systems. No spam.