Skip to content

src/core - 4 files, 14,271 B gzip

Core

The only mandatory import. A page that loads these four files and nothing else is already themed, readable and keyboard operable, without a single class in the markup.

The markup panels below are read out of the live examples by site.js. With scripting off the examples still render and the panels stay empty.

Every number on this page is measured in your browser rather than transcribed into it. The pixel sizes under the type scale and the contrast ratio on each swatch are read out of the live cascade by src/behaviour/token-probe.js, in whichever theme you are in, at whatever width this window is, so a figure here cannot go stale while a token moves underneath it.

Reset

Deliberately small. It removes inherited inconsistency, it does not impose a look. Border-box inherits from html so a subtree can opt back out. Margin is owned here so a component never has to defend against a browser margin it did not ask for, and list padding is left alone so markers survive.

Two !important declarations exist in the whole framework and both are here, because layers beat specificity and only importance reverses layer order. One keeps [hidden] hidden even against a component rule. The other clamps every animation and transition under prefers-reduced-motion to 0.01ms, framework wide, so an author who forgets the media query cannot ship motion a reader has asked not to see. 0.01ms rather than zero, so transitionend still fires and a state machine waiting on it does not stall.

The reset also floors every button at the WCAG 2.2 SC 2.5.8 target size, pins textarea resize to the block axis so a reader cannot drag one past its container, and stops iOS inflating text on rotate.

Theme

Three states, not two. Light is the default, and it holds whatever the operating system asks for: a reader who has stored no choice gets .theme-light written before the first paint. A pinned choice writes .theme-light or .theme-dark on the document element and beats prefers-color-scheme in both directions. Choosing to follow the system writes no class and hands control back to the operating system, which is a state most implementations drop and most readers eventually want.

Change it, then read the ratios below
Theme

Right now this page is .

Show codeHide code

The control is a radio group and not a two-state toggle, because a toggle cannot express "follow the system" without a third press that reads as a bug. What makes the pinned class win is not source order: the prefers-color-scheme: dark block targets :root:not(.theme-light), so a pinned light theme is never matched by it in the first place, and the pinned blocks are two-class selectors that outrank the plain :root default wherever they sit.

Surfaces

Eight surfaces, re-declared per theme. They are lifted verbatim from the products this framework was extracted from and are not open to redesign. Every ink token below is measured against all eight of them, in both themes, and the worst case is what decides whether a pairing ships.

Body ink on each surface, measured live
  • --s-main

  • --s-card

  • --s-top

  • --s-side

  • --s-prev

  • --s-hover

  • --s-rail

  • --s-rail-foot

Show codeHide code

Each ratio is --bs-text against that surface, composited the way the browser paints it and compared against the 4.5:1 body floor. The two rail surfaces are the ones to watch when you pin the theme: they are the darkest and the lightest of the eight, which is why they decide most of the worst cases the token comments record.

Ink

Six theme-aware ink tokens, and the reason there are only six is that these are the values that clear AA against every surface. A raw palette colour is not a text colour until it has been measured.

Each one set on the card surface
  • Body and headings

    --bs-text

  • Supporting copy

    --bs-text-secondary

  • Labels and captions

    --bs-text-muted

  • --bs-text-link

  • Positive state

    --bs-text-ok

  • Negative state

    --bs-text-danger

Show codeHide code

These are measured on the card surface only, which is the friendliest of the eight. The figures in the table below are the worst case across all eight in both themes, and the worst case is what decided the token. Pin the theme to light and watch the link and status inks change value rather than merely change shade: they are different colours per theme for exactly this reason.

Semantic ink tokens and the worst case each one holds.
Token Role Why it is not the obvious value
--bs-text Body and headings 20.47:1 on the dark page, 13.82:1 on the worst dark surface.
--bs-text-secondary Supporting copy Uses the tertiary value, not the secondary one.
--bs-text-muted Labels, captions, markers The source secondary measures 4.47:1 on a card and 4.03:1 on hover, so the muted role is filled by a different value at 5.82:1 worst case.
--bs-text-link Links The brand iris tops out at 4.13:1 on the dark page, so links are periwinkle in dark and a darkened iris in light.
--bs-text-ok Positive state as text The fill colour is 1.37:1 as text on light. This is its text-weight sibling.
--bs-text-danger Negative state as text Same story: the fill is 2.28:1 as text on light.

