Home / Articles / Line Length, Spacing Scales, Dark Surfaces, Shadows and Focus Rings in CSS

This article is published in English.

Line Length, Spacing Scales, Dark Surfaces, Shadows and Focus Rings in CSS

Learn the CSS fundamentals behind polished interfaces: ch-based line lengths, a 4px spacing scale, layered dark surfaces, multi-layer shadows and focus-visible rings.

1903 words

A web app can have a fashionable gradient, glassy cards and smooth animations and still feel unfinished within seconds of use. Nothing is broken, yet the visual rhythm reads as a weekend prototype rather than a product on the level of Linear, Stripe or Vercel. The cause is rarely the color palette or a lack of artistic talent. It is usually a handful of measurable CSS decisions about line length, spacing, contrast, elevation and focus states, and this article shows how to get each of them right.

Cap line length with ch units

Text that runs across the full width of a 1440px monitor is one of the quickest giveaways of an unpolished frontend. Very long lines are hard to read: once a line goes past roughly 80 characters, the eye has to travel all the way back to the left edge, and it often lands on the wrong line when it drops down. Over a long page that constant re-finding of place is tiring.

The pattern usually looks like this, a full-width container with nothing limiting the paragraph:

{/* BAD: Unbounded text stretches across the whole viewport */}
<div className="w-full p-8">
  <h1 className="text-3xl font-bold">API Documentation</h1>
  <p className="text-slate-300 mt-4 text-base">
    Our platform enables developers to authenticate and stream webhook events in real-time... (stretches 1200px wide)
  </p>
</div>

The fix: size reading blocks by characters

CSS has a unit made for this problem. A ch is as wide as the font's "0" character, so sizing in ch tracks the number of characters per line regardless of font size. Comfortable reading usually falls between 45 and 75 characters per line. A reusable prose class can combine a ch-based cap with generous line height and slightly tightened letter spacing:

/* Clean readable prose container */
.prose-container {
  max-width: 68ch; /* Optimal line length regardless of font size */
  line-height: 1.65;
  letter-spacing: -0.01em;
}

In Tailwind, the max-w-prose utility provides the same kind of cap (65ch), and mx-auto centers the column:

{/* Enterprise Grade: Beautiful, focused reading experience */}
<div className="max-w-prose mx-auto px-6 py-12">
  <h1 className="text-3xl font-bold tracking-tight text-white">
    API Documentation
  </h1>
  <p className="mt-4 text-slate-300 text-base leading-relaxed">
    Our platform enables developers to authenticate and stream webhook events in real-time...
  </p>
</div>

Keeping documentation, blog posts and descriptive settings text within about 65 to 70ch makes those pages look balanced and inviting immediately. Apply it to text blocks, not whole layouts.

Put every gap on a 4px/8px scale

Scan the utility classes in a codebase that feels rough and you will often find values like these:

  • card padding of p-[18px]
  • a modal margin of mt-5 (20px)
  • button padding of px-3.5 py-[7px]
  • a section gap of gap-7 (28px)

Each was picked because it looked fine on one screen at one moment, and together they destroy spatial rhythm. People perceive consistent intervals even without noticing them, and when spacing values bear no relationship to each other, the layout feels cluttered and disconnected.

A token scale to adopt

Established design systems restrict spacing to multiples of 4 or 8 pixels and give each step a name and a purpose. A practical scale looks like this:

  • space-1 (4px): micro spacing, such as the gap between an icon and its label
  • space-2 (8px): compact padding for badges and small tags
  • space-3 (12px): internal padding of form fields
  • space-4 (16px): standard card and button padding
  • space-6 (24px): gaps between cards
  • space-8 (32px): separation between sections
  • space-12 (48px): separation between major dashboard blocks

Treat anything outside the scale, such as margin-top: 19px, as a defect rather than a style choice. Tailwind's default scale already uses 4px steps, so in practice the rule is to avoid arbitrary bracket values.

Avoid pure black backgrounds in dark mode

A common first attempt at a dark SaaS interface sets the page to pure black with pure white text:

/* The Harsh Dark Mode Trap */
body {
  background-color: #000000;
  color: #ffffff;
}

White on black produces the maximum possible contrast ratio of 21:1. That easily passes accessibility minimums, but at that extreme, bright text can appear to bleed or glow against the background (halation), which many readers, especially those with astigmatism, find tiring, particularly on OLED and high-contrast screens.

The bigger problem is depth. Surfaces that are closer to the viewer, or to the light source, naturally look a little brighter. If the base layer is already #000000, there is no room left to distinguish a card from a dropdown from a modal, because black cannot get any darker beneath them.

Build a layered surface scale

Use deep, slightly tinted dark tones instead, and step up in lightness as elements rise in elevation. The first part of the token set defines four surface levels, from the page canvas to overlays such as modals and tooltips:

:root {
  /* Slate / Charcoal Depth Stack */
  --bg-canvas: #090d16;   /* Deepest background */
  --bg-surface: #0f172a;  /* Cards, tables, sidebar */
  --bg-elevated: #1e293b; /* Dropdowns, popovers, active tabs */
  --bg-overlay: #334155;  /* Modals, tooltips */

The rest of the same :root block adds two translucent border strengths and three text levels, from headings down to timestamps and inactive icons. Note that in the snippet as shown, the --border-active and --text-primary declarations share one line; that is valid CSS but worth reformatting, and #f8fafc is simply a near-white color rather than white at 95% opacity:

  --border-subtle: rgba(255, 255, 255, 0.08);
  --border-active: rgba(255, 255, 255, 0.16);  --text-primary: #f8fafc;   /* 95% opacity white for headings */
  --text-secondary: #94a3b8; /* Muted slate for body text */
  --text-tertiary: #64748b;  /* Inactive icons, timestamps */
}

