v1.0.0 · MIT License · ~5 KB gzipped
Not a framework. Not a component library. One file of honest defaults for type-first, reading-first interfaces.
§ 00–15 · Contents
Hairline covers every element an editorial page actually uses — with sensible, considered defaults that hold up in production.
| § 00 | Design tokens | CSS custom properties for type scale, spacing, measure, leading, tracking, ink/surface colors, radius, and motion. All overridable. |
| § 01 | Box model | Universal border-box,
zeroed margins, and min-width: 0
to prevent size inflation in flex/grid children. |
| § 02 | Document & body | Serif body font stack, antialiasing, text rendering, and full prefers-reduced-motion
compliance. |
| § 03 | Typography | text-wrap: balance
on headings, text-wrap: pretty
on paragraphs. Form fields inherit the document font. |
| § 04 | Headings | Weight-400 h1–h6 with tight leading and negative tracking. Hierarchy through size, not weight. Includes
[data-kicker]
eyebrow labels.
|
| § 05 | Body copy | Lede, dropcap, pullquote, measure constraint. Inline: strong, em, mark, abbr, sub, sup, oldstyle & tabular figures. |
| § 06 | Lists | Disc, decimal, nested lists. [role="list"]
bare reset. Definition lists for glossaries. |
| § 07 | Links | Accent color, offset underlines, skip-ink. Auto-appended ↗ for target="_blank".
Visited + focus-visible states. |
| § 08 | Media & figures | Block display, aspect-ratio preservation, object-fit. figure
+ figcaption
styled. |
| § 09 | Tables | Collapsed borders, muted small-caps headers, hairline row dividers, zebra striping, tabular-nums.
|
| § 10 | Forms | Normalised fields, custom select arrow, smooth focus rings, accessible buttons. Disabled states. |
| § 12 | Code | Inline code,
pre
blocks, and kbd
key lift — JetBrains Mono stack.
|
| § 13 | Horizontal rules | Plain hairline, ornament [data-ornament],
and labelled [data-label]
divider. |
| § 14 | Utilities | .sr-only,
.measure,
.lede,
.pullquote,
.capo,
.smallcaps,
.muted.
|
| § 15 | Clean black output, expanded link URLs, orphan/widow control, no page breaks inside blocks. |
Live specimen
Every element below is built from hairline's defaults — the CSS powering this page is hairline.css plus layout-only overrides.
Notes from a decade of sitting on both sides. Where a senior engineer learns to stay close to the work, and why that matters more than the title.
Structure matters more than syntax. Most arguments about code style are really arguments about structure in disguise — and knowing where to put the energy changes everything about how a system ages over time.
Being senior isn't about volume — of opinions, slides, or output. It's about precision.
Text can carry strong emphasis, editorial italics,
highlighted passages, API abbreviations,
inline code, keyboard shortcuts like ⌘K, and footnote1 superscripts.
§ 00 · Tokens
Every value is a CSS custom property. Set your brand color in two lines. The cascade handles the rest.
@import "hairline.css";
:root {
/* override only what you need */
--color-accent: hsl(340 80% 50%);
--measure: 70ch;
--leading-normal: 1.7;
}
Install
Download and link it. That's the whole install.
curl -O https://raw.githubusercontent.com/nsimona/hairline/main/hairline.css
<link rel="stylesheet" href="hairline.css">
No npm. No PostCSS. No config file.
Download hairline.css · Read the README · Browse examples
Philosophy
The premise
Most resets strip away browser defaults and leave you with nothing. Hairline strips the bad defaults and replaces them with good ones — a considered serif stack, balanced headings, readable measure, and honest typographic hierarchy that works out of the box for any editorial page.
Why serif?
The stack (Iowan Old Style → Palatino → Georgia) reads beautifully at long-form length.
Layer your own font-family on top for product UI — that's two lines of CSS.
Why weight-400 headings?
Light headings create hierarchy through size alone — a considered, magazine-like rhythm that doesn't shout.
Why min-width: 0?
Without it, flex and grid children overflow when content is wide. This one line prevents it on every element, globally.
Why 65ch measure?
Comfortable reading sits between 45–75 characters. 65ch is the sweet spot.
Change it via --measure in a single line.