Two borders, for the same reason. --bs-border-color is a decorative hairline at 1.55:1 and may only separate. --bs-border-strong clears the 3:1 non-text floor and is what identifies a control, which is what SC 1.4.11 asks for.

Spacing scale

Eleven steps, drawn here at the size they actually resolve to. Steps 1 to 4 are a linear 4px run because control padding, icon gaps and the boxes that sit next to 1px hairlines have to land on whole device pixels; a geometric run down there produces 6.4px and 10.2px and blurs the hairline it abuts. From step 4 the run turns geometric, alternating 3:2 and 4:3, so the scale doubles every two steps and every value is still an integer pixel at a 16px root.

Bar width is the token, not a picture of it
  • --bs-space-0

  • --bs-space-1

  • --bs-space-2

  • --bs-space-3

  • --bs-space-4

  • --bs-space-5

  • --bs-space-6

  • --bs-space-7

  • --bs-space-8

  • --bs-space-9

  • --bs-space-10

Show codeHide code

Step 0 draws nothing, which is the point of having it: a zero in the scale is what lets a component keep one property and vary the step rather than switch between declaring a gap and not declaring one. The measurements are in pixels because that is what the browser resolved, and they double if you raise your browser's font size, because the scale is in rem.

Radius scale

Each step is roughly the sum of the two below it, so subtracting one padding step from an outer radius lands on the next radius down and nested corners stay concentric instead of drifting. Step 3 is the source project's card radius, which is why every card on this page has that corner.

Real corners at real values
  • --bs-radius-0

  • --bs-radius-1

  • --bs-radius-2

  • --bs-radius-3

  • --bs-radius-4

  • --bs-radius-5

  • --bs-radius-full

Show codeHide code

The last one is a pill and not a circle: a large radius on a rectangle rounds to the shorter side, so it reports 9999px and draws the height. The border on each box is --bs-border-strong rather than the decorative hairline, because a corner you cannot see is not a demonstration of a corner.

Scales

The global scales and the reasoning behind each shape.
Scale Steps Shape
--bs-space-* 0 to 10 Linear 4px to step 4, then geometric. Control padding and icon gaps have to land on whole device pixels or they blur the hairline they abut.
--bs-radius-* 0 to 5, full Each step is roughly the sum of the two below it, so subtracting one padding step from an outer radius lands on the next radius down and nested corners stay concentric.
--bs-z-* 10 bands 100 apart. A component places its own internals at band plus 1 to 99, so no component needs to know another's z value.
--bs-duration-* 1 to 6 80ms to 600ms on a 1.5 ratio. Perceived duration is closer to logarithmic than linear, so a geometric run spaces the steps evenly to the eye.
--bs-container-* 3xs to 2xl, prose In rem so they scale with the reader's root font size. The prose width is in ch because comfortable line length is a function of the font.

Breakpoints are not custom properties, because a media query cannot read var(). The canonical values every file hardcodes are 30em, 48em, 64em, 80em and 90em, in em so they track font scaling rather than device pixels.

Element defaults

Bare selectors only, no classes. Links get an underline by default because SC 1.4.1 forbids colour as the only thing distinguishing a link from surrounding text. Focus is :focus-visible and never :focus, and the ring is drawn outside the box so moving focus never changes layout.

No class on anything below

A heading with no class

A paragraph, an underlined link, some inline code, a Ctrl keycap and small print. Select this sentence to see the selection colour, or press Tab to see the focus ring on the link.


  • Markers keep their colour from the muted ink token.
  • List padding is untouched so the markers are not orphaned.
Show codeHide code

Typefaces

The framework ships the faces it is drawn in. That is a change from naming them and hoping: the sans token has always asked for Inter, no operating system installs Inter, and a family a page names but does not serve is a family almost nobody sees. Every visitor without their own copy read this framework in whatever the rest of the stack resolved to, which is San Francisco on a Mac, Segoe UI on Windows and Roboto or Arial elsewhere. The design was one thing and the render was another on the overwhelming majority of visits, and nothing in the CSS said so.

The monospace token had the same problem and a worse symptom, because nothing in it was ever downloadable at all. It resolved to Consolas on Windows, SF Mono on a Mac and something else again on Linux, and those faces do not share a cap height. An eyebrow set beside a title is a measured ratio in the type scale, and it was landing differently on every platform.

So both faces are served from the same origin as the CSS. No Google Fonts request, no third-party connection, no cookie, nothing to consent to. Swapping either out is one value in your own stylesheet, and the section below shows it three ways.

