Right now this page is .
Show codeHide code
src/core - 4 files, 14,271 B gzip
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.
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.
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.
Right now this page is .
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.
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.
--s-main
--s-card
--s-top
--s-side
--s-prev
--s-hover
--s-rail
--s-rail-foot
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.
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.
Body and headings
--bs-text
Supporting copy
--bs-text-secondary
Labels and captions
--bs-text-muted
A link
--bs-text-link
Positive state
--bs-text-ok
Negative state
--bs-text-danger
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.
| 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.
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.
--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
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.
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.
--bs-radius-0
--bs-radius-1
--bs-radius-2
--bs-radius-3
--bs-radius-4
--bs-radius-5
--bs-radius-full
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.
| 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.
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.
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.
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.
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.
| 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. |
| 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.
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.
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.
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.
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
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.
Everything below lives in bare.utility, the last layer,
so no utility in this framework needs !important to beat a
component.
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.
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.