Applied to markup, the canvas sits at the bottom, the card uses the surface color with a subtle border, and the heading and body copy use the primary and secondary text tokens:

{/* Clean, layered elevation */}
<div className="bg-[var(--bg-canvas)] min-h-screen p-8">
  <div className="bg-[var(--bg-surface)] border border-[var(--border-subtle)] rounded-xl p-6 shadow-sm">
    <h2 className="text-[var(--text-primary)] font-semibold">
      Workspace Overview
    </h2>
    <p className="text-[var(--text-secondary)] text-sm mt-1">
      Manage team roles and API keys.
    </p>
  </div>
</div>

Hierarchy now reads from lightness alone, with no need for heavy shadows.

Replace one heavy shadow with several light ones

Amateur interfaces tend to use a single dark, blurry shadow:

/* BAD: One thick, dark, muddy shadow */
.card-bad {
  box-shadow: 0 10px 20px rgba(0, 0, 0, 0.5);
}

The result looks like a dated photo-editing effect. Real objects do not cast one uniform blur. A physical shadow combines two components:

  • a tight, high-contrast shadow right next to the object, produced by the main light source
  • a broad, soft shadow that fades gradually into the surroundings, known as ambient occlusion

Layer shadows with low alpha values

box-shadow accepts a comma-separated list, so you can stack two or three shadows, each with a small alpha value. Here a 1px shadow grounds the element, a medium blur adds ambient depth, and a wide, very soft layer spreads the effect:

/* Polished Enterprise Shadow */
.card-elevation-high {
  box-shadow:
    0 1px 2px rgba(0, 0, 0, 0.06),   /* Crisp grounding line */
    0 8px 16px rgba(0, 0, 0, 0.08),  /* Middle ambient blur */
    0 24px 48px rgba(0, 0, 0, 0.12); /* Soft dispersed glow */
}

On dark backgrounds, shadows are hard to see, so they need higher opacity, and a thin light border does much of the work of separating the element. Negative spread values keep the dark shadow from bleeding out past the sides:

/* In dark mode, pair subtle shadow with a crisp top border */
.card-dark-elevation {
  box-shadow:
    0 20px 25px -5px rgba(0, 0, 0, 0.5),
    0 8px 10px -6px rgba(0, 0, 0, 0.5);
  border: 1px solid rgba(255, 255, 255, 0.08);
}

Instead of a paper cutout laid on black, the element appears to float just above the canvas.

Never remove focus outlines without a replacement

One line causes more harm than any other in frontend stylesheets:

/* DO NOT DO THIS */
*:focus {
  outline: none;
}

Teams add it because the browser's default focus ring clashes with custom-styled controls. Removing it without providing an alternative leaves keyboard users unable to see which element is focused, which makes the interface effectively unusable for them and fails accessibility requirements.

Use :focus-visible instead

Modern browsers support :focus-visible, which matches only when the browser decides a focus indicator is needed, typically during keyboard navigation and not after a mouse click. That lets you drop the ring for mouse users while keeping it for keyboard users. The first step removes the default outline from buttons:

/* Remove ugly mouse clicks, preserve crystal-clear keyboard rings */
button:focus {
  outline: none;
}

The second step defines a clear custom ring for keyboard focus:

button:focus-visible {
  outline: 2px solid #6366f1; /* Crisp indigo ring */
  outline-offset: 2px;
  border-radius: 6px;
}

Two refinements are worth knowing. First, a safer form of the first rule is button:focus:not(:focus-visible), which removes the outline only when a visible indicator is not needed, so browsers without :focus-visible support keep their default ring. Second, the border-radius in the focus rule changes the button's shape while it is focused; if the button already has rounded corners, leave that line out, since modern browsers draw outlines that follow the element's radius.

In Tailwind the same idea is expressed with focus-visible: variants that add a ring, an offset and an offset color matched to the dark background:

<button className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 rounded-lg text-white font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-900 transition-all">
  Save Changes
</button>

Tailwind's outline-none behavior changed between major versions (newer releases add outline-hidden), so check yours. Either way, mouse users get clean controls and Tab users always see where focus is.

A polish checklist before you merge

Run through these checks before shipping a frontend change:

  1. Reading width: are text blocks limited to roughly 50 to 75ch?
  2. Spacing: do all paddings, margins and gaps come from a strict 4px/8px scale?
  3. Dark mode layers: are surfaces built from layered charcoal or slate tones rather than raw #000000?
  4. Shadows: are shadows soft, layered and diffuse instead of a single dark blur?
  5. Focus states: can someone operate the entire screen with the Tab key and always see where focus is?

Wrapping up

Polish comes less from artistic intuition than from consistent constraints: ch-based line lengths, a fixed spacing scale, lightness as depth in dark themes, shadows that imitate real light, and focus rings that stay visible. Each is a small, reviewable rule, so you can enforce it with tokens, lint rules and a checklist instead of relying on taste in every pull request.