<!-- one line, alongside the four mandatory core files -->
<link rel="stylesheet" href="/css/core/font.css">

It is not a fifth core file and nothing depends on it. Leave the line out and the page downloads no font at all, renders in the metric-matched system fallback, and costs exactly what it costs today. Copy fonts/ across with the CSS so the two keep their relative positions, or edit the url() paths in that one file.

A font is discovered only when the stylesheet that names it has been parsed, so the default is a two-hop chain: CSS, then font. A page that wants the face on first paint preloads the one subset it will use, and only that one, because a preload the page does not use is a download nobody asked for.

<link rel="preload" as="font" type="font/woff2" crossorigin
      href="/fonts/bare-sans-latin.woff2">

crossorigin is required even when the file is on your own origin. Fonts are fetched in anonymous CORS mode, and a preload without it is fetched a second time rather than reused.

What ships

A face called Bare Sans, which is Inter. Same bytes, different family name: declaring these subsets as Inter would shadow a real Inter on the machine of the one reader who already has the whole family, and serve them seven scripts instead of the face they installed. Inter sits directly behind Bare Sans in the stack for exactly that reader. Inter is OFL 1.1 and declares no Reserved Font Name, so the rename is permitted outright.

And a face called Bare Mono, which is JetBrains Mono, on identical terms: OFL 1.1, no Reserved Font Name, renamed for the same shadowing reason, with 'JetBrains Mono' left in the mono stack where it already was. It sets both monospace roles - --font-mono for code and --font-eyebrow for the uppercase label.

It was chosen on one measurement. The eyebrow is uppercase, so what pairs a mono label to sans text is cap height, and Bare Mono is 0.7300 em against Bare Sans at 0.7275 - 0.3% apart, or 0.03px at the eyebrow's 12px. Every other OFL candidate measured sits between 1.9% and 5.6% short. Consolas, which is what the eyebrow resolved to on Windows before this shipped, is 12.3% short: in the top bar, where a 12px eyebrow shares a line with a 17px title, the scale intends its caps at 70.6% of the title's, Bare Mono delivers 70.8% and Consolas delivered 61.9%. The label was reading a whole size-step smaller than the scale declares, and doing it differently on every platform.

One variable file per script, upright only, carrying the full weight axis as published: 100-900 for the sans, 100-800 for the mono, which is the axis JetBrains Mono's own font carries. The framework itself declares three weights - --bs-weight-body 400, --bs-weight-medium 500 and --bs-weight-heading 600 - so most of that axis is bytes a default page never renders. Clipping it would save 12,780 bytes on Latin and needs a font compiler, a rebuild step and a modified binary to keep. That is a build step in a framework whose first rule is that there is none, so the axis stays whole: set --bs-weight-heading: 700 and you get a real 700 rather than a smeared 600.

No italic file either. The sans path asks for italic in two places, one of them inside a monospace block, and a real italic is another 51,832 bytes on Latin alone to serve them. The browser slants the upright instead.

Bare Sans: every subset that ships, and what a page pays to render it.
Subset Codepoints Bytes Loaded when
latin 230 48,256 English and the ASCII punctuation set. The default path.
latin-ext 733 85,068 Central and Eastern European, Turkish, Welsh, Maltese.
greek 110 18,996 Modern monotonic Greek.
greek-ext 238 11,232 Polytonic Greek, so classical text as well as modern.
cyrillic 107 18,748 Russian, Bulgarian, Serbian, Ukrainian.
cyrillic-ext 157 25,960 The Central Asian and Caucasus Cyrillic additions.
vietnamese 115 10,252 The stacked tone and vowel marks Latin-ext does not carry.
Bare Mono: six files, not seven, and a thinner tail.
Subset Codepoints Bytes Loaded when
latin 229 40,404 Any code block or eyebrow on an English page. The default path.
latin-ext 190 15,196 The common Central European and Turkish letters, not the long tail.
greek 83 9,004 Modern monotonic Greek. There is no polytonic file.
cyrillic 102 12,108 Russian, Bulgarian, Serbian, Ukrainian.
cyrillic-ext 10 2,028 Ten characters, against 157 in the sans.
vietnamese 115 7,504 The stacked tone and vowel marks Latin-ext does not carry.

