Bootstrap 6 color system
Bootstrap 6 colors flow through a fixed pipeline: a handful of oklch() hues become full scales, a subset of those scales gets semantic roles, and components read the roles — never the hues. Customizing colors means picking the right layer to change, so start by identifying which one your task belongs to.
Workflow
- Step 1: Locate the layer you need to change
- Step 2: Base hues and scales
- Step 3: Theme colors
- Step 4: Layer colors for backgrounds, foregrounds, and borders
- Step 5: Color modes
- Step 6: Compile-time overrides
- Step 7: Runtime overrides
- Step 8: Functions and utilities
- Step 9: Verify
Step 1: Locate the layer you need to change
The pipeline, with the file that owns each stage:
- Base hues —
scss/_colors.scssdefines 16 hues asoklch()scalars and collects them in$colors. - Color scales — the same file derives steps
025–975from each hue withcolor-mix(), emitting--blue-500,--red-200, and so on into:root. - Theme colors —
scss/_theme.scssmaps semantic names (primary,danger, …) onto those scale tokens, one role at a time. - Root tokens —
scss/_root.scssemits every theme role as--<name>-<role>(--danger-bg-subtle) plus the layer color tokens. - Theme classes —
scss/helpers/_theme-colors.scssoutputs.theme-<name>in@layer helpers, remapping--<name>-<role>onto the generic--theme-<role>. - Components and utilities — read
--theme-*(with a neutral fallback) and the layer tokens. Nothing reads a raw hue.
Match the task to the layer: a different brand blue is stage 1, a new semantic color is stage 3, restyling one component is a component token (see the bootstrap-component skill), and a one-off tweak in an app is a runtime CSS variable override.
Step 2: Base hues and scales
Each hue is a single oklch() value that anchors step 500:
$blue: oklch(60% 0.24 240) !default;
The 16 hues are blue, indigo, violet, purple, pink, red, orange, amber, yellow, lime, green, teal, cyan, brown, gray, and pewter, plus $white and $black. $colors collects them through defaults(), so the map merges rather than replaces.
Scales are generated, not hand-picked. Every hue is mixed toward $tint-color (var(--white)) for steps 025 050 100 200 300 400 and toward $shade-color (var(--black)) for 600 700 800 900 950 975, in the $color-mix-space color space (lab). Change a hue and its whole scale re-derives.
Add a hue by merging into $colors — you get its full scale and any color utilities built from $colors automatically:
@use "bootstrap/scss/bootstrap" with (
$colors: (
"slate": oklch(55% 0.04 250),
)
);
Tint and shade percentages ($color-tints, $color-shades), the mixing space, and the mix targets are all configurable the same way. Individual steps can be overridden through $color-tokens.
Mixing happens against var(--white) / var(--black) on purpose: it keeps color-mix() live in the output instead of letting the CSS minifier flatten it into static lab() values.
Step 3: Theme colors
$theme-colors in scss/_theme.scss defines eight semantic colors — primary, accent, success, danger, warning, info, inverse, secondary — each a nested map of nine roles:
| Role | What it's for |
|---|---|
base |
The raw brand color the rest is derived from |
bg |
Solid background, e.g. a solid button or badge |
bg-subtle |
Tinted surface for alerts, subtle buttons, callouts |
bg-muted |
One step stronger than subtle |
fg |
Text on a subtle surface |
fg-emphasis |
Higher-contrast text on a subtle surface |
contrast |
Text and icons sitting on base / bg |
border |
Borders on subtle surfaces |
focus-ring |
Focus outline color |
Most roles are light-dark(…) pairs, and every value is a var() reference to a scale token rather than a literal color:
"danger": (
"base": var(--red-500),
"fg": light-dark(var(--red-600), var(--red-400)),
"bg-subtle": light-dark(var(--red-100), var(--red-900)),
"border": light-dark(var(--red-300), var(--red-600)),
"contrast": var(--white)
),
The map produces two things: --danger-fg-style tokens on :root, :host, and a .theme-danger class that assigns them to the generic --theme-fg, --theme-bg-subtle, and friends. That indirection is the whole point — a component written against --theme-* supports every color, current and future, with one rule.
Use .theme-reset on a nested subtree to set those --theme-* tokens to initial and return children to their component defaults.
The primary, success, and danger keys are required; other code (links, focus rings, form states, checked controls) reads their tokens. Renaming or removing them breaks the build.
Step 4: Layer colors for backgrounds, foregrounds, and borders
Neutral surfaces don't live in $theme-colors. Three separate maps in scss/_theme.scss cover them:
$theme-bgs→--bg-body,--bg-1through--bg-4(increasingly contrasty surfaces), plus--bg-white,--bg-black,--bg-transparent.$theme-fgs→--fg-body,--fg-1through--fg-4(decreasing emphasis).$theme-borders→--border-body,--border-subtle,--border-muted,--border-emphasized.
Use these as the unthemed fallbacks in component tokens: var(--theme-bg-subtle, var(--bg-1)), var(--theme-border, var(--border-color)). Note that --border-color is a separate global token in $root-tokens — the default border color for components — distinct from this --border-* family.
Unlike $theme-colors, these are flat maps, which makes them easy to extend with an extra surface step. All four maps merge through defaults(), so pass only the keys you are changing.
Step 5: Color modes
Bootstrap 6 does not redeclare variables per mode. Every adaptive token is a light-dark() pair, and CSS resolves it from the inherited color-scheme:
:root, :hostsetscolor-scheme: light dark, so the default is whatever the OS prefers.[data-bs-theme="dark"]and[data-bs-theme="light"]setcolor-schemeon that subtree, forcing a mode. It works on any element, nests, and needs no JavaScript.
So switching modes is a matter of setting one attribute, and authoring adaptive colors is a matter of using light-dark() instead of writing a dark-mode override block.
Two mixins exist for the cases light-dark() can't cover:
// Non-color values can't go through light-dark()
@include color-mode(dark, $root: true) {
--shadow-strength: 2.4;
}
color-mode($mode, $root) honors the $color-mode-type config, which defaults to "media-query" (emitting @media (prefers-color-scheme: …)); set it to "data" to emit [data-bs-theme="…"] selectors instead. color-scheme($name) is the plain media-query wrapper when you always want the query.
Custom modes work the same way: add your own [data-bs-theme="blue"] selector and override tokens inside it.
Step 6: Compile-time overrides
Maps built with defaults() — $colors, $color-tokens, $root-tokens, $theme-colors, $theme-bgs, $theme-fgs, $theme-borders, and every component $*-tokens — merge. Pass only the keys you're changing, and pass null to remove one.
Nested theme-color maps merge one level deep. To change one role of a built-in theme, pass the whole sub-map for that theme. A partial sub-map replaces the theme's other roles.
Retuning an existing theme color is still easiest one layer down — change the hue and everything derived from it follows:
@use "bootstrap/scss/bootstrap" with (
$blue: oklch(58% 0.21 250)
);
To add or restyle a theme color, pass a partial $theme-colors map through the main entrypoint. defaults() keeps the built-ins. Keep primary, success, and danger unless you replace their whole sub-map:
@use "bootstrap/scss/bootstrap" with (
$theme-colors: (
"brand": (
"base": var(--indigo-500),
"fg": light-dark(var(--indigo-600), var(--indigo-400)),
"fg-emphasis": light-dark(var(--indigo-800), var(--indigo-300)),
"bg": var(--indigo-500),
"bg-subtle": light-dark(var(--indigo-100), var(--indigo-900)),
"bg-muted": light-dark(var(--indigo-200), var(--indigo-800)),
"border": light-dark(var(--indigo-300), var(--indigo-600)),
"focus-ring": light-dark(color-mix(in oklch, var(--indigo-500) 50%, var(--bg-body)), color-mix(in oklch, var(--indigo-500) 75%, var(--bg-body))),
"contrast": var(--white)
)
)
);
When all you want is one more theme color without utilities, skip the Sass config and write the class yourself. Components only care about the --theme-* tokens:
@layer helpers {
.theme-brand {
--theme-base: var(--purple-500);
--theme-bg: var(--purple-500);
--theme-bg-subtle: light-dark(var(--purple-100), var(--purple-900));
--theme-fg: light-dark(var(--purple-600), var(--purple-400));
--theme-border: light-dark(var(--purple-300), var(--purple-600));
--theme-contrast: var(--white);
}
}
.theme-brand then works on every theme-aware component. The trade-off: utilities and --brand-* root tokens are generated from $theme-colors, so .bg-brand and var(--brand-bg) won't exist.
Step 7: Runtime overrides
Every token is a CSS custom property, so most color changes need no recompile at all:
/* Global — repeat on :host if you adopt the stylesheet in a shadow root */
:root,
:host {
--bs-primary-bg-subtle: #eef2ff;
}
/* One subtree */
.marketing-hero {
--bs-theme-bg-subtle: #eef2ff;
}
Token names are written unprefixed in Sass (--primary-bg-subtle). Bootstrap's dist and CDN CSS run PostCSS to add the --bs- prefix, so use --bs-* when overriding the shipped CSS and unprefixed names when you compile the source yourself.
Runtime overrides are the right tool for per-page or per-tenant theming; compile-time config is for changing the system's defaults.
Step 8: Functions and utilities
Helpers in scss/_theme.scss for reading across the color maps, mostly used when generating utilities:
theme-color-values($role)— flat map of color name to the raw value of that role.theme-color-refs($role)— same, but asvar(--<name>-<role>)references.theme-token-refs($map, $prefix)— the equivalent for the layer color maps.theme-opacity-values($var, $fallback)— an opacity ladder built withcolor-mix()againsttransparent.
color-contrast($background) in scss/_functions.scss returns a light, dark, or black foreground using the WCAG contrast algorithm. It works on Sass color values, not var() references, so it's for build-time swatches and generated classes — the contrast role in $theme-colors is the runtime answer.
The generated utilities follow the role names: .fg-primary, .fg-emphasis-primary, .fg-contrast-primary, .bg-primary, .bg-subtle-primary, .bg-muted-primary, .border-primary, and .border-subtle-primary, for every theme color. The layer colors get the same treatment (.bg-1 through .bg-4, .fg-1 through .fg-4, .border-subtle), and opacity is a numeric ladder on the same class names — .fg-50, .bg-70, .border-30, in steps of 10.
Reach for .theme-* instead when you want a whole element themed rather than one property recolored.
Step 9: Verify
npm run css-lint, thennpm run css.- Grep the compiled
dist/css/bootstrap.cssfor the tokens you expected (--bs-primary-bg-subtle,--bs-slate-500) to confirm they were emitted. - Check both color modes: OS preference,
data-bs-theme="dark", and a nesteddata-bs-theme="light"inside it. - Verify contrast for any new theme color — at minimum
contrastonbg, andfgonbg-subtle, in both modes. - If you added or removed entries in
$colorsor$theme-colors, confirm the generated utilities and.theme-*classes changed accordingly, and thatnpm run docs-buildstill passes (the docs render swatches from these maps).
