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, so an unstyled first load with no class
and no system preference is a light page. A pinned choice writes
.theme-light or
.theme-dark on the document element and beats
prefers-color-scheme in both directions. Clearing the
choice removes the 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.
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.
The framework self-hosts nothing. Inter is named first in the sans stack and a metric-matched fallback face sits directly behind Inter, so a page where neither webfont is present does not reflow when text renders.