304,756 bytes sit in the directory and no page downloads that number. Every @font-face in src/core/font.css carries a unicode-range, so a browser fetches a file only when it actually paints a character inside that range. An English page takes the first row of each table and nothing else. A Greek page takes three, because its interface furniture is still Latin. A Japanese page takes none of it and renders in the reader's own system font. The two faces are billed separately as well: a page with no code block and no eyebrow on it never requests a mono file, whatever language it is in.

Read the Latin numbers against the CSS honestly rather than in isolation: 88,660 bytes of font on a page that paints both faces, against about 4.6 kB gzipped for all four mandatory core files, and woff2 is already compressed so there is no transfer saving left to find. The faces are by a wide margin the largest thing this framework can put on a page. That is why they are a file you link rather than something the tokens drag in, and why replacing them is a documented path rather than an escape hatch. Set your own family and none of it is ever requested, even with the stylesheet linked: a browser downloads a font file only when a rule that uses it matches something on the page.

These are the subsets Google publishes, copied byte for byte, so self-hosting them buys no bytes and is not claimed to. What it buys is the request: one origin instead of three, no DNS lookup, TCP connect and TLS handshake to a font CDN before the first glyph, nothing for a visitor to consent to, and no third party in a position to log the visit.

Use a different font

One custom property: --font-sans. Headings, display and body copy all resolve through it, so setting it once moves the whole page and there is no second stack to keep in step. Your stylesheet sits outside every cascade layer, so it wins with no specificity fight and no !important. Setting it also means the shipped woff2 files are never requested even with font.css linked, because a browser downloads a font only for a rule that matches.

The mono works the same way and is two properties rather than one, because the code face and the eyebrow face are allowed to differ. --font-mono is code, keyboard keys and samples; --font-eyebrow is the uppercase label. Point the second at the first if you want them to move together.

:root {
  --font-mono: 'Your Mono', ui-monospace, Menlo, Consolas, monospace;
  --font-eyebrow: var(--font-mono);
}

If you swap the mono and keep the sans, check the pairing rather than assuming it: the eyebrow is uppercase, and a mono whose cap height sits well below the sans reads a size-step smaller than the scale says it is. Bare Mono was picked to land within 0.3% of Bare Sans on exactly that measurement.

The narrower hooks are still there if you want the parts to differ: --bs-font-sans is headings and display, --bs-body-font is body copy, and --bs-font-mono and --bs-font-eyebrow are the two monospace roles. Each falls back to the token above, so setting none of them is the normal case.

A face the visitor already has. Nothing downloads.

/* your stylesheet, no build step involved */
:root {
  --font-sans: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
}

A font of your own, self-hosted. Same origin, same privacy properties as the shipped one.

@font-face {
  font-family: 'Your Face';
  src: url('/fonts/your-face.woff2') format('woff2');
  /* A range, not a list: one variable file covers the three weights the framework uses. */
  font-weight: 400 600;
  font-style: normal;
  font-display: swap;
}

:root {
  --font-sans: 'Your Face', system-ui, sans-serif;
}

'Inter Fallback' is deliberately not in that stack. It is a local alias of Arial and its metric twins with Inter's own metrics forced onto it, so a face that has not arrived yet occupies the same line boxes as the one that replaces it and the swap changes letterforms without moving them. Those overrides are measured against Inter and are wrong for any other face. Keep it only if your font is metric-compatible with Arial; if it is not, drop it, or declare the same four overrides against your own metrics.

A Google font, if you want one anyway.

<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Source+Sans+3:wght@400..600&display=swap">

<style>
  :root {
    --font-sans: 'Source Sans 3', system-ui, sans-serif;
  }
</style>

The preconnect is not optional there. Without it the first glyph waits on a DNS lookup, a TCP connect and a TLS handshake to a second origin, after the browser has already parsed a stylesheet from a third. The weight range is honest about intent but buys nothing: wght@400..600 and wght@100..900 return the identical file, so the whole axis arrives either way. That is the same axis the shipped files carry, from the same build, over two more connections.

A script that is not shipped

Bare Sans covers Latin, Greek, Cyrillic and Vietnamese, and that is the whole of it. It contains no Han, no Arabic, no Hebrew, no Devanagari, no Thai. Those are not missing subsets, they are different families, and no subsetting strategy produces a glyph the face never had. Add the family that has them, give it a unicode-range, and put it behind Bare Sans in the stack so Latin text still renders in Bare Sans.

Bare Mono covers the same scripts minus polytonic Greek, which Google publishes no subset for, and its extended Latin and Cyrillic are much thinner than the sans': 190 codepoints against 733, and 10 against 157. Nothing special happens at those edges. A character the mono does not have matches no range, is not downloaded, and is set in the next family --font-mono names, which is the reader's own monospace.

@font-face {
  font-family: 'Noto Sans SC';
  src: url('/fonts/noto-sans-sc-subset.woff2') format('woff2');
  /* Without this the file is fetched on every page, including the ones with no Han on them. */
  unicode-range: U+3000-303F, U+4E00-9FFF, U+FF00-FFEF;
  font-weight: 400 600;
  font-display: swap;
}

:root {
  --font-sans: 'Bare Sans', 'Noto Sans SC', 'Inter Fallback', system-ui, sans-serif;
}

Budget for it properly, because CJK is a different order of magnitude and rounding it off helps nobody. Noto Sans SC is 4,516,508 bytes across 101 files. Those files are cut for cache reuse rather than by frequency, so the hundred most common characters in a real article already pull nine of them, 521,872 bytes, and the full article pulls eighty-four, 4,323,972 bytes. Load a script like that on the pages that use it and never from a global stylesheet, and prefer the system CJK face if the design can take it: every device that reads Chinese, Japanese or Korean already has one.

If you copy the shipped files anywhere, copy fonts/OFL.txt with them. OFL 1.1 asks for the copyright notice and the licence text to travel with the font, and the subsetting pipeline strips the machine-readable licence field out of the binary, so a woff2 on its own no longer says what it is or who holds it. The package is MIT, these seven files stay OFL, and neither may be sold on its own.

Type scale

Fluid, with no media queries. Every step interpolates from a 320px base to a 1440px cap, so the mobile value is the authored value and widening is continuous rather than stepped. Both ends are bounded: nothing collapses on a phone and nothing keeps growing on a 5K display.

Two ratios, not one. Body-adjacent steps sit near 1.06 to 1.23 so body, lead and h6 stay in the same conversation, while heading steps open to about 1.25 and display jumps 1.42 to buy hierarchy. The ratio also widens with the viewport, because a phone cannot spend 48px on an h2.

Resize the window and watch it interpolate

Display

bs-text-display now on , authored 34 to 68

Heading 1

bs-text-h1 now on , authored 30 to 48

Heading 2

bs-text-h2 now on , authored 26 to 38

Heading 3

bs-text-h3 now on , authored 22 to 30

Heading 4

bs-text-h4 now on , authored 19 to 24

Heading 5

bs-text-h5 now on , authored 17 to 20

Heading 6

bs-text-h6 now on , authored 16 to 18

Body

bs-text-body now on , authored 15 to 17

Small

bs-text-small now on , authored 13 to 14

Caption

bs-text-caption now on , authored 12 to 13

Show codeHide code

The two figures on each line are read out of the live cascade, so at 320px they are the authored mobile values and at 1440px they are the caps. Drag the window across and they change continuously, because there is not a media query in the file.

A step is a size, a leading and a tracking together. Setting the size on its own is what produces 48px type on 1.6 leading. Line height falls as size rises, and tracking goes negative for display and positive for captions, because type set large needs tighter tracking than type drawn for text sizes.

Type utilities

Everything below lives in bare.utility, the last layer, so no utility in this framework needs !important to beat a component.

Named roles

Eyebrow, uppercase and tracked wide

Display

A lead paragraph. Larger, looser, and capped at a shorter measure than body copy so it reads as an introduction rather than as a first paragraph.

Body copy is capped at 66ch. Below about 45ch the eye returns too often, above about 75ch it loses the start of the next line, and line length is the largest single readability lever that almost nothing ships.

Monospace, optically corrected against the sans face.

Tabular figures 1234567890 for columns that must align.

Caption, the quiet role.

Show codeHide code

Also available: bs-measure-wide, bs-measure-lead, bs-measure-heading, bs-measure-display, bs-measure-none, the bs-leading-* and bs-tracking-* families, bs-weight-body|medium|heading, bs-balance, bs-pretty, and bs-flow-none and bs-flow-tight as rhythm escape hatches.

All of it renders in whatever the sans token names. Inter is first in that stack and is served from your own origin, with a metric-matched fallback face directly behind it so a page whose font has not arrived yet, or that has swapped it out entirely, does not reflow when text renders. See Typeface for what ships, what it costs and how to replace it.