Migrating to v6

Track and review changes to the CoreUI for Bootstrap source files, documentation, and components to help you migrate from v5 to v6.

Work in progress. v6 is under active development on the v6-dev branch — this page tracks every migration-relevant change as it lands, so nothing here is final until a v6 release ships. The class API keeps its meaning — .btn-primary, .alert-success and friends render as before. Where the Sass or CSS-variable surface does change, the entry below carries a Breaking badge.

v6.0.0 (unreleased)

Dependencies

  • Popper is gone; @floating-ui/dom replaces it. Breaking Every positioned component — tooltips, popovers, dropdowns, autocomplete, multi-select, and the pickers — now positions with Floating UI. @popperjs/core is no longer a peer dependency and the bundle builds (coreui.bundle.*) no longer include it. What to change:
    • npm: replace @popperjs/core with @floating-ui/dom in your dependencies.
    • CDN (non-bundle builds): replace the popper.min.js script tag with the two Floating UI files, in this order: floating-ui.core.umd.min.js, then floating-ui.dom.umd.min.js.
    • The popperConfig option (tooltip, popover, dropdown) is now floatingConfig and receives a Floating UI computePosition config — Popper’s modifiers array does not translate; use Floating UI middleware.
    • The tip and menu carry data-coreui-placement instead of Popper’s data-popper-placement; CSS or JS keying on the old attribute needs the new name.
    • Positioning is now asynchronous (computePosition is promise-based). Code that read the tip’s coordinates synchronously right after show() should wait for the shown event.

Distributed files

  • The *.rtl.css builds are gone. Breaking One stylesheet now serves both directions, so dist/css no longer carries coreui.rtl.css, coreui-grid.rtl.css, coreui-reboot.rtl.css, coreui-utilities.rtl.css, their minified twins, source maps or SRI hashes — 20 files in all. Everything positional was already written with logical properties; the six places that genuinely differ (the off-canvas panel, the range slider’s label and tooltip, the select and validation arrows, the carousel controls, the breadcrumb divider) now emit their mirrored declaration from a :dir(rtl) rule in the same file, matched on the element’s own direction rather than on an ancestor. That costs about 500 bytes gzip, where an RTL reader used to download a second 56 kB stylesheet.

    What to change: drop the .rtl from your stylesheet link and keep dir="rtl" on <html>. $enable-rtl: false still produces an LTR-only build and $enable-ltr: false an RTL-only one, so nothing that was possible before stops being possible.

  • The Bootstrap theme and the Bootstrap-compatible JavaScript are gone. Breaking dist/css/themes/bootstrap/bootstrap.css, dist/js/bootstrap.js, bootstrap.bundle.js and bootstrap.esm.js no longer ship, with their minified twins, source maps and SRI hashes, and neither does scss/themes/bootstrap/bootstrap.scss. The two halves no longer agreed in v6: the JavaScript wrote data-bs-* attributes and --bs-* properties where the stylesheet still read data-coreui-* and --cui-* ones.

    What to change: load coreui.min.css and coreui.bundle.min.js (or coreui.min.js), write data-coreui-* attributes, listen for *.coreui.* events, reach the plugins through coreui instead of bootstrap, and set --cui-* properties instead of --bs-*.

  • dist/css gained base.css and a stylesheet per component. base.css holds the :root tokens and Reboot; css/components/ holds one file per component plus manifest.json, which records each file’s load order and the components it needs alongside it. Nothing was taken away — coreui.css still carries everything — so this is additive, and pages that use a few components can now ship a few kilobytes of CSS instead of the whole library. See per-component CSS files.

  • dist/css gained coreui-legacy.css, and coreui.css lost the v5 spellings it carries. Breaking coreui.css is the v6 class API only. The deprecated v5 spellings — the colour modifiers (.alert-{color}, .btn-{color}, .btn-outline-{color}, .btn-subtle-{color}, .btn-ghost-{color}, .callout-{color}, .chip-{color}, .list-group-item-{color}), the form check markup (.form-check, .form-check-input, .form-check-label, .form-check-inline, .form-switch, .form-switch-lg / -xl) and .btn-check on the input — moved to coreui-legacy.css, 3.4 kB gzip. That takes 2.1 kB gzip off coreui.css for markup that has already moved, and turns the compatibility layer into something you can see on a network tab and delete in one line.

    What to change: if your markup still uses any of them, add the sheet after coreui.css (or last, after base.css and the component files):

    <link href="css/coreui.min.css" rel="stylesheet">
    <link href="css/coreui-legacy.min.css" rel="stylesheet">

    From Sass, @use "coreui-legacy"; next to your @use "coreui";. Everything in it is still removed in v7. See v5 compatibility CSS.

  • $enable-deprecated-classes and $enable-deprecated-form-check are gone. Breaking A separate stylesheet answers the question both flags existed for, without a recompile: false is now “do not load coreui-legacy.css”. Sass builds that set either flag fail with an unknown-variable error — delete the line.

Browser support

Breaking

The minimum supported browsers are now Chrome 123, Edge 123, Firefox 129, Safari 18 and iOS 18. Safari 18 is set by color-mix(): a mix in oklch with an achromatic operand — transparent, white, black, the greys, which is how every translucent and tinted colour in v6 is made — keeps the other colour’s hue only from Safari 18, where WebKit started carrying missing components forward as the spec requires; Safari 17.x interpolates the hue towards 0° and renders indigo as pink. Apple ships Safari 18 for macOS 13 and 14 as well, so the cut only affects browsers left un-updated. light-dark() sets the Chrome/Edge floor; @starting-style and transition-behavior: allow-discrete set Firefox 129 (the Menu’s entry animation) and @property needs 128 — its inherits: false registrations are what stop the colour and opacity utilities leaking into descendants, so an older Firefox does not lose an animation, it gets a bug. :has() sets a lower bound of Firefox 121, since validation and focus states on Multi Select, Autocomplete and the pickers, the sidebar navigation indicators, calendar ranges and the rating icons are all selected with it — and an unsupported :has() invalidates the entire rule it appears in. This is the floor the v6 color system already required in practice: theme decisions resolve in the browser via light-dark() and color-mix() with no fallback, so on older browsers dark mode did not work regardless of what the previous .browserslistrc claimed (Chrome 60 / Safari 12). v6 makes the declaration honest and takes the wins: the compiled CSS drops legacy vendor prefixes (about 8 kB gzip off coreui.min.css) and the JavaScript ships modern syntax un-transpiled. If you must support older browsers, stay on v5.

JavaScript

v6’s JavaScript is written in TypeScript. The published behaviour is unchanged — the sources were converted module by module against a gate that required the minified bundles to stay byte-identical — but the package now carries types, and what it ships from js/ has changed.

Type declarations ship with the package

@coreui/coreui-pro now ships .d.ts files and points types at js/dist/index.d.ts, so TypeScript consumers get the full API without @types packages or hand-written module declarations:

import { Modal, MultiSelect } from '@coreui/coreui-pro'

const modal = new Modal('#example', { backdrop: 'static', keyboard: false })
const version: string = Modal.VERSION

Each component with options also exports its config type (ContextMenu and Dropdown take Menu’s MenuConfig), so an options object can be typed and reused, and takes it in the constructor and getOrCreateInstance, so an option it does not know is a type error; a picker also takes the options it forwards to its calendar, its field and its time columns, except those it sets itself. Declarations are generated from the sources, not maintained by hand, and CI type-checks a consumer file against the emitted declarations, so the shipped types cannot drift from the code.

The js/src sources ship as TypeScript

Breaking

The package used to ship js/src/**/*.js; it now ships js/src/**/*.ts. The documented per-plugin path is unaffected — js/dist/*.js still ships, UMD-ready, and that is what the Webpack, Vite and Parcel guides tell you to import:

import Alert from '@coreui/coreui-pro/js/dist/alert'

A bundler that reads the package’s exports now resolves that import to the ES module next to the UMD file, js/dist/alert.mjs; a require() still gets js/dist/alert.js.

Only imports that reached into the raw sources are affected:

- import Alert from '@coreui/coreui-pro/js/src/alert.js'
+ import Alert from '@coreui/coreui-pro/js/dist/alert.js'

If your toolchain compiles TypeScript, you can import js/src/*.ts directly and get types and tree-shaking together. The per-plugin files are typed as well — js/dist/alert.js ships next to js/dist/alert.d.ts, and js/dist/alert.mjs next to js/dist/alert.d.mts.

js/index.esm.js and js/index.umd.js are no longer published

Breaking

These two files are the bundler’s entry points, and they import the sources — which are now TypeScript, so the files could not resolve for a consumer anyway. They are no longer part of the published package. Use the built bundle instead:

- import { Tooltip } from '@coreui/coreui-pro/js/index.esm.js'
+ import { Tooltip } from '@coreui/coreui-pro/dist/js/coreui.esm.js'

or, if you are already resolving the package by name, import { Tooltip } from '@coreui/coreui-pro' — module points at the same ESM bundle.

Unused date helpers are gone from js/dist/util

Breaking

The util modules are internal, but the package exports every file under js/dist, so a deep import could reach them. These helpers are no longer exported: js/dist/util/date-range-picker.js as a whole, getFirstAvailableDateInRange, getSelectableDates, createDateFromMonth, createDateFromWeek and createDateFromYear from util/calendar.js, getDateSections from util/date-sections.js (now getSectionLayout), and convertTimeToDate, formatTimePartials, getAmPm, isValidTime and shouldUseTwoDigitHour from util/time.js. Most had no reader in the library and are gone; createDateFromYear, formatTimePartials and shouldUseTwoDigitHour stay as module internals. Pass dates to the components in the formats their options take (startDate: '2026W03' with selectionType: 'week'), or keep a copy of the helper in your code. The nine per-unit comparisons of util/calendar.js, isMonthDisabled, isMonthInRange, isMonthSelected and their isQuarter… and isYear… counterparts, are three functions that take the calendar view as the second argument: isPeriodDisabled(date, 'months', min, max, disabledDates), isPeriodInRange(date, 'quarters', start, end) and isPeriodSelected(date, 'years', start, end).

CalendarUtil and RangeUtil in the per-file UMD builds

Loaded with a plain <script>, js/dist/util/calendar.js registers as CalendarUtil, and the new js/dist/util/range.js as RangeUtil. The calendar util used the Calendar global of the component, so the two files overwrote each other and js/dist/calendar.js read its helpers from itself. Load the util file before the component, as with the other js/dist/util files.

coreui.Offcanvas in the UMD bundle

The UMD bundle exposed the offcanvas plugin as coreui.OffCanvas, while the ESM bundle, the per-plugin file and the docs all call it Offcanvas. The UMD global now carries coreui.Offcanvas as well; coreui.OffCanvas stays as an alias of the same class, so existing scripts keep working:

- const offcanvas = new coreui.OffCanvas('#offcanvasExample')
+ const offcanvas = new coreui.Offcanvas('#offcanvasExample')

The UMD bundle keeps what companion packages put on window.coreui

coreui.js and coreui.bundle.js used to replace window.coreui with a fresh object, so coreui.Utils, coreui.ChartJS, coreui.DataGrid or coreui.Scheduler loaded before the library were gone once it loaded. The library now copies the entries of an existing plain window.coreui into its own object, so the order of the script tags no longer matters. Anything else under that name, such as an element with id="coreui", is still replaced.

One jQuery dispatcher for every plugin

Every $(el).plugin('method') call now goes through the same dispatcher: the instance is created or reused, a string argument names a public method, and anything else throws TypeError: No method named "…". A few plugins used to let a private name through ($(el).tooltip('_getConfig') called it) or skipped the check altogether (button, number-input, password-input); they now reject those names like the rest.

The attribute that enables a plugin

data-coreui-toggle="…" now means one thing only: this element is a control the user activates. It stays on the <button>, <a> or <input> that opens, closes or presses something — Modal, Offcanvas, Dialog, Drawer, Collapse, Tab, Tooltip, Popover, Context Menu, Combobox, Button, Loading Button.

Everywhere else the element is a mount point or a source: the plugin builds the component inside or around it, and nothing is toggled. Those take a bare data-coreui-<name>, the shape Chip Input, Chip Set, Multi Select and the masked date and time fields already used. Renamed:

- <div data-coreui-toggle="date-picker"></div>
+ <div data-coreui-date-picker></div>
wasis
data-coreui-toggle="autocomplete"data-coreui-autocomplete
data-coreui-toggle="calendar"data-coreui-calendar
data-coreui-toggle="date-picker"data-coreui-date-picker
data-coreui-toggle="date-range-picker"data-coreui-date-range-picker
data-coreui-toggle="list-box"data-coreui-list-box
data-coreui-toggle="number-input"data-coreui-number-input
data-coreui-toggle="otp"data-coreui-otp
data-coreui-toggle="password-input"data-coreui-password-input
data-coreui-toggle="password-strength"data-coreui-password-strength
data-coreui-toggle="range-slider"data-coreui-range-slider
data-coreui-toggle="rating"data-coreui-rating
data-coreui-toggle="stepper"data-coreui-stepper
data-coreui-toggle="time-picker"data-coreui-time-picker
data-coreui-toggle="transfer"data-coreui-transfer

The JavaScript constructors are unchanged — this is the markup entry point only, and only in the vanilla package.

data-coreui="navigation" is gone. The sidebar navigation read two attributes for one job; the v4-era spelling was kept for compatibility and is now dropped. Use data-coreui-navigation, which v5 already documented as the one to write.

Lifecycle methods return a promise

show(), hide(), toggle() and close() returned before the transition ended, so continuing after one meant listening for shown.coreui.* / hidden.coreui.*. They now return a promise that resolves at the same moment that event fires, so you can await the method instead:

- myCollapseEl.addEventListener('shown.coreui.collapse', () => {
-   // the area is expanded
- })
- collapse.show()
+ await collapse.show()
+ // the area is expanded

Collapse, Menu, Modal, Offcanvas, Popover, Sidebar and Tooltip return one from show/hide/toggle, Toast from show/hide, Tab from show, and Alert from close. The event path is unchanged and still the right tool when you must react to changes your own code did not start.

The promise always settles — a call that does nothing (already open, a prevented show.coreui.*, an instance disposed mid-transition) resolves right away rather than stranding the caller. It resolves with no value, so it tells you the call finished, not whether the state changed.

Live changes go through setters, not update(config)

Breaking

update() meant two different things. On Combobox, List box, Transfer, Menu, Progress, Range, Tooltip and Popup it takes no argument and recomputes from what the component already owns; on Autocomplete, Calendar, MultiSelect, OTP Input, Range Slider, Rating and the date/time inputs it took an options object and replaced the configuration. Calling it the first way on a component that meant the second one threw a TypeError.

Options are now fixed once a component starts. What can change on a live instance, its value, its data and its state, has a method of its own, named after the option it changes:

- multiSelect.update({ options })
+ multiSelect.setOptions(options)
- rating.update({ value: 2 })
+ rating.setValue(2)
- dateInput.update({ validationState: 'invalid' })
+ dateInput.setValidationState('invalid')
- autocomplete.update({ disabled: true })
+ autocomplete.disable()
ComponentMethods
AutocompletesetValue(), setOptions(), setValidationState(), disable(), enable()
CalendarsetStartDate(), setEndDate(), setRange(), setCalendarDate(), setSelectEndDate()
Chip Input, ComboboxsetValidationState(); Combobox keeps setValue() and setItems()
Date Input, Time InputsetDate(), setValidationState(), disable(), enable()
Date Range Input, Date Range PickersetStartDate(), setEndDate(), setRange(), setValidationState()
Date Picker, Time PickersetDate() / setTime(), setValidationState()
Multi SelectsetValue(), setOptions(), setTotal(), setValidationState(), disable(), enable()
OTP Input, Range Slider, RatingsetValue(), setValidationState(), disable(), enable()
  • A setter takes the type of its option. A wrong type throws the TypeError the constructor throws, and the component is left as it was; Autocomplete and MultiSelect skipped that check on update().
  • The date and time setters fire no change event, since the code that calls them already holds the value. setDate() of Date Input, Time Input and Date Picker, setTime() of Time Picker, setStartDate(), setEndDate() and setRange() of Date Range Input, Date Range Picker and Calendar, and setCalendarDate() and setSelectEndDate() of Calendar change the value without dateChange, timeChange, startDateChange, endDateChange, calendarDateChange, selectEndChange or the native input and change of the value field; errorChange still reports a date the field refuses. The verbs (clear(), reset(), today(), now()) and what the user does fire as before. setDate(date, true) of Date Input and Time Input and the same last argument of the Date Range Input setters fire the events typing the date would, for a date the user gives through another control.
  • Other options are fixed. A new format, size, label, icon, name, required or readonly means disposing the instance and creating a new one. update() applied some of them, such as format on Date Input or size on Rating, and ignored the rest in silence on Combobox, Chip Input, Date Range Input and the pickers.
  • A setter keeps what it does not set. setValue() keeps a given validation state, setValidationState() keeps the value, and Rating and Range Slider no longer fall back to the defaults: rating.setValue(2) on a rating built with { itemCount: 3 } keeps three items.
  • The setters are reachable the same way everywhere, including through the static and jQuery dispatchers, which pass the method name and whatever follows it: coreui.Rating.ratingInterface(el, 'setValue', 2).

Toaster.update(id, options) is unchanged — it rewrites one toast in the store rather than reconfiguring the component.

A getter says what it returns

Breaking

getSelected() returned three different things. List box gave the selected values, Chip Set gave the selected elements while getSelectedValues() sat next to it giving the values, and Transfer required an argument no other component takes:

chipSet.getSelected().join(',') // [object HTMLDivElement],[object HTMLDivElement]
transfer.getSelected()          // TypeError: Cannot read properties of undefined

Every getter now carries the noun it returns:

- listBox.getSelected()
+ listBox.getSelectedValues()

- chipSet.getSelected()
+ chipSet.getSelectedValues()

- transfer.getSelected('source')
+ transfer.getSelectedValues('source')

Chip Set’s getValues() and getSelectedValues() are unchanged and are now the whole selection API: the element list behind them is internal. A chip carries a value of its own — data-coreui-chip-value, falling back to the chip’s text — so values, not nodes, are what the rest of the plugin works with.

Transfer still takes 'source' or 'target', the argument its setLoading(loading, list) already takes. Anything else now throws a TypeError naming the two lists instead of failing on an undefined property.

The button that opens a panel is pickerIcon

Breaking

indicator named two unrelated things. On Autocomplete it was a boolean that showed the button opening the options menu; on List box, Combobox and Transfer it is a string choosing the marker drawn on each option. The button also answered to three different names across the family — indicator, indicatorIcon, and ariaIndicatorLabel next to ariaToggleLabel.

The word stays where it describes something that indicates — the per-option marker — and the button takes the name the platform gives it (::picker-icon, ::picker(select), showPicker()):

- new coreui.Autocomplete(el, { indicator: true })
+ new coreui.Autocomplete(el, { pickerIcon: true })

- new coreui.DatePicker(el, { indicatorIcon: mySvg, ariaToggleLabel: 'Open' })
+ new coreui.DatePicker(el, { pickerIcon: mySvg, ariaPickerLabel: 'Open' })

- new coreui.MultiSelect(el, { optionsStyle: 'text' })
+ new coreui.MultiSelect(el, { indicator: 'none' })
wasison
indicator: booleanpickerIcon: string | booleanAutocomplete
indicatorIconpickerIconthe four pickers
ariaIndicatorLabelariaPickerLabelAutocomplete, Multi Select
ariaToggleLabelariaPickerLabelthe four pickers
optionsStyle: 'checkbox' | 'text'indicator: 'checkbox' | 'none'Multi Select

pickerIcon takes over both jobs the old pair did: false renders no button, true renders the built-in icon, and a string renders your own inline SVG (sanitized). Autocomplete and Multi Select gained it — their icon used to be hard-coded — so pickerIcon: false now hides Multi Select’s button too.

Unchanged: indicator on List box, Combobox and Transfer, along with the .list-box-option-indicator class and the data-coreui-indicator attribute; Password Input’s ariaToggleLabel, because that button toggles visibility rather than opening a picker; and the shared .form-control-action class, which eight components use for three different jobs.

The “N selected” text is one option, and it can be a function

Breaking

Four components rendered the same sentence under three option names and two shapes: Combobox substituted {count} into selectedText, List box and Transfer appended selectedCounterText after count/total, and Multi Select appended selectionTypeCounterText after the count.

Appending cannot be translated. A translator receives only the tail of the sentence, so the number cannot move and the noun cannot be inflected — Polish needs “2 zaznaczone”, “5 zaznaczonych” and “1 zaznaczony” from the same option, and a language that puts the number last cannot be served at all.

- new coreui.ListBox(el, { selectedCounterText: 'selected' })
+ new coreui.ListBox(el, { selectedLabel: (count, total) => `${count}/${total} selected` })

- new coreui.Combobox(el, { selectedText: '{count} selected' })
+ new coreui.Combobox(el, { selectedLabel: (count) => `${count} selected` })

- new coreui.MultiSelect(el, { selectionTypeCounterText: 'item(s) selected' })
+ new coreui.MultiSelect(el, { selectedLabel: (count) => `${count} item(s) selected` })
wasis
selectedText (Combobox)selectedLabel
selectedCounterText (List box, Transfer)selectedLabel
selectionTypeCounterText (Multi Select)selectedLabel

The string form stays, because a function cannot be written into data-coreui-selected-label: {count} and {total} are replaced in it, so markup-only pages keep a translatable counter.

The argument order matches the two function options the library already ships — Rating.ariaLabel(value, itemCount) and OTPInput.ariaLabel(index, total). total is passed everywhere, including the two components whose default text ignores it, so one function can serve all four.

One selection event, one payload key

Breaking

Seven components told you the selection had changed, under two event names and four payload keys. Autocomplete and Multi Select fired changed, everyone else fired change; the values arrived as value, selected, values, or as the pair sourceValues/targetValues. Nothing announced the difference, so code moved between two components of the same family read undefined:

// Multi Select rewritten as a Combobox — the listener never runs
el.addEventListener('changed.coreui.combobox', event => save(event.value))

// List box — the event fires, the value is undefined
el.addEventListener('change.coreui.list-box', event => save(event.value))

The verb is now change everywhere and the values arrive as event.value:

- el.addEventListener('changed.coreui.autocomplete', event => save(event.value))
+ el.addEventListener('change.coreui.autocomplete', event => save(event.value))

- el.addEventListener('changed.coreui.multi-select', event => save(event.value))
+ el.addEventListener('change.coreui.multi-select', event => save(event.value))

- el.addEventListener('change.coreui.list-box', event => save(event.selected))
+ el.addEventListener('change.coreui.list-box', event => save(event.value))

- el.addEventListener('change.coreui.chip-set', event => save(event.values))
+ el.addEventListener('change.coreui.chip-set', event => save(event.value))

- el.addEventListener('change.coreui.transfer', event => save(event.targetValues))
+ el.addEventListener('change.coreui.transfer', event => save(event.value))
wasis
changed.coreui.autocomplete, changed.coreui.multi-selectchange.coreui.*
event.selected (List box)event.value
event.values (Chip Set, Chip Input)event.value
event.targetValues (Transfer)event.value
event.sourceValues (Transfer)event.source

event.value carries what that component’s own getter returns, which is the one thing the key never told you before: getValue() for Autocomplete and Combobox, the selected values for List box (an array even in single mode), the selected options for Multi Select, the chip values for Chip Set and Chip Input. Transfer’s value is its target list, with the source list alongside as event.source; event.target stays the .transfer element.

Two events keep their own names because they answer a different question. select.coreui.chip-set still carries selected — membership and selection are separate in a chip set — and move/moved.coreui.transfer still carry direction and the values that are moving, which is the change itself rather than the state after it.

On a page that loads jQuery, the two renamed events now reach onchange handlers written as a DOM property or an inline attribute, on the element or any ancestor: jQuery’s own trigger drops the namespace for that one path. Measured in Chromium, a document.onchange handler goes from zero calls to one per selected option. The five components that already fired change have always behaved this way. Handlers bound with $(el).on('change') are not affected, and neither is addEventListener.

changed.coreui.range keeps its name. It is not a selection event: Range relays the value of the native <input type="range"> it decorates, on every native input and change. Range Slider, the component of this family that does have a selection, already fires change.

announce() (new)

announce(message, { context, priority, timeout }) reads a message to screen reader users through a visually hidden live region of the page, or of the open modal dialog, native or marked with aria-modal="true" like the panel of a date picker, since a modal dialog leaves the rest of the page inert. priority takes the values of ariaNotify(), 'normal' and 'high'. Toaster announces its toasts through it. See Accessibility.

Components

Accordion on the native <details>

  • Breaking The accordion renders on the native <details>/<summary> elements now. .accordion-item goes on a <details> element and .accordion-header on its <summary>; the browser opens and closes the panel, so the component needs no initialization and works on markup added after load.

  • Breaking .accordion-button, .accordion-collapse and .collapsed are gone. The trigger is the <summary> itself and the panel is the element’s own content, revealed through ::details-content. Style the open state with .accordion-item[open] > .accordion-header instead of :not(.collapsed).

  • Breaking Grouping moves from data-coreui-parent to the name attribute. Give the <details> elements of one accordion the same name to keep a single item open at a time; omit it for an always-open accordion. The id plumbing (data-coreui-target, aria-controls, aria-expanded) is no longer needed — the browser exposes the disclosure state on its own.

  • A <summary> is not a heading. Where the accordion divides a page into sections, wrap the label in an <h2>–<h6> inside the summary; the heading inherits the summary’s typography.

  • The header’s focus ring draws 1px inside the item (--cui-focus-ring-offset: -1px on .accordion-header:focus-visible), so it no longer overlaps the borders of adjacent items.

  • The panel animation is CSS-driven through interpolate-size: allow-keywords, which only Chromium implements today. accordion.js supplies the same animation in Firefox and Safari; that part of it goes away, without a breaking change, once they ship calc-size(). The component’s events and methods do not depend on it and stay either way.

  • Breaking The accordion’s events replace Collapse’s. shown/hidden.coreui.accordion fire on the .accordion-item whenever its open state changes, mirroring the native toggle event but bubbling, so they can be delegated. There is no show/hide counterpart: <details> has no cancelable event before the state change, so an item is kept shut by cancelling the click on its <summary> instead of by preventing an event.

  • Breaking The plugin replaces Collapse’s, and it is instantiated on the .accordion container, not on a panel. Where v5 called Collapse.getOrCreateInstance(panel).show(), v6 calls Accordion.getOrCreateInstance(accordion).show(item), with the item given as an element or as its index. hide() and toggle() take the same argument, and showAll() / hideAll() open and close the whole accordion — the latter pair also handles the name juggling the HTML Standard requires, which is why expanding an exclusive accordion is a plugin call rather than a loop over open. None of it is needed to open and close items; the element does that on its own.

  • The header draws its focus ring on :focus-visible, not :focus. A mouse click no longer paints a ring, keyboard focus still does, matching .btn.

  • New CSS variables: --cui-accordion-active-border-color (the open item’s border and the header’s inset shadow, previously stuck on the resting border colour), plus --cui-accordion-content-duration and --cui-accordion-content-easing, which drive the panel transition and are read back by accordion.js so both paths animate for the same length of time.

  • The indicator can be an element instead of a background image. Put an <svg class="accordion-icon"> in the <summary> and the header stops drawing its own icon, so any icon set or <img> can be used without URL escaping an SVG into a custom property. --cui-accordion-header-active-icon selects the shape shown while the item is open, and a second element marked .accordion-icon-active does the same in markup — that is what a plus/minus accordion needs, where the icon is swapped rather than rotated. The default chevron and its rotation are unchanged when neither is used.

  • Breaking The open item is neutral, and .theme-{color} is what tints it. --cui-accordion-active-bg and --cui-accordion-active-color were fixed to --cui-primary-bg-subtle / --cui-primary-fg-emphasis, so every accordion turned the brand colour on open whether or not that suited the page. They are --cui-bg-2 and --cui-fg-body now, and the active tokens resolve through --cui-theme-fg / -bg-subtle / -border first — so the colour is a decision you make per accordion instead of one baked into the component. To keep the old look, put .theme-primary on the .accordion.

  • Breaking --cui-accordion-inner-border-radius is gone; the header’s corners follow --cui-accordion-border-radius. The token was computed from the global --cui-border-radius when the stylesheet was built, so setting --cui-accordion-border-radius rounded the item while the header inside kept the page’s default radius. The two corners are subtracted where they are used — calc(var(--cui-accordion-border-radius) - var(--cui-accordion-border-width)) — so one token now shapes the whole item. Defaults render as before; drop any override of the removed variable.

  • Breaking The header tokens are named after the header. The summary is not a button any more, so --cui-accordion-btn-* is --cui-accordion-header-* (-color, -bg, -icon, -active-icon, -icon-width, -icon-color, -active-icon-color, -icon-transform, -icon-transition) and the $accordion-header-* Sass variables are $accordion-header-*. Rename in place; nothing else about them changed.

  • One padding token and one font size for the whole control. --cui-accordion-padding-x / -y and --cui-accordion-font-size are read by the header and the body alike, so resizing an accordion is one declaration instead of four. --cui-accordion-header-padding-* (was -btn-padding-*) and --cui-accordion-body-padding-* are still there for resizing one part, declared on .accordion as var(--cui-accordion-padding-*). Because they resolve where they are declared, override --cui-accordion-padding-* on .accordion (or a .accordion-* size class), not on a single .accordion-item — there it no longer reaches the header and the body.

  • Breaking The breadcrumb has no bottom margin. .breadcrumb sat on margin-bottom: 1rem through $breadcrumb-margin-bottom and --cui-breadcrumb-margin-bottom; both are gone and the list ends where its content ends, like every other navigation component. Add .mb-5 (or set margin-bottom on the <nav>) where the page relied on that gap.
  • Three breadcrumb tokens have real defaults. --cui-breadcrumb-bg, --cui-breadcrumb-border-radius and --cui-breadcrumb-font-size were null in Sass, so the first two were emitted empty (--cui-breadcrumb-bg: ;) and the third was not emitted at all. They are transparent, var(--cui-radius-5) and inherit now. Nothing renders differently on a default breadcrumb, and a font size set on an ancestor still reaches it — the one visible change is that a breadcrumb given a background now has rounded corners like every other surface. Set --cui-breadcrumb-border-radius: 0 to square it off.
  • --cui-breadcrumb-divider is now declared on .breadcrumb, so set it there and not on a wrapper. The property was read but never emitted, which left it out of DevTools and out of the CSS variables table, and meant a value set anywhere above the list reached the separators by inheritance. It is a real token now — and a token declared on .breadcrumb beats one inherited from an ancestor, so <nav style="--cui-breadcrumb-divider: '>'"> stops working. Move it onto the <ol class="breadcrumb">.
  • A custom divider now survives in RTL. --cui-breadcrumb-divider-flipped defaults to the main divider instead of to a second copy of the Sass default, so setting only --cui-breadcrumb-divider no longer leaves right-to-left pages on the built-in /. Set the flipped one only for a separator that points somewhere — > becomes < — as before.
  • New: .breadcrumb-link, an opt-in class that gives a step a padded, hoverable target. Bare links keep rendering as they always did. On a <button> it looks identical to a link, which is what lets one step toggle a menu of collapsed items — the composition is documented under Collapsed items, together with the reason not to reach for role="menu" there.
  • New: .breadcrumb-divider, a separator you can put in the markup. The automatic ::before divider stays exactly as it was and existing markup is untouched, but a <li class="breadcrumb-divider"> placed between two items carries whatever you put in it — an inline SVG, an icon, a character — with no URL escaping and no variable to set, and an empty one renders the default divider. The automatic divider steps aside wherever an element is placed (.breadcrumb-item + .breadcrumb-item stops matching), so the two never double up, and the spacing is identical either way. Give the element aria-hidden="true", since a screen reader would otherwise count the separators as steps in the trail.

Avatar

  • The avatar carries its own colours, and .theme-{color} tints it. --cui-avatar-bg and --cui-avatar-color are new: a bare avatar is a neutral circle instead of a transparent one, and a .theme-* class puts the theme colour behind the letters and the colour that reads on it in front of them. The docs used to reach for bg-{color} plus a hand-picked text-*, which fails WCAG AA on five of the six examples they shipped — measured 1.85 on bg-warning text-white, 2.94 on info, 3.50 on success, 3.69 on danger, and 3.12 on bg-secondary, which carried no text colour at all. Every theme colour now lands between 4.56 and 18.03. Background utilities keep working on an avatar; they just no longer have to.

  • New: .avatar-subtle, a tinted avatar that keeps the theme colour in the letters rather than behind them.

  • Breaking One size token replaces the width/height pair. --cui-avatar-width and --cui-avatar-height always carried the same value, in the base and in all four size classes, so setting a size meant writing it twice. They become --cui-avatar-size, and --cui-avatar-status-width / -height become --cui-avatar-status-size the same way.

    The label is derived from it — calc(var(--cui-avatar-size) * .4) — rather than listed per size. That ratio is what every size already used, so nothing renders differently; --cui-avatar-font-size still overrides it where a size needs its own.

    The Sass variables follow: $avatar-width / $avatar-height / $avatar-font-size / $avatar-status-width / $avatar-status-height become $avatar-size / $avatar-status-size plus $avatar-font-size-ratio, and $avatar-sizes keys drop to size and an optional status-size. The $avatar-widths map, deprecated since 5.1.0, is gone.

  • Breaking .avatar-img fills the avatar and crops instead of stretching. It was height: auto, which deformed every image that was not square; it is now height: 100% with object-fit: cover. Square images look the same.

  • Breaking The status indicator’s ring follows the theme. It was 1px solid $white, a hardcoded white that read as a mistake in dark mode. It now comes from --cui-avatar-status-border-color, defaulting to the body background.

  • Breaking A stacked avatar lifts on hover instead of pushing its neighbours. The hover changed margin-inline-end, which moved every avatar after it; it now uses transform, so nothing else shifts. $avatar-transition changes from margin .15s to transform .15s ease-in-out accordingly.

  • Stacked avatars wear a ring and stop pulling what follows them. Each avatar in a stack gets a border in the page background colour (--cui-avatar-stack-ring-width / -color), so overlapping photos stay separated; the last avatar drops its negative margin, which used to drag the content after the stack underneath it. The overlap is a token now — --cui-avatar-stack-spacing, the fraction of the avatar size — and .avatar-stack-xs through -xl size every avatar in a stack at once.

  • The status indicator follows .theme-{color} and is never invisible. .avatar-status reads the theme slot and falls back to the new --cui-avatar-status-bg (a neutral grey), so a bare indicator reads as “offline” instead of rendering as an empty ring. Background utilities keep working on it.

Alert

  • Breaking An alert without a theme class carries its own surface. --cui-alert-bg falls back to var(--cui-bg-1) instead of transparent, so a plain .alert no longer takes the color of whatever it sits on. Set --cui-alert-bg: transparent on the alert to restore the old rendering.

  • Breaking The alert lays its children out in a row. It is display: flex with align-items: start and --cui-alert-gap between children, so an icon next to a label needs no d-flex and no spacing utility. Text mixed with inline elements is a single anonymous item and is unaffected, but an alert built from several block elements — a heading, paragraphs, an <hr> — now sits side by side. Wrap that content in a .vstack:

    <div class="alert alert-success" role="alert">
      <div class="vstack">
        <h4 class="alert-heading">Great job!</h4>
        <p>…</p>
      </div>
    </div>
  • Breaking A solid alert is .alert-solid, not a pile of utilities. The docs used to say to drop the contextual class and reach for bg-{color} plus a text-* class picked by hand — and the examples hard-coded text-white, which fails WCAG AA on five of the eight theme colours: measured 3.50 on success, 3.69 on danger, 2.94 on info, 1.85 on warning and 1.10 on light, where white text on a near-white background is invisible. .alert-solid reads the theme’s own contrast token, so every colour lands between 4.56 and 18.03 — including a theme colour you define yourself. Markup using the old recipe still renders; it is the contrast that was never right.

  • An alert marked .fade show fades in when it is added to the page. The classes used to animate the alert in one direction only, because an element inserted with .show already on it has no earlier state to transition from; @starting-style supplies one. Nothing to change in your code — an alert you append at runtime animates without the class juggling that used to be needed, and an alert already in the markup is unaffected.

  • The close button is a flex item, not an absolutely positioned one. It is pushed to the end with margin-inline-start: auto, so it no longer overlaps the text and the alert no longer reserves three paddings of room for it. A .btn-close that is a direct child of the alert still lands where it did, with or without .alert-dismissible, and .alert-dismissible itself is now an empty class kept for existing markup. One case does change: a close button nested inside a wrapper used to be positioned by the alert, and is now laid out by that wrapper like any other content. Move it up to be a direct child of the alert to keep it at the trailing edge.

  • An <hr> inside an alert takes the alert’s border colour. The alert redeclares --cui-hr-border-color from the theme’s border colour, falling back to --cui-alert-border-color, so a separator in a .theme-success alert is green rather than the page’s neutral grey, and one in an alert whose border you recoloured follows it. A variant such as .alert-solid paints its border with the surface colour, so there the separator takes the theme’s border colour, or the text colour without one.

  • Breaking --cui-alert-border is gone. The border is written in the rule from --cui-alert-border-width and the border colour, so that a .theme-* class can colour it. Set --cui-alert-border-width and --cui-alert-border-color instead, and border-style on the alert for a dashed or dotted line.

  • Breaking $alert-dismissible-padding-r is gone; the space for the close button follows --cui-alert-padding-x. The variable was multiplied out in Sass when the stylesheet was built, so raising --cui-alert-padding-x widened three sides of a dismissible alert and left the fourth on the default: at 2rem the alert had 32px of padding and 48px of room for the button, which also kept its own 16px padding. Both are subtracted from the tokens now — calc(var(--cui-alert-padding-x) * 3) and calc(var(--cui-alert-padding-y) * 1.25) var(--cui-alert-padding-x) — so one token resizes the whole alert. Defaults render as before; drop any override of the removed variable.

The close button is masked, not filtered

Breaking

.btn-close used to paint a black glyph baked into its SVG and recolour it for dark mode with a filter. The colour now comes from the element, the way the check, radio, carousel and toggler icons already work:

- background: transparent var(--cui-btn-close-bg) center / 1em auto no-repeat;
- filter: var(--cui-btn-close-filter);
+ background-color: currentcolor;
+ mask-image: var(--cui-btn-close-icon);
  • --cui-btn-close-filter is removed, together with $btn-close-filter and $btn-close-filter-dark. Recolour the button with --cui-btn-close-color instead of inverting the image.
  • $btn-close-color defaults to inherit, so the glyph follows the surface it sits on. A close button inside a themed alert or a dark toast no longer needs a rule of its own to stay legible.
  • Breaking .btn-close-white is gone, deprecated since v5.0.0, and so is the dark-mode rule that forced the glyph white. Both restated what the inherited colour already produces on a surface that paints its own text — a .text-bg-inverse toast, a dark navbar’s offcanvas, a .bg-body.text-body section under data-coreui-theme="dark". Delete the class; the button renders identically without it. The rule additionally got the mirror case wrong: a data-coreui-theme="light" section inside a dark page inherited the forced white and drew a white icon on white.
  • Breaking --cui-btn-close-width and -height become --cui-btn-close-icon-size. The glyph is square, so one variable sizes it; the padding keeps both axes (--cui-btn-close-padding-y, -padding-x). Nothing changes on screen: the glyph is still 1em inside .25em of padding, so the pointer target stays 24px at the default font size — the WCAG 2.2 minimum.
  • Breaking --cui-btn-close-bg is --cui-btn-close-icon ($btn-close-bg → $btn-close-icon). It stopped being a background when the glyph moved to a mask; the name had been describing the old implementation.
  • The SVG in --cui-btn-close-icon carries the shape only. A custom image works the same way — give it any colour, the mask ignores it.

Badge

  • Breaking Colour comes from .theme-*, and .badge-* now names the variant. A bare .badge used to be white text with no background of its own, so it was invisible until a .text-bg-* utility supplied both. It renders neutral on its own now — --cui-badge-color is inherit and --cui-badge-bg is --cui-bg-2 — and a .theme-* class colours it: <span class="badge theme-danger">. The .badge-* namespace carries the three variants instead: solid (default), .badge-subtle and .badge-outline, each mapping the same theme roles.

    .text-bg-* still works and still sets both properties, so existing markup keeps rendering. Markup that paired .badge with a background utility alone (<span class="badge bg-danger">) now shows the inherited text colour on that background instead of white — swap it for .theme-danger or add a text colour utility.

  • The badge lays itself out. display is inline-flex with --cui-badge-gap between children and --cui-badge-min-height as the floor, so an icon next to a label needs no spacing utility and badges keep one height across a table or a list.

  • The font size has a legibility floor. --cui-badge-font-size is clamp(12px, .75em, .75em), so a badge inside small text stops shrinking at 12px while still scaling up with a larger parent.

  • New: .badge-position-{top|bottom}-{start|end}. One class pins a badge to a corner of its positioned parent, centred on the corner, and flips with the writing direction. It replaces the position-absolute top-0 start-100 translate-middle utility stack the docs used to recommend; that stack still works. The .btn .badge nudge (position: relative; top: -1px) is written as .btn :where(.badge) so a position class on a badge inside a button wins; a stylesheet that relied on beating .btn .badge with two classes now beats it with one.

  • New: --cui-badge-border-width / --cui-badge-border-color (transparent), .badge-lg, and a link reset. The border tokens are what .badge-outline switches, .badge-lg joins .badge-sm on the size scale, and a.badge no longer inherits the underline from the reboot.

  • Breaking The engine is native CSS scroll snapping, not transforms. The track (.carousel-inner) is a scroll container: dragging, swiping and momentum come from the browser, and programmatic jumps animate the scroll position over a fixed 300ms. The transform choreography is gone with its state classes — .carousel-item-next, .carousel-item-prev, .carousel-item-start, .carousel-item-end and .pointer-event no longer exist; react to the slide/slid events instead of styling them. The .slide class is inert — leave it in old markup or drop it, nothing reads it any more. The markup skeleton (indicators + inner with items + controls) is unchanged, and the controls, indicators and captions keep their overlaid layout.
  • Breaking ride is autoplay, wrap is ends, and touch is gone. data-coreui-autoplay="true" (boolean — the "carousel" spelling is gone) cycles from page load; ends takes "loop" (default — continues seamlessly where the layout allows), "wrap" (the old jump) or "stop" (hard ends with the matching control disabled). Touch needs no option: the browser owns it. Autoplay now turns off for good once the user interacts (WCAG 2.2.2), and while it runs the active indicator fills like a progress bar over the slide’s interval.
  • New layout knobs and a pause control. --cui-carousel-items, --cui-carousel-items-gap and --cui-carousel-items-peek put several slides in the viewport; .carousel-center snaps the active slide to the middle and .carousel-auto lets slides keep their own width. A button with .carousel-control-play-pause (holding .carousel-control-pause-icon and .carousel-control-play-icon) toggles autoplay and swaps its aria-label between data-coreui-pause-label and data-coreui-play-label.
  • Breaking .carousel-dark, the carousel-dark() mixin and $carousel-dark-* are gone. The chrome colours are light-dark() pairs resolved on the carousel itself, so the standard data-coreui-theme="dark" attribute is the dark variant — and data-coreui-theme="light" pins the white chrome on a dark page the same way.
  • Every chrome measurement is a --cui-carousel-* token declared on .carousel — control width and opacities, icon images and sizes, indicator geometry and transitions, caption metrics, the play/pause button — so the whole carousel restyles at runtime without recompiling.
  • Crossfade timing moved to tokens. $carousel-transition-duration and $carousel-transition are gone; the fade reads --cui-carousel-fade-duration and --cui-carousel-fade-timing, and the slides stack on a grid instead of floating.

Autocomplete and Multi Select share one options menu

  • Breaking The generated options dropdown now uses unified combobox-* classes in both components: combobox-popup, combobox-options, combobox-option (+-with-checkbox), combobox-optgroup(-label), combobox-options-empty, combobox-header, combobox-all, with the state classes selected and indeterminate. The former autocomplete-dropdown/ autocomplete-option(s)/autocomplete-optgroup* and form-multi-select-dropdown*/-options*/-option*/-optgroup*/-all*/ form-multi-selected/form-multi-select-indeterminate classes are gone, along with their Sass variables and CSS custom properties — theme the shared surface through $combobox-* / --cui-combobox-* instead. This markup is generated by the plugins (authors don’t write it), so only custom CSS targeting the dropdown internals needs updating. The field/selection markup of both components is unchanged.

  • Breaking The options list is now a List box instance, so the combobox-* option classes are gone. .combobox-popup stays as the panel; everything inside it is List box markup:

    v6 alphanow
    .combobox-header.list-box-header
    .combobox-all, .combobox-all-with-checkbox.list-box-select-all
    .combobox-options.list-box-options
    .combobox-option, .combobox-option-with-checkbox.list-box-option
    .combobox-option-indicator.list-box-option-indicator
    .combobox-optgroup.list-box-section
    .combobox-optgroup-label, .combobox-optgroup-label-with-checkbox.list-box-section-label
    .combobox-options-empty.list-box-empty
    data-value on an optiondata-coreui-value

    The same move takes the tokens: --cui-combobox-header-*, --cui-combobox-select-all-*, --cui-combobox-options-*, --cui-combobox-option-* and --cui-combobox-optgroup-* are replaced by --cui-list-box-header-*, --cui-list-box-padding-* / -gap, --cui-list-box-option-* and --cui-list-box-section-label-*. Only --cui-combobox-popup-min-width is left on .combobox-popup. Option padding follows the List box (.5rem .75rem, indicator in its own grid column) rather than the wider 1.25rem the combobox surface used.

  • Breaking Multi Select’s selectAllStyle and optionsGroupsStyle are removed. A list has one marker, and indicator decides it for the options, the selectable group labels and the select all button alike. Replace selectAllStyle: 'text' / optionsGroupsStyle: 'text' with indicator: 'none' when you want the whole list without checkboxes.

  • Breaking Autocomplete keeps the focus in its input while the options list is open. Options take no tabindex and are never focused; the input carries aria-activedescendant, and the arrows, Enter and Space are handled there, so Home, End and the space bar keep working as text keys.

  • Breaking Multi Select opens a dialog instead. The panel is role="dialog" named by the control, and the focus moves into it: onto the search box when search is on, otherwise onto the option the list points at. Options take a roving tabindex and the arrows move the focus itself, so there is no aria-activedescendant to read any more. Tab cycles through the panel and the search box without leaving them, and Escape closes the panel and hands the focus back to the control. The select all button left the arrow order — it sits in the panel header, reachable with Tab, and carries aria-pressed (true/false/mixed); selectable group labels are in the arrow order in its place.

  • Breaking Multi Select’s search takes true, false or 'external', and search: 'global' is gone. Typing reaches the search box from anywhere in the component by default, which is what 'global' used to turn on, so data-coreui-search="global" becomes data-coreui-search="true". Set globalSearch: false to keep typing local: the search box then filters only while it has the focus, and a keystroke over the options list jumps to the first option starting with it. Any other string throws a TypeError.

  • Breaking Multi Select’s value wins over options marked selected. It used to add to them, in the markup and in options, and later options applied the start value again; now a given value is the selection, also for options that arrive later for the first time, as in the React and Vue editions, while options shown before keep their state. An array of numbers matches the options, and value: '' reads like an empty data-coreui-value.

  • Multi Select takes search: 'external'. The search box then filters nothing, search.coreui.multi-select carries the query, and setOptions() replaces the options without rebuilding the control or touching the selection; a selected value the new options lack keeps its chip and is still sent with the form, unless it is disabled. setLoading() shows a loadingLabel row below the options meanwhile, and they stay usable. resetSelectionOnOptionsChange empties the selection when the new options carry different values, for options that depend on another field.

  • Multi Select’s search.coreui.multi-select follows the text, not the keys. It fires when the text of the search box changes, a paste included, and no longer on a key that leaves the text alone, such as an arrow, or when the panel opens or closes over an empty box.

  • Multi Select’s dispose() gives the native select back the options it had before the component started. A select built from options in the config, or one whose options setOptions() replaced, used to keep the generated options; an empty <select> given options is empty again after dispose().

  • Breaking Multi Select sends one change.coreui.multi-select per change of the selection. The clear button, deselectAll(), selectAll() and a pick that replaces the value of a single select used to send one event for every option they touched; they send one now, once the native select holds the new value and the control shows it, the way the native change works. An action that changes nothing sends no event, native ones included, and the component no longer sends one for each preselected option while it builds or rebuilds. A selection the page gives sends none, from setValue() or from a given value that options from setOptions() bring later, and a native reset of the form that changes the selection sends one, as Autocomplete, Chip Input and Combobox do.

  • Breaking Form controls fire their change event once per change of the value, on a form reset as well, and never from a value the page gives. OTP Input’s change.coreui.otp-input, Rating’s reset(), Autocomplete’s clear() and Range’s changed.coreui.range used to fire even when the value stayed; they fire only when it changes now, and Range no longer fires while it starts or on the change after an input. OTP Input and Range Slider now fire on a native form reset that changes the value, and Date Input sends the native input and change of its value field there too. setValue() of OTP Input does not fire. A form reset brings Rating and Range Slider back to the value they were created with, not one a later setValue() gave, and OTP Input’s reset() puts back the code it started with, as a native reset does. A stepper keeps the validation marks of a form reset a listener cancels.

  • Multi Select keeps its selection through setOptions(); it used to fall back to the options marked selected. A selected option that is disabled, also inside a disabled group, shows as a disabled chip and stays through the clear button, deselectAll() and Backspace; the options of a disabled group are disabled in the list and in the native select as well, so, like any disabled option, they are not sent with the form. A single select whose selection is empty holds its value="" placeholder option, or none, instead of letting the browser fall back to the first option, so the form gets no option the control does not show.

  • Breaking Autocomplete’s search takes only 'external', and global typing is globalSearch, on by default. The array and the comma list are gone, so search: ['external', 'global'] and data-coreui-search="external, global" become search: 'external', and search: 'global' goes away: typing on the picker or clear button, or in the panel, reaches the field without it. Set globalSearch: false to keep a keystroke where the focus is. An array now fails the option type check, so new coreui.Autocomplete(el, { search: ['external'] }) throws until it is updated, and a string other than 'external' no longer turns external search on, so a page still writing the comma list in a data attribute filters the server’s results again. The space bar and shortcuts with Ctrl or Cmd stay with the button that has the focus.

  • Autocomplete no longer gives the host tabindex="-1" and keeps a tabindex the page wrote out of the tab order while it lives, so the field is the only tab stop; a click on the frame puts the focus in the field rather than only opening the panel. With search: 'external' the panel opens for the options you set even when they do not contain the typed text.

  • Breaking Autocomplete’s typeahead hint no longer reaches the server. With showHints on, the hint input behind the field carried a name and, being readonly rather than disabled, was serialized with the form: the configured name when there was one, <uid>-hint when there was not. A form therefore submitted either the field twice with an empty string in front of the value, or an extra …-hint key nobody asked for. The hint now carries no name, so new FormData(form).getAll(name) returns one entry and form.elements[name] is an input rather than a RadioNodeList. Code written around the old shape, such as reading getAll(name)[1], needs the plain value.

  • Breaking Multi Select’s search field no longer reaches the server. The input that filters the options carried a name and is a plain text input, so every form holding a Multi Select submitted whatever the user had typed to narrow the list: search-<name> when the select had a name, search-<uid> when it did not. It carries no name now, the search-… key is gone from the payload, and the input sets autocomplete="off" so the browser stops offering to fill it.

  • Breaking An unnamed Multi Select stays out of the form. A <select> the page had not named was given name="<uid>", so its value was submitted under a key nobody chose. The attribute is written only when there is a name, from the option or from the element. dispose() also takes back the id and the name the component added, where it used to leave both behind.

  • Breaking An unnamed Autocomplete stays out of the form too. Its input took a generated name when the option was not set, so a form carried a key nobody chose. It does not now. The generated id is unaffected.

  • Breaking Validation state comes from validationState. invalid and valid are deprecated aliases of validationState: 'invalid' and 'valid', and validationState also takes a state from $form-validation-states. A given 'invalid', from the options or from an .is-invalid class in the markup, now blocks the submit, and the next change the user makes to the value drops it, while a change from code keeps it. The markup class is read once, when the component starts, so it no longer comes back when the text returns to the value it started with, a native form reset drops it, and a class written later is not read. A validation that reports the input, from a submit or the page’s own checkValidity(), now shows on the frame, and the autocomplete is the only one that writes aria-invalid and the message link on its input: the form plugin and the stepper leave the input to it. See Validation.

  • New component: Menu — contextual overlays with submenus, container teleport, responsive placement and logical (start/end) placements, ported from Bootstrap v6 with CoreUI’s class conventions. See the Menu docs.

  • New component: Context Menu (PRO) — a right-click, long-press or Shift+F10 inside an element with data-coreui-toggle="context-menu" opens a .menu at the pointer and suppresses the browser’s own menu. Built on Menu, so items, submenus, keyboard navigation and dismissal are the same; show({ x, y }) opens it at a point from JavaScript.

  • Dropdown is now a compatibility layer over Menu. The v5 markup, classes and *.coreui.dropdown events keep working unchanged — the plugin shares Menu’s engine underneath. Where their options overlap, Dropdown also understands Menu’s additions (for example container). Deprecation notice: Dropdown may be removed in v7 — prefer Menu for new work.

  • Breaking The responsive alignment classes .dropdown-menu{-sm|-md|-lg|-xl|-2xl}{-start|-end} moved to coreui-legacy.css, and .dropup/.dropend/.dropstart no longer carry the menu’s static position. Alignment that changes with the viewport is a responsive placement on the toggle — data-coreui-placement="bottom-start lg:bottom-end" — which now also works with data-coreui-display="static" and inside a navbar, because the plugin writes the resolved placement onto the menu as data-coreui-placement and the stylesheet positions the static menu from that attribute rather than from the wrapper class. dropdown.css loses every @media; the direction classes keep the caret and still decide the placement when no option is given. .dropdown-menu-end and .dropdown-menu-start stay.

    - <ul class="dropdown-menu dropdown-menu-lg-end">
    + <button data-coreui-toggle="dropdown" data-coreui-placement="bottom-start lg:bottom-end">
    + <ul class="dropdown-menu">

List Box (new)

  • New component: List Box (PRO) — a keyboard-driven list of values on the ARIA listbox pattern, with none/single/multiple selection, sections, descriptions, links, an empty state and an activeDescendant mode that leaves the focus in your own input.

Combobox (new)

  • New component: Combobox — a .form-control toggle next to a popup List box, where the options are markup you write, with single and multiple selection, an optional search, a checkbox indicator and a hidden select that takes part in form validation and submission.

Transfer (new)

  • New component: Transfer (PRO) — two list boxes and a pair of buttons that move the selected options between them, with a select-all header and counter per list, an optional search, a one-way mode and an announcement of every move through announce().

Form (new)

  • New plugin: Form (PRO) — validates a form with data-coreui-validate and novalidate on submit: stops an invalid submit, marks the invalid controls with .is-invalid and aria-invalid, links their messages with aria-describedby and moves focus to the first. It replaces the starter script of the validation page, see Validation.
  • Breaking Both components render on the native <dialog> element now, opened with showModal()/show() and closed with close(). The browser provides the top layer (no z-index management), the ::backdrop, focus trapping, background inertness and the dialog ARIA semantics — the markup drops role, aria-modal, aria-hidden and tabindex="-1".
  • Breaking The modal wrapper layers are gone. .modal > .modal-dialog > .modal-content flattens to a single <dialog class="modal"> holding .modal-header/.modal-body/.modal-footer directly. Size (.modal-sm/-lg/-xl) and fullscreen (.modal-fullscreen*) modifiers sit on the dialog itself. .modal-dialog-scrollable is renamed to .modal-scrollable; .modal-dialog-centered is gone — the browser centers a modal dialog by default. Offcanvas markup only swaps the tag: <dialog class="offcanvas offcanvas-start">.
  • Legacy v5 markup keeps working — for now. When a component is instantiated on v5 markup (via data API or JavaScript), the plugin rebuilds it into the <dialog> structure at runtime and logs a console warning. The old element stays in the DOM as an inert display: contents shell around the new dialog, so existing element references and event listeners keep working (our events bubble through it). Selectors and custom CSS targeting .modal-dialog/.modal-content are the one thing that cannot be preserved. This transitional layer will be removed in v7.
  • Breaking .modal-backdrop and .offcanvas-backdrop are gone — the backdrop is the native ::backdrop pseudo-element. Style it through the new --cui-modal-backdrop-bg/--cui-modal-backdrop-opacity and --cui-offcanvas-backdrop-* tokens. $zindex-modal-backdrop and $zindex-offcanvas-backdrop are removed.
  • Breaking Animation opt-in is inverted. Modals animate by default; .fade is gone and .modal-instant / .offcanvas-instant disable animation instead. The auto-upgrade maps a missing .fade to .modal-instant. The offcanvas .showing state class is replaced by the native [open] attribute (.hiding remains for the exit transition).
  • Breaking The Modal focus option is removed — the native dialog always traps focus. handleUpdate() remains as a no-op for API compatibility; the scrollbar-compensation logic it triggered no longer exists (scroll lock is overflow: hidden + scrollbar-gutter: stable on the root element, with the dialog-open class).
  • New capabilities: a modal: false option opens the dialog non-modally (page stays interactive); a cancel.coreui.modal event fires when a modal is dismissed with Escape; toggling from a trigger inside an open modal swaps to the target modal with a seamless backdrop (no flash); modals can be stacked — the top layer manages paint order.
  • Sass: $modal-transition and $modal-show-transform are replaced by $modal-transition-duration/$modal-transition-timing; the offcanvas transition token splits the same way (--cui-offcanvas-transition-duration/-timing).

Dialog (new) and Drawer (new)

  • One backdrop model for both. :root emits --cui-backdrop-color, --cui-backdrop-opacity and --cui-backdrop-blur; the dialog’s and the drawer’s ::backdrop mix colour and opacity from them, so either can be retuned alone. The looks are unchanged (dialog: black at 50%/65%, drawer: the page colour at 25%), and .backdrop-scrim, .backdrop-translucent and .backdrop-none swap them from the markup. .dialog-no-backdrop and .drawer-no-backdrop keep working. The sidebar’s backdrop tokens are --cui-sidebar-backdrop-zindex/-bg/-opacity now (were unprefixed --cui-backdrop-*, which the new root tokens would have shadowed).

  • New components: Dialog and Drawer — the native-<dialog> popup and side panel with the Bootstrap 6 look and dialog-* / drawer-* classes: Dialog is a centered floating panel over a blurred backdrop with optional slide entry variants; Drawer is a floating, inset, rounded panel with translucent/sheet/fullscreen/fit-content variants and responsive classes (.drawer-sm….drawer-2xl). Both share the DialogBase behavior with Modal/Offcanvas, which keep the classic look and the v5 vocabulary as compatibility layers over it — deprecated in v6 and removable in v7, so reach for Dialog and Drawer in new work. See the Dialog docs and Drawer docs.

Translucent surfaces share two tokens

.navbar-translucent, .toast-translucent, .card-translucent, .drawer-translucent and .menu-translucent used to carry their own mix amount and filter — two as Sass variables, three as literals — at 85% or 80% depending on the component. All five now read --cui-translucent-opacity (80%) and --cui-translucent-backdrop-filter (blur(5px) saturate(180%)) from :root, so one override retunes every translucent surface and one on an element retunes that surface alone. Consequences: the navbar, the toast and the card become slightly more see-through (85% → 80%; set --cui-translucent-opacity: 85% on them to keep the old look), and $navbar-translucent-bg-opacity, $navbar-translucent-backdrop-filter, $toast-translucent-bg-opacity, $toast-translucent-backdrop-filter, $card-translucent-bg-opacity and $card-translucent-backdrop-filter are removed in favour of $translucent-opacity and $translucent-backdrop-filter. The card’s caps keep their own amount as the --cui-card-translucent-cap-bg-opacity token on .card-translucent.

Calendar

  • Changed. The range band sits under the day cells and follows the theme. --cui-calendar-cell-range-bg is --cui-primary-bg-subtle (the theme’s bg-subtle under a .theme-* class) instead of a 12.5% tint laid over the cells, so the selected start and end keep their solid fill and the day numbers are no longer tinted. A hovered day in the range takes --cui-primary-bg-muted (the theme’s bg-muted), one step stronger than the band.
  • Changed. minDate and maxDate are compared by the day. v5 compared a day cell at midnight with the instant, so a limit that carried a time of day, such as minDate: new Date(), disabled its own day. Now that day can be picked; the Date Picker’s time selects and the field bound the time on it.
  • Changed. A week row stands for the ISO week that holds most of its days. With firstDayOfWeek other than 1 a row spans two ISO weeks, and v5 numbered it by its last day but picked the week of its first day: a Sunday-first row from August 30 to September 5, 2026 showed 36 and picked 2026W35, and that value given back as startDate did not select the same row. The number, the picked value, the weeks of startDate, endDate, minDate and maxDate, and the range and its preview now all follow the row’s majority week, so that row is 2026W36 and the picked date is its Monday, August 31, the date 2026W36 stands for. With Tuesday to Thursday first, the numbers move instead, so the row from September 1 to 7 shows 36 where it showed 37.
  • Changed. In week selection disabledDates disables a week only when it disables every day of it, as it does for a month, quarter or year. Before, a week was disabled when its row’s first day was: with Monday first disabledDates: [new Date(2026, 7, 31)] disabled week 36, and with Sunday first a function that disables weekends disabled every row. A range of weeks across a week that holds a disabled day is kept, as a range of months is.
  • Changed. In week selection minDate and maxDate count as whole weeks, so their weeks can be picked on any first day of the week, as the month, quarter or year of a limit can in those selections, and the months view opens a month only when its page holds a week inside them. Before, a minDate later than the first day of its row disabled that row, and with Tuesday to Thursday first a maxDate of 2026W36 disabled the row of week 36.
  • Breaking cellHover is gone. It reported the date under the pointer or the focus for the hover preview in the date picker’s field, which v6 no longer draws. To follow the pointer or the focus over a calendar, listen for mouseover or focusin on its element: each day, month, quarter and year cell the calendar shows carries its date in data-coreui-date, in the toDateString() form, and a week row carries it on the day cells it shows. Unlike the event, the listeners also see cells that cannot be picked, and only mouseout or focusout tells that the pointer or the focus left.
  • Breaking selectAdjacementDays and showAdjacementDays are spelled selectAdjacentDays and showAdjacentDays — the option keys, the data-coreui-select-adjacent-days / data-coreui-show-adjacent-days attributes and the picker passthrough alike. The typo is fixed in the React and Vue libraries in the same release.
  • Breaking update(config) is gone; the dates change through setStartDate(), setEndDate() and setCalendarDate(), and the other options are fixed once the calendar starts. A call like update({ startDate }) used to reset every other option (range, calendars, locale, …). See Live changes go through setters, not update(config).
  • Breaking Each grid is one tab stop, and tabindex no longer marks a selectable cell. The arrows, Home, End and the page keys move within a calendar and Tab leaves it, which is what role="grid" promises. Every cell inside minDate / maxDate can take the focus, also one that cannot be picked: the keys stop on a date in disabledDates instead of skipping it, a screen reader announces it as unavailable, and Enter and Space do nothing there, so a month where nothing can be picked is explored like any other. A selectable day, month, quarter, year or week row carries data-coreui-selectable, and tabindex="0" sits on one cell per grid, so a two-month range picker keeps a stop in each month that reaches into the limits. Custom CSS or scripts that reached for .calendar-cell[tabindex="0"] need .calendar-cell[data-coreui-selectable].
  • Breaking Day cells write data-coreui-date in the toDateString() form ("Thu Sep 17 2026"), like the month, quarter and year cells, instead of the full Date#toString() value. A script that selected a day cell with [data-coreui-date="${date}"] needs [data-coreui-date="${date.toDateString()}"].
  • Breaking Picking a date no longer fires calendarDateChange and keeps the month on screen. A picked day of an adjacent month (selectAdjacentDays) used to become the calendar date while the grid stayed put, so the next-month button then skipped a month; it now stays where it was and shows as selected with an outline on a subtle background instead of the solid fill (--cui-calendar-cell-selected-adjacent-color, --cui-calendar-cell-selected-adjacent-bg, --cui-calendar-cell-selected-adjacent-hover-color, --cui-calendar-cell-selected-adjacent-hover-bg). Listen to startDateChange / endDateChange for the picked date.
  • A selected day or week keeps its colour under the pointer, a shade darker (--cui-calendar-cell-selected-hover-bg, --cui-calendar-cell-selected-hover-color); it used to take the hover colour until the pointer left.
  • In the years view the navigation names the page it shows, 2020 – 2031, instead of the one year the page is built around, so turning a page is announced as the new range. The grid label and the navigation write the range in the locale’s own digits.
  • In the years view the double arrows and Page Down / Page Up turn the calendar by the twelve years a page shows instead of ten, so a turn no longer repeats two years of the previous page.
  • In the years view the year button goes back to the view that picks the date instead of doing nothing, and it is disabled in year selection, where the years view is the one that picks.
  • Without weekNumbersLabel the week-number column is named for screen readers with the locale’s word for week, in a visually hidden span; before, its header was empty. Each week number is named the same way with its number (Week 36), and the new ariaWeekNumberLabel option takes a function of the week number to name it yourself.
  • Breaking The years view names its double arrows with new ariaNavNextYearsLabel / ariaNavPrevYearsLabel options ('Next 12 years' / 'Previous 12 years') instead of the year labels, which stay on the days, months and quarters views. A page that translates ariaNavNextYearLabel / ariaNavPrevYearLabel needs to translate these two as well.
  • A grid in which nothing can be picked is described by the new ariaNothingToPickLabel option ('Nothing on this page can be picked'), also announced when the calendar turns to such a page, so a page that translates the other calendar labels translates this one too.
  • A pick is announced through the new ariaSelectedDateAnnouncement and ariaSelectedRangeAnnouncement options ('Selected date: {date}', 'Selected range: {start} to {end}'); before, picking a date with Enter left a screen reader silent. A page that translates the other calendar labels translates these two as well.
  • The toggle of a date picker and of a date range picker is named with the value the picker holds (Toggle calendar, Friday, August 14, 2026), so a screen reader reads the picked date when the popup closes and focus goes back to it. A toggle the page named itself keeps its name.
  • The month and year between the navigation arrows are no longer a live region. A page turn is announced through announce(), inside the panel of a date picker when the calendar sits in one, and a key that leaves a day or a week row names no page, since the date that takes focus names it: a key that moved from August 31 to September 1 used to be read twice, as the date and then as September 2026.
  • A locale whose own calendar has different months (Persian by default in fa-IR, or an Islamic, Hebrew or Chinese calendar asked for with -u-ca-) now shows the calendar in the Gregorian calendar, in its own language and digits. The grid was always Gregorian, so fa-IR used to show September 1, 2026 as the 10th of Shahrivar 1405 and name the January cell Dey. Calendars that share the Gregorian months, such as Thai Buddhist or Japanese, keep their year numbering in the calendar. The date field lays out and reads Gregorian numbers for every locale, without the locale’s era.
  • Breaking A cell drawn by renderDayCell no longer gets an aria-label, so its content is what a screen reader reads. Every render function receives the cell’s full name as meta.label (Wednesday, August 12, 2026, August 2026, Q3 2026, 2026); put it in the cell, for example in a visually-hidden span next to a short visible label marked aria-hidden="true". Day cells the calendar draws itself announce the full date with the weekday instead of the short numeric date (8/12/2026), and in week selection the row that takes focus is named by the days it spans (August 10 – 16, 2026), after its week number when week numbers are shown (Week 33, August 10 – 16, 2026), instead of reading out all seven.
  • The copy of today among an adjacent month’s days no longer takes the today class, aria-current="date" or meta.isToday, so a two-month calendar marks one current date and a date picker opens on today only when its own month is shown. renderDayCell receives the full meta in week selection too, not only label.
  • A cell that cannot be picked carries aria-disabled="true": a day outside minDate / maxDate or in disabledDates, a week row, month, quarter or year outside the range, and in day selection a day among an adjacent month’s days unless selectAdjacentDays is on. Such an adjacent day is no longer aria-selected either, and renderDayCell gets isDisabled: true and isSelected: false for it, so the copy of a picked date that fills the next panel reads as unavailable filler.
  • With selectAdjacentDays, the range and its hover preview are drawn on the adjacent month’s days as well, and renderDayCell gets isInRange: true for them, as for the month’s own days. Before, both stopped at the edge of the month.
  • With range, the grid of the view that picks the range is aria-multiselectable, and every day, week row, month, quarter or year of the selected range that can be picked carries aria-selected="true", not only its two ends, so a screen reader announces the ones in between as selected. The months and years views of a day range only navigate and keep it on the ends, and meta.isSelected still marks only the ends.
  • In week selection, renderDayCell gets the isDisabled, isInRange and isSelected of the day’s week row, the way the calendar draws it: every day of a selected week is isSelected, every day of a week in the range is isInRange, and a day is isDisabled only when its row cannot be picked. Before, each field described the day alone, so a selected week reported only the day holding its start or end date as selected.
  • A year below 100 stays as written: a calendar on December of year 99 draws the days of year 99, where it drew 1999, and its next panel shows January of year 100, where it showed January 2000. The month, quarter and year views, the arrow keys and paging keep the year the same way. Years before 1 cannot be picked, as dates before minDate cannot: their cells are disabled, and the arrow keys and paging stop at 1 January of year 1.
  • A year string written in four digits keeps its year: 0026-05, 0026W05, 0026Q2, 0026 and 12/31/0099 given to the calendar stand for the years 26 and 99, where they became 2026 and 1999. Two digits still expand to the century within 50 years of today. The value reported for week, month, quarter and year selection writes the year in at least four digits (0026-05, where it was 26-05), so it reads back as the same year.
  • Additive: startDateChange and endDateChange now carry a dateObject property with the underlying Date, alongside the existing date property. date remains formatted per selectionType — a Date for day selection, but a string for week ("2026W27"), month ("2026-01"), quarter ("2026Q1") and year ("2026"). Existing listeners are unaffected.
  • Breaking startDateChange and endDateChange no longer report a date that stays empty. Picking a day before the start of a range, or an end across a disabled day, used to fire endDateChange with null although no end had been picked, and clearing a range across a disabled day before a start was picked fired startDateChange with null. A day picked again is still reported.
  • .months and .years are gone from the stylesheet. Their one rule hid a .time-picker that followed a .calendar panel in the months or years view among its siblings, where no picker renders one, so it matched nothing. The calendar names the view it shows on the cell content: .calendar-cell-inner.day, .month, .quarter and .year.
  • The navigation puts the year button before the month button in locales that write the year first (for example Japanese, Chinese, Korean, Hungarian or Lithuanian), the way the locale’s own dates read.

Date Picker / Date Range Picker

Breaking

The pickers are rewritten. DatePicker, DateRangePicker and TimePicker are now a composition of the section-based input fields (date input), the calendar and a shared popup primitive, instead of the v1 monolith. The rewrite ships under the existing names — DatePicker, data-coreui-date-picker, .date-picker — so no renaming is needed on your side, but the options below changed. The v1 shells are gone; if you need to compare against the old behaviour, v5 is the reference. Mapping:

  • Removed — projected regions replace button configuration. footer, todayButton, todayButtonClasses, cancelButton, cancelButtonClasses, confirmButton, confirmButtonClasses, ranges, rangesButtonsClasses → put a <template data-coreui-template="footer"> / data-coreui-template="ranges" child in the picker element. Zero-argument actions bind with data-coreui-picker-action="today|clear|reset|close"; actions with arguments (range presets) go through picker.getContext() (setDate, setRange, clear, reset, close + read-only state).
  • Removed — obsolete with section fields. inputDateFormat, inputOnChangeDelay, inputReadOnly, placeholder — the masked field is always a valid partial date; there is no free-text parsing to debounce. Section placeholders come from the section fields’ own per-section options — dayPlaceholder, monthPlaceholder, yearPlaceholder and the rest.
  • Removed — previewDateOnHover and separator on the date range picker. The fields no longer preview the hovered day in a second, read-only input; the calendar still highlights the range up to the hovered day. The separator between the two fields is always rendered, and separatorIcon / separatorIconRtl replace its icon.
  • Kept. timepicker: true still adds the time half to the date picker, now as a row of time selects under the calendar rather than a second panel beside it. Because the two halves own different parts of one value, a picker with time does not auto-close: picking a day keeps the time and picking a time keeps the day. seconds narrows what the time half offers and selectionOptions is its escape hatch.
  • Changed. With timepicker, a minDate or maxDate that carries a time of day bounds the time on its day: the time selects disable the hours, minutes and seconds before minDate or after maxDate there, a time the panel sets beyond the limit (the current time when that day is picked) moves to the limit, and a typed time beyond it marks the field invalid. A limit without a time of day covers its whole day.
  • Kept. Calendar passthrough (locale, minDate/maxDate, disabledDates, selectionType, showWeekNumber, custom cell renderers, …), container, startDate/endDate/date, startName/endName/name, size, validation states, the data API (data-coreui-date-picker), and the imperative API (show/hide/toggle/clear/reset).
  • Passthrough is by name. Options that belong to the inner calendar or date input are forwarded automatically, so data-coreui-selection-type, data-coreui-show-week-number and data-coreui-format work on the picker element. calendarOptions / inputOptions remain as the programmatic escape hatch.
  • Every selection type has a matching mask. Month and year selection narrow the mask to MM/yyyy and yyyy. Week selection renders the native <input type="week"> presentation — a fixed localized label plus week and year sections (Week 29, 2026, Tydzień 29, 2026), where the year is the ISO week-numbering year. Quarter selection narrows to QQQ yyyy (Q4 2026). The v1 text input rendered 2026W27/2026Q1; the submitted value now follows the mask (set format="yyyy-Www" for an ISO-style field), and the event carries the v1-formatted string in event.formattedDate next to the Date in event.date (see “Picker events can be cancelled” below). The format parser gained ww (ISO week) and q/QQQ (quarter) tokens, plus quoted literals ('Week' ww — fixed text that may contain token letters, '' escapes a quote).
  • Format comes from the mask. inputDateFormat is replaced by format (forwarded to the field); inputDateParse still applies to pasted text.
  • Footer buttons are projected, today/now actions keep the v1 gating. A projected button opting into the today action (now on the time picker) is disabled at initialization when its target falls outside minDate/maxDate/disabledDates — matching the v1 built-in button. This is one-way: the picker only ever sets disabled, never re-enables. The state of any other projected button is yours — the slot context exposes isDateSelectable(date) (isTimeSelectable on the time picker) for computing it. Programmatic dates (setDate, actions, the initial date config) run through the same min/max validation as typed ones: out of range, the field turns is-invalid, errorChange fires, and getDate() reports null. dateChange on the shells always carries the validated value, and setDate() does not fire it.
  • The calendar is built on first open, not at construction. It accounts for ~83% of a date picker’s DOM (and ~89% of a range picker’s), and it is not observable before the popup opens, so the shells create it in show() and seed it from the field values. Measured over 20 pickers: date picker 65 ms → 15 ms and 3200 → 540 DOM nodes, range picker 93 ms → 12 ms and 6060 → 740 nodes. The first open costs a few milliseconds more (the calendar is built then); later opens are unchanged. Code reaching into the private picker._calendar before the first open now finds null.
  • setRange() and reset() on the Date Range Picker show the range in the calendar, on the month of its start date (of its end date when there is no start), in whatever view the calendar is in and also when the range does not change, so a preset clicked again after browsing brings its range back. The calendar used to move only when a field changed, and went to the month of the end date when both changed. clear() leaves it where it is; setDate(), today() and reset() on the Date Picker already showed their date.

The pickers take their validation state from validationState

Breaking

invalid and valid on DatePicker, DateRangePicker and TimePicker are deprecated aliases of validationState: 'invalid' and 'valid', and validationState also takes a state from $form-validation-states. A given 'invalid' now blocks the submit, and the next change of the value the user makes (a pick in the panel, the cleaner, a footer or custom-range button that changes the value, typing in the field) drops it; closing the popup and a change your code makes keep it. An .is-invalid or .is-valid class on the picker element is read once, when the picker starts. An aria-invalid on it is not read at all; remove it, since the date range picker makes its element a group and the attribute would be announced on that group. Set the state after start with setValidationState(). See the Validation sections of the date picker, date range picker and time picker.

The date picker adopts the markup you write

The date picker is composed from the date input, the calendar and a popup, joined by the one date it owns. Mark a div with data-coreui-picker-field, a button with data-coreui-picker-toggle or data-coreui-picker-cleaner inside the picker element and it uses them instead of generating its own — a trigger can be a regular button with text, the icons can be plain <svg> in the HTML. Nothing changes for the bare <div data-coreui-date-picker>.

Breaking aria-expanded moved from the picker’s root element to the toggle button, where it describes the control that opens the calendar; the toggle also carries aria-controls and aria-haspopup="dialog". Styles keyed to the root’s .show class are unaffected.

The date range picker is built on the date range input

Breaking

The range picker no longer builds its two fields itself: it composes the new date range input, which owns both dates, their validation and the arrow between them. Two consequences:

  • The picker element is no longer the frame. It wraps one: <div class="date-picker date-range-picker picker"><div class="form-control-group form-date-range">…. CSS or scripts that reached for .form-control-group on the picker’s own element now find it one level down; .show, .date-range-picker and the data-coreui-toggle attribute stay where they were. aria-expanded moved from the element to the toggle button, which also carries aria-controls and aria-haspopup="dialog".

  • The two array options are split in two, following the rule that every label is its own option: ariaLabels: ['Start date', 'End date'] becomes ariaStartLabel / ariaEndLabel, and floatingLabels: ['Check-in', 'Check-out'] becomes startFloatingLabel / endFloatingLabel.

    - data-coreui-floating-labels='["Check-in", "Check-out"]'
    + data-coreui-start-floating-label="Check-in" data-coreui-end-floating-label="Check-out"

Picker events can be cancelled, and dateChange has one shape

  • Breaking dateChange / startDateChange / endDateChange on the Date Picker and Date Range Picker carry the same payload whichever way the date arrived. event.date is the validated Date (or null) the field holds, and event.formattedDate is that value in the shape of selectionType. A selection from the calendar used to put the formatted value in event.date and the Date in event.dateObject, while a date typed into the field put the Date in event.date — the same listener saw two shapes.

    - const date = event.dateObject ?? event.date
    + const date = event.date
  • Breaking The pickers emit their change events only when the value changes. A pick of the value already selected, and clear() or reset() when the picker already holds that value, no longer fire dateChange, startDateChange, endDateChange or timeChange; v5 fired them on every pick and on every clear() and reset(). The new setDate(), setTime(), setStartDate(), setEndDate() and setRange() fire no change event at all.

  • shown and hidden on the pickers wait for the popup’s transition, like the modal, dialog, toast and collapse events: shown fires once the panel has finished opening (and not at all when it closes first), hidden once it has finished closing and left the page (and not at all when it reopens first). v5 fired both as soon as show() or hide() ran.

  • show and hide can be cancelled on the Autocomplete, Multi Select, Date Picker, Date Range Picker and Time Picker, the way they already could on Modal and Dialog: event.preventDefault() in a show listener keeps the panel closed, in a hide listener keeps it open, and the matching shown / hidden does not fire. hide on a panel that is not shown no longer emits anything.

Date Input / Time Input

  • The disabled option is the only switch. A .disabled class written on the field does not disable it: the field sets the class from the option.
  • reset() restores the date the field was created with, not the last one passed to update(), and emits dateChange when the value moves. A native form reset (form.reset(), a reset button) now resets the field too.
  • RTL keyboard navigation now follows the visual direction in nested RTL contexts. Arrow keys (and Home/End) mirror when the field renders right-to-left; the direction is read from the element’s computed style instead of document.documentElement.dir, so a dir="rtl" ancestor works and not only a document-level direction.
  • Breaking A required field blocks the form. The value moved from an <input type="hidden">, which the browser leaves out of validation, to a <textarea> laid invisibly over the field, with tabindex="-1" and aria-hidden="true"; a textarea, unlike a text input, leaves Enter submitting a form that has no submit button. A required field that is empty, incomplete or holds a rejected date now stops the submit, and the browser puts focus on its first empty section with its own message over the field. The field marks itself invalid whenever a validation reports it, also outside a submit, and its value field fires input and change when the date changes. This applies to the date and time pickers and to both ends of a date range as well. Code that selects input[type="hidden"] inside the field needs .form-date-time > textarea.

Time Picker

  • Breaking The roll infix is gone from the class and token names. It marked which of two renderings a body used, and the time picker has one now, so .time-picker-roll-col and .time-picker-roll-cell are .time-picker-col and .time-picker-cell, with --cui-time-picker-col-* and --cui-time-picker-cell-* in place of their -roll- spellings. .time-picker-roll itself was always present on .time-picker-body and its declarations moved there, so the modifier is gone rather than renamed; --cui-time-picker-body-padding went with it, since the modifier zeroed the padding it set.

  • seconds governs the field as well as the panel, and both are on by default, as they were in v5. v5 formatted its value with seconds, so 10:30:00 AM is what both majors submit. seconds="false" takes the panel column and the field’s seconds section away together; with the section present the value is complete only once the seconds are typed, the same as every other section of the field.

  • Breaking hours is 0 to 23 in every locale. v5 read the list and the filter in the column’s cycle, so on a locale whose v5 columns were 12-hour (the ones that write AM and PM in Latin letters, such as en-US) hours: [1, 3, 5, 7] meant 1, 3, 5 and 7 in the morning and in the afternoon, 12 meant noon and midnight, and a filter received 1 to 12. The 12-hour column still lists 1 to 12 next to AM/PM, but each entry now stands for the 24-hour hours the list allows: picking an hour that only one period allows sets that period, and the period the picked hour does not allow is disabled. To keep such a list, name both halves: [1, 3, 5, 7, 13, 15, 17, 19], with 0 and 12 for 12. More locales get 12-hour columns than in v5, because the columns follow the field’s clock, Korean, Traditional Chinese, Arabic and Mexican Spanish among them; their v5 columns were 24-hour, so a list written for them keeps its meaning.

  • The day period column speaks the locale. It read “AM” and “PM” in every locale; it now shows the names the field shows, 오전 and 오후 in Korean. A longer name widens its column, so --cui-time-picker-cell-width is now the narrowest a cell gets, and --cui-time-picker-cell-padding-x keeps the name off the cell’s edges.

  • The time selection body is now a shared primitive used by both pickers, so the hours/minutes/seconds filters and the 12/24-hour locale split behave identically in each. See time picker and the date picker’s own time half.

  • The time row sits under the calendars again, with its clock icon. v6.0.0-alpha put it beside them in a column of its own; .date-picker-body is a grid with the v5 areas once more, so .date-picker-timepickers spans the calendars it belongs to, with its clock icon back as an inline SVG that takes its colour from --cui-date-picker-time-icon-color.

  • Breaking The date picker’s time controls carry its own name. .time-picker-inline-icon and .time-picker-inline-select are .date-picker-time-icon and .date-picker-time-select, with --cui-date-picker-time-icon-* and --cui-date-picker-time-select-* in place of the --cui-time-picker-inline-* tokens. They sit in the date picker’s panel and are styled from its stylesheet, so nothing about them belonged to the time picker but the name. The time picker’s own panel is untouched.

  • The time selection is one tab stop, and the arrows move between its parts. v6.0.0-alpha gave a tab stop to every column of the roll, so Tab walked the hour, minute, second and AM/PM lists one by one. The body is a composite now, like the section field it mirrors: Tab reaches it once, Arrow Left and Arrow Right move between the parts and stop at the ends, and Arrow Up / Arrow Down still change the focused value. The date picker’s time selects answer the same keys.

  • Each picker has one time rendering, and the variant option is gone. The time picker shows the roll; the date picker’s time half shows the selects, which is what v5 rendered inside a date picker with time. Each picker builds its own rendering, so the choice is no longer an option anywhere; selectionOptions still reaches the body a picker built, for the hour and minute filters and the labels. The date picker’s time selects moved with it: it now ships in the date picker stylesheet, so a page that only uses the time picker no longer downloads it.

  • Same removals as the date pickers: footer/cancelButton/confirmButton and their class options give way to a projected <template data-coreui-template="footer"> with data-coreui-picker-action="now|clear|reset|close"; inputReadOnly, inputOnChangeDelay and placeholder are obsolete with a section field.

  • reset() restores the value the picker was constructed with. In v2 the shell remembers it: setting a value goes through the field’s update(), which rewrites the field’s own configuration, so the field can no longer restore the original on its own.

The v1 picker shell classes are gone

Breaking

The rewritten pickers style the field through forms/_form-date-time.scss and render their icons as inline SVG, so the v1 shell’s own elements no longer exist: .date-picker-input, .date-picker-input-wrapper, .date-picker-input-preview, .date-picker-cleaner, .time-picker-input and .time-picker-cleaner. Custom CSS targeting those needs rewriting against the new structure; everything else (.date-picker, .date-picker-popup, .date-picker-body, .date-picker-footer, the -sm/-lg size modifiers, the .time-picker-* bodies) keeps its name.

The picker panel is .date-picker-popup / .time-picker-popup

Breaking

-dropdown was the vocabulary of the per-family panels, and every other family lost it when the options menu became .combobox-popup. The pickers were the last holdout:

v5v6
.date-picker-dropdown.date-picker-popup
.time-picker-dropdown.time-picker-popup

The rename comes with the reason for it. The panel declares its own tokens now rather than inheriting them from the field’s root through a *:not(.date-picker) > … patch, so nothing inside it depends on having a particular ancestor — it renders the same in place, teleported through the container option, or rendered into document.body by a framework port’s portal.

width: min-content went with it: an absolutely positioned panel already shrinks to its content, and both pickers measure identically without the declaration.

.form-select is absorbed by .form-control

  • A <select> takes .form-control now, like every other control. The two were already the same thing everywhere but the chevron: measured across every computed property, .form-select and .form-control on the same <select> differed in exactly ten — the eight background-* that draw the chevron and the padding that makes room for it. Everything else already came from the shared --cui-control-* tokens.

  • <select class="form-control"> used to render without any arrow at all — .form-control sets appearance: none, which removes the native one, and nothing put ours back. That is fixed by the same change.

  • New: .form-control-caret puts the chevron on an element that is not a <select> — a button that opens a listbox, for instance.

  • .form-select, .form-select-sm and .form-select-lg keep working as aliases of the .form-control classes, not as a second implementation. Deprecated in v6, removed in v7.

    - <select class="form-select form-select-lg">
    + <select class="form-control form-control-lg">
  • Breaking The $form-select-* Sass variables are gone, along with scss/forms/_form-select.scss. Most were pass-throughs to $input-* / --cui-btn-input-* and their .form-control equivalents apply unchanged; the chevron’s own are renamed $form-control-select-*. The runtime tokens move with them: --cui-form-select-bg-img → --cui-control-select-bg-img, -bg-position, -bg-size, -indicator-padding → --cui-control-select-padding-end, and the -feedback-icon-* pair.

  • Breaking The chevron is translucent, and its color is no longer a Sass variable. $form-control-select-indicator-color and its dark counterpart are gone; the SVG carries #00000080 and #ffffff80, and the control declares both images as --cui-control-select-bg-img and --cui-control-select-bg-img-dark, dark mode switching which one the background-image reads. A color inside a data URI cannot read a token, so making it translucent is what lets the chevron sit correctly on a field of any color instead of on the two we picked. Measured on the default field, the stroke goes from rgb(44, 49, 60) to rgb(127, 127, 127) in light mode and from rgb(223, 223, 223) to rgb(132, 133, 135) in dark. Override the token with your own data URI to get a solid one back.

  • Two differences a select inherits from .form-control, neither of which changed a rendered box in any case measured. It paints its background with background-clip: padding-box, the value .form-control has always used to avoid a rounded-corner artefact in Firefox — 0.12% of the control’s pixels, the antialiasing of the four corners. And .form-select-sm / -lg now carry the min-height their .form-control counterparts always had, so a sized select is finally the same height as a sized input; it only shows if you have cut the padding below that floor.

Form labels carry their own weight

  • Breaking .form-label and .col-form-label render medium and full-contrast by default. $form-label-font-weight is var(--cui-font-weight-medium) (was null — labels inherited the surrounding weight) and $form-label-color is var(--cui-fg-body) (was null), so a label inside a muted or tinted context keeps the page’s text colour instead of fading with it. Set the knobs back to null, or --cui-label-font-weight / --cui-label-color to inherit, for the old behaviour.

Validation: data-coreui-validate replaces .was-validated

Breaking Client-side validation no longer uses .was-validated or the bare :valid / :invalid pseudo-classes. Add data-coreui-validate to the <form> instead: the styles ride the :user-invalid pseudo-class, so feedback appears only after the user interacts with a control or attempts to submit — no JavaScript toggles a class anymore, and after an AJAX submission there is nothing to remove to reset the form’s appearance.

  • Success styling is opt-in. data-coreui-validate alone shows only invalid styling; set data-coreui-validate="valid" to also colour the valid fields.
  • Server-side validation is unchanged — .is-invalid / .is-valid keep working globally, without the attribute.
  • Feedback of a check, a radio, a switch, a .form-control or .form-select in an input group, or a control placed directly in a component frame belongs to the .form-field around it. The field shows it while the control carries the state, wherever it sits in the field, for example in a .form-field-content next to the label, and a check, a radio or a switch colours the label. Feedback right after any other control still follows it.
- <form class="needs-validation" novalidate>
+ <form data-coreui-validate novalidate>

The starter JavaScript is gone. A form with data-coreui-validate and novalidate is handled by the new form plugin: it stops an invalid submit, marks the invalid controls with .is-invalid and aria-invalid="true", links the message of each to it with aria-describedby while it stays invalid, and moves focus to the first. Remove the copied script; to show the errors your own way, call preventDefault() on validate.coreui.form. Components follow the attribute: the stepper marks the controls of a novalidate step form itself each time it validates the step, passing or not: .is-invalid on the invalid ones (and .is-valid on the valid ones under data-coreui-validate="valid"), and the date / time / date-time inputs key their submit-time state off the attribute instead of the class.

The stepper also sets aria-invalid on those controls and moves focus to the first of them, and its panes are no longer aria-live: while a control is invalid, the stepper adds its .invalid-feedback to aria-describedby instead, the one the stylesheet shows for it (after it, or in the .form-field around the controls the field page lists) or the one named in data-coreui-invalid-feedback, so the message is read with the field. The panes used to read out every message that appeared in them; a message that changes while the user types is now silent, so pass the ones that have to be heard to announce(). Going back to an earlier step no longer validates the current one. stepValidationComplete.coreui.stepper now fires before the stepper decides, as validate.coreui.form does, so a validity its handler sets with setCustomValidity() blocks the step, and event.isValid tells whether every control passed before the handler ran. next() and finish() fire it once per step instead of twice.

Form components share one validation state

Breaking The multi select, the chip input, the combobox, the OTP input, the date and time fields, the pickers and the autocomplete take and show their validation state the same way, described under Form components:

  • The state comes from the validationState option, or from .is-invalid or .is-valid on the component’s element, read once when the component starts. A class or an aria-invalid written on the element later is not read; set the state with setValidationState().
  • A given 'invalid' blocks the submit, with the text of the component’s invalid feedback as the browser’s message, until the user changes the value or the form is reset, which removes the state.
  • The form plugin and the stepper leave the field that carries a component’s value to the component, which shows the state they report on its frame.
  • After taking the result over with preventDefault() on validate.coreui.form, keep event.isValid rather than calling checkValidity(): the call reports every invalid field again, and the components then show their error.

Stepper: next() on the last step finishes the stepper

next() on the last enabled step now does what finish() does: it validates the step and, when it passes, finishes the stepper and fires finish.coreui.stepper. It used to validate the step and stop, so a wizard driven by a single Next control never finished. A step button with .disabled does not count, so next() finishes on the step before it.

Stepper: finish() before the last step does nothing

finish() called on an earlier step used to move to the next step, the same as next(). It now does nothing until the last enabled step is active, the same as in React and Vue. Call next() to move forward.

Stepper: the active indicator’s background follows the theme

Breaking

--cui-stepper-step-indicator-active-bg is removed. The active step’s indicator background is a 5% tint mixed on the step from the theme colour, or from --cui-stepper-step-indicator-active-color without one, so a .theme-* class on the stepper or on a single step button tints it along with the text and border. To give the active indicator another background, set --cui-stepper-step-indicator-bg on .stepper-step-button.active.

Input group: .has-validation removed

Breaking .has-validation is gone. It existed so a group could keep its corner logic with a -feedback element sitting inside it as the last child — in v6 the feedback lives outside the group: wrap the group in a .form-field and put the -feedback after it, and the message renders under the whole frame. A group still carrying the class simply gets the default corner handling; a group still carrying the feedback inside should move it out.

- <div class="input-group has-validation">
-   <span class="input-group-text">@</span>
-   <input type="text" class="form-control is-invalid" required>
-   <div class="invalid-feedback">Please choose a username.</div>
- </div>
+ <div class="form-field">
+   <div class="input-group">
+     <span class="input-group-text">@</span>
+     <input type="text" class="form-control is-invalid" required>
+   </div>
+   <div class="invalid-feedback">Please choose a username.</div>
+ </div>

Input group: one frame class instead of five

Breaking

.input-group squared off the frame of a field component by naming each family’s wrapper — .autocomplete-input-group, .date-picker-input-group, .time-picker-input-group, .form-multi-select-input-group and .form-password. All five became .form-control-group, so the input group targets that one class:

- > .autocomplete:not(:last-child) > .autocomplete-input-group,
- > .date-picker:not(:last-child) > .date-picker-input-group,
- > .time-picker:not(:last-child) > .time-picker-input-group,
- > .form-multi-select:not(:last-child) > .form-multi-select-input-group,
- > .form-password:not(:last-child) > .form-control,
+ > .form-control-group:not(:last-child),

A field component is the frame — it carries .form-control-group on its own root rather than nesting one — so it is a direct child of the input group like any other control, and there is no wrapper level to reach through.

If you wrote CSS against those wrapper classes to adjust a field inside an input group, target .form-control-group instead. A hand-authored group — a password field, a chip input — is now a first-class child of .input-group and needs no wrapper of its own.

The input group styles ship in the components layer, not forms. It assembles controls rather than being one, and the layer it sat in lost to components whatever the selector said — so a .btn inside a group kept the radius it sets for itself and showed rounded corners against the field. If your own stylesheet re-squared those corners as a workaround, the override is no longer needed. A rule of yours that relied on the group losing to a component now loses to the group instead; give it a layer of its own if you need it back.

Chip Input keeps focus among the chips after a removal

Removing the focused chip used to send focus back to the text field. It now moves to the next chip, else the previous one, and only reaches the text field once no chip is left, the same rule Chip Set follows, so several chips can be removed in a row from the keyboard.

Chip Input is submitted only once you name it

Breaking

The hidden input that carries the chips took a generated name when the option was not set, so a form carried a key nobody chose. It is named only when you ask for one, and the option table no longer lists the name as auto-generated. dispose() also removes that input, where it used to leave it in the page.

Chip Input carries its chips in a <textarea>

Breaking

The chips travel in a hidden <textarea> instead of an <input type="hidden">, because the browser leaves a hidden input out of validation. The submitted value is the same, the chips separated by commas, and the id and name options land on the <textarea>. A script or a selector that looked for .chip-input input[type="hidden"] looks for .chip-input > textarea instead. With it the chip input takes required and validationState, blocks the submit while it is empty and required, and a native form reset puts back the chips it started with. A disabled chip input is no longer submitted, like any disabled field. See Validation.

Chip Input takes the frame class itself

Breaking
- <div class="form-control-group chip-input" data-coreui-chip-input>
+ <div class="chip-input" data-coreui-chip-input>

A chip input has no control to wrap — the element is the frame — so it adds .form-control-group to itself rather than asking you to write both. Writing both still works.

Chip Input reads the group’s tokens

.chip-input no longer keeps a private copy of the frame. --cui-chip-input-min-height and --cui-chip-input-gap are gone: the element reads --cui-control-min-height and --cui-control-group-gap from .form-control-group like every other group, and .chip-input-sm/-lg switch those tokens instead of private ones. The Sass variables that only re-pointed a --cui-btn-input-* token ($chip-input-min-height, -font-size, -bg, -color, -border-width, -transition-* and the size variants of min-height and font-size) are removed; what remains is what the chip input sets differently — padding, border colour and radius, and the gap, per size. The disabled chip’s opacity reads --cui-control-group-disabled-opacity instead of a literal .65. Rendered values are unchanged.

Number Input (new)

  • New component: Number Input (PRO) — a number field with a stepper you can actually hit. The browser’s own spinner is tiny, shows only on hover in some engines and does not exist at all on iOS; the component replaces it with two buttons sized like every other adornment, on the shared .form-control-group frame. Nothing to migrate — it is listed here because the number-input name, its classes and its Sass variables are new in v6.

The disabled option is the only switch of the form components

Combobox, Autocomplete, Multi Select, Chip Input, Chip Set, Chip, Rating, Date Input and Time Input take their disabled state only from the disabled option (data-coreui-disabled="true"). A .disabled class in the markup, or a disabled attribute on the select a multi select enhances or on a combobox toggle button, does not disable them. All but Chip Set set the class from the option, Combobox and Multi Select also that attribute; Chip Set passes the option to its chips. dispose() leaves the class and the attribute for the next instance to write again from its options. In v5 a .disabled class disabled a chip, a chip set and a chip input, and the autocomplete data API skipped an element with the class; write data-coreui-disabled="true" instead, on a single chip too. A disabled class returned by chipClassName no longer disables a chip.

A disabled multi select now disables its select, so the form leaves it out of validation and of the submitted data, as it does for the other disabled fields; a required multi select that is disabled no longer blocks the submit.

Multi Select

  • The option checkboxes are the standard form-check surface. The indicators used to re-implement the form-check visuals as ::before pseudo-elements with their own variable set; they are decorative .form-check-input spans now (aria-hidden, since aria-selected carries the semantics), so size, border, radius, background and theming all come from the form-check surface and the two stay in lockstep. The per-indicator look variables are gone with them — only $combobox-option-indicator-width survives, and it follows the checkbox: --cui-check-size where the indicator can see it, and the shared --cui-control-size otherwise. Theme the option checkbox through $check-* / --cui-check-* instead.
  • Validation state comes from validationState, and the select is no longer watched. invalid and valid are deprecated aliases of validationState: 'invalid' and 'valid', and validationState also takes a state from $form-validation-states. A given 'invalid' now blocks the submit, and the next change the user makes to the selection drops it. An .is-invalid or .is-valid class on the select is read once, when the component starts, and moves to the frame; a class or aria-invalid written on the select later is not read, so set the state with setValidationState(). A native form reset restores the selection the control started with. See Validation.
  • Backspace in the empty search box focuses the last chip. It removed the last value at once; the first press now moves the focus to the last chip and the second removes it, as in Chip Input. The arrow keys move between the search box and the chips, the selection is one tab stop, removing a focused chip moves the focus to its neighbour, and a value removed through a chip, Backspace or the cleaner is announced (ariaRemovedAnnouncement). A single select and the counter and text selections still remove the last value at once.
  • selectionType: 'chips' is the default, and 'tags' is deprecated. The two render the same chips; 'tags' keeps working until v7. ariaTagDeleteLabel is deprecated in favour of ariaChipRemoveLabel; the old option still names the remove button and takes precedence while it is set.

Password Input and Number Input build their own frame

Breaking

Both components supply their own buttons, so they supply the frame that lays them out. The markup is a plain form control carrying the toggle:

- <div class="form-control-group">
-   <input type="password" class="form-control">
-   <button type="button" class="form-control-action" data-coreui-toggle="password" aria-label="Toggle password visibility">
-     <span class="form-password-action-icon"></span>
-   </button>
- </div>
+ <input type="password" class="form-control" data-coreui-password-input>
  • data-coreui-toggle moves from the button to the input, and its value is the component’s name: password-input (and number-input for the new Number Input). The button no longer exists in your markup, so there is nothing left to put it on.
  • Classes you write on the input, other than .form-control, move onto the frame — size, spacing, width. They describe the field, and once the input is wrapped the field is the frame; a margin left on the control would sit inside the border. They move back on dispose().
  • An input already inside a .form-control-group is left where you put it, so a field can share a frame with other adornments, and only a frame the component created is removed on dispose().
  • The accessible name of the toggle is the ariaToggleLabel option now, since the button is no longer yours to label.

Password Input: the toggle icon comes from JavaScript

Breaking

The toggle button carried <span class="form-password-action-icon"></span>, and CSS painted it as a mask that swapped through a sibling selector. The plugin renders it now — inline SVG on currentColor, like every other adornment in the library — so the button needs no icon markup at all:

  <div class="form-control-group">
    <input type="password" class="form-control">
-   <button type="button" class="form-control-action" data-coreui-toggle="password" aria-label="Toggle password visibility">
-     <span class="form-password-action-icon"></span>
-   </button>
+   <button type="button" class="form-control-action" data-coreui-toggle="password" aria-label="Toggle password visibility"></button>
  </div>
  • .form-password-action-icon is gone, and scss/forms/_form-password.scss with it. That takes 2.5 kB of URL-encoded SVG out of the stylesheet — it was there for every page, whether or not one had a password field.
  • $form-password-icon-show / $form-password-icon-hide and the matching --cui-form-password-icon-* properties are gone. Supply your own artwork through the new showIcon / hideIcon options instead, the way the other adornments already take theirs. Both are sanitized; allowList, sanitize and sanitizeFn control that.
  • The plugin now initializes on DOMContentLoaded for markup carrying data-coreui-toggle="password", so the icon is present without any script of your own — the same data-API pass Chip, Chip Set, Chip Input and Search Button already make.

Password Input: the toggle is disabled with the field

The toggle button used to stay enabled on a disabled field and ignore the click, while toggle() flipped a disabled field anyway. The button is now disabled with the field and leaves the tab order, like the other action buttons, and neither the button nor toggle() changes what a disabled field shows: a password revealed before the field is disabled stays visible, and can be hidden once the field is enabled again.

Icons rendered by JavaScript

  • New components render their icons as inline SVG on currentColor (the chips pattern) instead of CSS mask data-URIs: swap them with the indicatorIcon / separatorIcon options (sanitized with the SVG allowlist), recolor them with plain CSS color. The corresponding $date-picker-*-icon/-icon-size Sass variables disappear with the v1 shells.
  • The calendar navigation icons moved to JavaScript too. The four $calendar-nav-icon-{prev,next,double-prev,double-next} Sass variables are removed, along with the CSS custom properties and mask rules that painted them. Customize them with the new navPrevIcon / navNextIcon / navPrevDoubleIcon / navNextDoubleIcon calendar options instead (inline SVG, sanitized). $calendar-nav-icon-width/-height/-color/-hover-color stay — the icon inherits color, so recoloring is plain CSS.
  • Calendar’s default allowList is now SVGAllowlist (was DefaultAllowlist), so the component can render its own inline SVG icons — and custom cell renderers may return SVG as well. Strictly more permissive; scripts and event handlers are still stripped.
  • Directional icons are resolved in JavaScript, not CSS. v1 swapped the range separator with a second Sass variable ($date-picker-separator-icon-rtl) applied through an RTL mask; v2 picks between separatorIcon and separatorIconRtl when it renders, based on the element’s computed direction — so a dir="rtl" ancestor works, not just a document-level direction. The calendar mirrors its prev/next icons the same way. Consequence for custom code: a component that resolves directional icons at construction time must be in its final DOM position when it is created — a detached element has no computed direction (the nearest ancestor that declares dir answers for it, dir="auto" included, and the document after that, but placement first is the reliable path).

Date and time fields are submitted only once you name them

Breaking

Date Input and Time Input took an id written on the host as permission to submit, naming their hidden input <component>-<id> when no name was configured, so <div data-coreui-date-input id="delivery"> posted date-input-delivery — a key the page never asked for, from an attribute a <label for> needs anyway. Date Picker and Time Picker inherit the rule through the field they embed, and with floatingLabel that field is given a generated id, so they posted a key that changed on every load (date-input-date-picker-190423). The value field is named from name alone now, and a field without one stays out of the form. dispose() removes the sections, the separators and the value field, takes back the aria-label it generated, and gives the host the author’s own child nodes, the same nodes, not copies. It used to leave a live-looking field behind that kept submitting.

Range / Range Slider

  • Changed. A pressed thumb takes --cui-primary-bg-muted, the theme’s bg-muted under a .theme-* class, instead of a 30% tint of the primary colour ($form-range-thumb-active-bg).

  • Breaking A Range Slider handle with no name of its own is no longer submitted. An array of names shorter than the handle count gave the leftover handles the literal name undefined, so a form carried an undefined= key. Those handles are unnamed now, as they already were in the React and Vue editions.

  • The filled track, value tooltip and tick marks come from a Range plugin. Wrap the input in <div class="form-range"> and give the input .form-range-input; the plugin writes --cui-range-fill (0–1) on the wrapper and the CSS draws the fill from it, data-coreui-tooltips adds a value tooltip on hover, drag and focus, or permanently with "always" (the same tooltips/tooltipsFormat options as the range slider), and a linked <datalist> renders tick marks laid out on a grid. Every such wrapper initializes itself. The input fires changed.coreui.range with the numeric value. The fill is the theme colour at half strength, a shade lighter than the thumb. The bare <input class="form-range"> keeps working unchanged as the CSS-only form of the control; it gets no plugin, no fill and no instance. New tokens on .form-range: --cui-range-track-fill-bg, --cui-range-track-disabled-bg and the --cui-range-tick-* family (labelled ticks use -height, unlabelled ones -minor-height).

  • The range track sits halfway between --cui-bg-2 and --cui-bg-3 (color-mix(in oklch, …), was --cui-bg-3), in both the native range and the range slider. --cui-bg-3 and the secondary colour resolve to the same grey in light mode, so a theme-secondary thumb and fill vanished against the track.

  • Validation reaches the range. .is-invalid/.is-valid on the input (either form) colour the thumb and its focus ring with the state, and a feedback element after the wrapper toggles through :has().

  • Breaking The range slider renders the markup of the range. It builds <div class="form-range"> inside its element, with one native .form-range-input per handle, so it looks like a range and reads its tokens; the slider keeps the handles in order, moves them and draws the band itself. The class names follow: .range-slider → the inner .form-range, .range-slider-input → .form-range-input, .range-slider-vertical → .form-range-vertical, .range-slider-tooltip → .form-range-tooltip (now a <div>), .range-slider-ticks/.range-slider-tick → .form-range-ticks/ .form-range-tick with the text in .form-range-tick-label, and the per-tick .clickable → .form-range-ticks-clickable on the container. .range-slider-inputs-container and .range-slider-track are gone (the first input draws the track), and so is the .disabled class on the element (the inputs are disabled); the stylesheet styles none of the .range-slider-* classes any more. The handles no longer carry role="slider", aria-valuemin, aria-valuemax or aria-valuenow: the native inputs expose them. Options, events and methods keep their names, apart from ariaLabels (below); the changed.coreui.range event that an inner Range fired on each handle is gone, so listen to input.coreui.range-slider and change.coreui.range-slider on the slider. The range tokens are declared on that inner .form-range, so a --cui-range-* token set on the slider element itself (inline, or by a class on it) no longer reaches the handles: target its .form-range (.my-slider .form-range { … }).

  • Breaking The range slider’s ariaLabels option is ariaLabel. It takes an array with one name per handle, as before, a comma-separated string, or a function (index, total) => string, the shape the OTP input uses for its digits; data-coreui-aria-labels becomes data-coreui-aria-label.

  • The range slider element is a group named after the field. The component gives it role="group", so an aria-label or aria-labelledby on it names the group, and a screen reader announces it when the focus enters the slider. With several handles, each handle carries only its own name (“Minimum value”); a single handle takes the name of the group, followed by its ariaLabel when it has one. aria-describedby moves onto every handle. A role written on the element is replaced with group.

  • Breaking The range slider takes a validation state. The new validationState option, or .is-invalid/.is-valid on the slider element read once when it starts, shows on the element and on the handles, and a given 'invalid' blocks the submit through the first handle until the user moves a handle or the form is reset. A class written on the element later is not read; set the state with setValidationState(). See Validation.

  • Range slider interaction follows the native control. Pressing a handle no longer moves it a step; a press on the track or a clickable tick moves the nearest handle and it follows the pointer (mouse, touch or pen), with change.coreui.range-slider on release instead of on press; the events carry numbers; a form reset restores the configured values instead of the middle of the range; setValue() and dispose() touch only what the slider built, so content of the element stays.

  • Breaking The range slider’s handles and track read the range’s tokens. The --cui-range-slider-thumb-* and --cui-range-slider-track-* families are gone; the slider’s .form-range declares the --cui-range-* family instead, so one setting styles both controls. Rename: --cui-range-slider-thumb-{width, height,bg,border,border-radius,box-shadow,active-bg,disabled-bg} → --cui-range-thumb-*, --cui-range-slider-thumb-transition → --cui-range-thumb-transition-{property,duration,timing}, --cui-range-slider-track-{width,height,cursor,bg,border-radius,box-shadow} → --cui-range-track-*, --cui-range-slider-track-in-range-bg → --cui-range-track-fill-bg, --cui-range-slider-disabled-track-in-range-bg → --cui-range-track-disabled-bg. The Sass variables follow ($range-slider-thumb-*, $range-slider-track-* → $form-range-*). --cui-range-slider-label-*, --cui-range-slider-tooltip-margin-* and --cui-range-slider-vertical-* are gone too, with their Sass variables ($range-slider-label-*, $range-slider-tooltip-margin-*, $range-slider-vertical-track-*): tick labels read --cui-range-tick-label-color and -font-size, the tooltips sit like the range’s, a vertical slider’s height is --cui-range-vertical-height and its track width --cui-range-track-height; the tick label padding has no counterpart. The filled band keeps its half-strength mix of the theme colour, now shared with the range’s fill, and a disabled slider greys the band through the theme slot on the track.

  • Breaking The range slider’s labels option is ticks. Every entry draws a tick mark, with or without text, so the option and everything around it carry the name the Range plugin uses: labels → ticks, clickableLabels → clickableTicks (data-coreui-ticks, data-coreui-clickable-ticks), .range-slider-labels-container → .form-range-ticks, .range-slider-label → .form-range-tick, and --cui-range-slider-label-* → --cui-range-tick-label-*. The entry shape is unchanged: a string, a bare number, or { value, label, class, style }.

  • Range slider ticks sit on a grid. The plugin builds grid-template-columns (rows when vertical) from the gaps between tick values, so ticks are in normal flow: the container sizes itself, and the resize listener plus the measured container height are gone. Every tick draws its mark from --cui-range-tick-* — taller when it has text — and the ticks are sorted by value. A bare number in ticks is a tick without text at that value. The new list option reads a <datalist> like the range does and merges its options with ticks. Custom CSS that positioned .range-slider-tick through left/bottom no longer applies.

  • Range slider tooltips follow the range’s modes. tooltips: true shows a handle’s tooltip on hover, drag and visible focus; "always" keeps them on.

  • A theme beats --cui-range-thumb-bg, as everywhere else. The token carried the --cui-theme-* slot inside its own default, so setting it inside a themed container dropped the theme. The slot sits on the property now.

  • Breaking The value bubble is a tooltip. The plugin renders <div class="form-range-tooltip tooltip bs-tooltip-top"> (vertical: bs-tooltip-start, or bs-tooltip-end in a right-to-left page) with .tooltip-arrow and .tooltip-inner, so the bubble looks like every other tooltip on the page — dark on a light page — and follows --cui-tooltip-* and the theme slots. The twelve --cui-range-slider-tooltip-* look tokens (-bg, -color, -padding-*, -font-size, -border-radius, -box-shadow, -arrow-*, -max-width, -transition, -zindex) and their Sass variables are gone, and so are -margin-bottom and -margin-end. The tooltip declares its own --cui-tooltip-* tokens, so set them on it: for the previous light bubble, give the slider tooltipClass: 'light-tip' and declare --cui-tooltip-bg: var(--cui-bg-4) and --cui-tooltip-color: var(--cui-fg-body) on .light-tip.

  • Breaking The pressed thumb adapts to the color mode. Its color was 70% white mixed into the theme color at build time, so in dark mode the pressed thumb flashed a near-white pastel. It now mixes toward --cui-body-bg at runtime — identical in light mode, dark and correct in dark mode.

  • Both sliders are themeable: the filled track and the thumbs read the theme slots, so a .theme-* class recolors them; without one they stay on primary as before.

  • Every --cui-sidebar-* token comes from a Sass map ($sidebar-tokens, $sidebar-nav-tokens, $sidebar-narrow-tokens and the variant maps), so the sidebar takes @use ... with () overrides like every other component. The narrow variant’s variables ($sidebar-narrow-*) live in sidebar/_sidebar-narrow.scss.
  • Hover and active links read the foreground ladder. Their text was the pure black/white --cui-emphasis-color and their background the Sass-computed --cui-tertiary-bg-translucent; they are --cui-fg-body and color-mix(in srgb, var(--cui-fg-body) 10%, transparent) now — a tint that works on any surface and can be retuned in the browser. Slightly stronger than before on a light sidebar, slightly weaker on a dark one.
  • Breaking --cui-tertiary-bg-translucent is gone from :root. The sidebar was its only reader. Set --cui-sidebar-nav-link-hover-bg / -active-bg on .sidebar-nav for a different tint.
  • Theme variants. .sidebar.theme-{color} paints the surface, text, icons and the hover/active links from that colour’s slots. .sidebar-light joins .sidebar-dark as the scheme switch.
  • Breaking .sidebar-body and .sidebar-form are removed. Neither carried styles beyond the sidebar’s own padding. .sidebar-self-hiding-* had already gone in v6 — a sidebar that hides itself below a breakpoint is hide() from a resize handler, or the mobile mode the sidebar takes below lg on its own.
  • The mobile backdrop reads the shared backdrop tokens. .sidebar-backdrop mixes --cui-backdrop-color at --cui-backdrop-opacity and blurs by --cui-backdrop-blur, like the dialog and the drawer; --cui-sidebar-backdrop-bg carries the mix and --cui-sidebar-backdrop-blur the blur, and --cui-sidebar-backdrop-opacity (.5, the old fade target) is gone. The .backdrop-* helpers work on the sidebar too.
  • .sidebar-narrow gets its bottom padding back. It read --cui-sidebar-toggler-height from a token declared on the toggler, a descendant, so the padding silently fell to 0; the token is declared on .sidebar now.

ScrollSpy

  • Breaking The active link is chosen by an activation line, not by scroll direction. The plugin used to compare each section’s offset with the previous scroll position, so the same scroll offset could highlight a different link depending on which way you arrived. It now activates the lowest section whose top edge has crossed a line drawn topMargin below the top of the scroll container — a pure function of the scroll position. Two visible consequences: the last active link stays highlighted while a gap between sections crosses the line (the navigation no longer goes blank), and a short last section is activated when the container is scrolled to the bottom, even though its top never reaches the line.
  • New option topMargin (default 12%) places the activation line; it takes a percentage of the container’s height or a pixel value (96px). A pixel line is recomputed on resize.
  • Breaking rootMargin defaults to null and is now the raw override. Set it and it is handed to the IntersectionObserver unchanged, taking precedence over topMargin; a v5 configuration such as rootMargin: '0px 0px -25%' keeps working as written. threshold defaults to [0] instead of [0.1, 0.5, 1] — with an activation line a section only needs to report whether it crosses it.
  • smoothScroll finishes the navigation. Once the scroll settles, the URL hash is replaced with the link’s target and focus moves to the section.
  • Breaking The deprecated offset option is removed — it has been a thin alias since the IntersectionObserver rewrite. Use topMargin instead: offset: 100 → topMargin: '100px'.

Tab

  • Breaking Constructing a Tab outside a tab-panel parent now throws a TypeError instead of silently returning a half-initialized instance, matching upstream. The silent variant hid real markup mistakes: the instance existed but never activated anything.
  • The data-coreui-toggle="pill" and "list" toggles stay — they are documented markup, and v6 keeps documented markup working.
  • $nav-link-transition splits into $nav-link-transition-property/-duration/-timing, emitted as --cui-nav-link-transition-*. The link’s transition was compiled into its rule while .tab-pane in the same file already read the three-token form, so half the file could be retimed in the browser and half could not. Setting --cui-nav-link-transition-duration now takes effect; before it was ignored.
  • Breaking .nav-link is a flex row, and it fills on hover and when active. The link lays its children out with --cui-nav-link-gap, --cui-nav-link-align and --cui-nav-link-justify, so an icon and its label line up without a wrapper. It also takes --cui-nav-link-hover-bg and --cui-nav-link-active-bg, rounded by --cui-nav-link-border-radius — the base nav used to change text color alone. Set the two backgrounds to transparent to keep the old look. Under a theme, hover reads --cui-theme-fg and the lighter --cui-theme-bg-subtle, and the active item the darker --cui-theme-bg-muted, so hovering a link never outweighs the selected one. .nav-tabs, .nav-underline and .nav-underline-border square the link off, and the two underline variants take neither fill, hover or active: they mark their state with a line, and a radius curves that line’s ends while a fill says the same thing twice.
  • Breaking .nav-tabs draws its line with an inset box-shadow, not a border. The links no longer pull themselves over the container’s border with a negative margin-bottom; each link carries its own bottom border (the line colour at rest, the panel colour when active) and the container paints the line as box-shadow: inset 0 calc(-1 * var(--cui-nav-tabs-border-width)) 0 var(--cui-nav-tabs-border-color). The rendered result is pixel-identical, but custom CSS targeting .nav-tabs’ border-bottom or the links’ negative margin needs a second look; recolour the line through --cui-nav-tabs-border-color. .card-header-tabs compensates with a deeper negative margin instead of zeroing the border.
  • --cui-nav-gap is new, declared on .nav and defaulting to 0, so the base nav renders exactly as it did. It gives the token a home on the component whose name it carries: .navbar-nav already overrode it, and .nav-underline keeps its own --cui-nav-underline-gap.

Tooltip

  • --cui-tooltip-opacity now blends into the background colour instead of fading the whole element. The old element-level opacity: .9 also faded the label text, compositing it below the WCAG 1.4.3 contrast minimum (about 3.5∶1). The surface and arrow keep the same translucent look; the text renders at full opacity. The token still works — override it and only the background alpha changes.
  • Breaking $tooltip-margin and --cui-tooltip-margin are removed. The variable had been null and deprecated since v4; the token compiled to an empty value and the margin declaration reading it never took effect. Position a tooltip with the JS offset option instead.
  • Roomier tip, less see-through. --cui-tooltip-padding-y is calc(var(--cui-spacer) * .375) (was .25rem), --cui-tooltip-padding-x is calc(var(--cui-spacer) * .75) (was .5rem) and --cui-tooltip-opacity is .95 (was .9). Set the three tokens on .tooltip to keep the old proportions.
  • Tooltips take theme classes. The surface and the text read the --cui-theme-bg/--cui-theme-contrast slots, with --cui-tooltip-bg and --cui-tooltip-color as the fallbacks, so a .theme-{color} class on the tip (via customClass) colours it; the default look is unchanged.

OTP input

  • The plugin is enabled with a bare data-coreui-otp. data-coreui-toggle="otp" no longer initialises anything. The toggle attribute marks a control the user activates — a button, a link, an input that opens a panel — and the OTP field is none of those: it is a wrapper the plugin builds its slots inside. Chip Input, Multi Select and the masked date and time fields already read that way.
  • A code delivered by SMS autofill or a password manager now fills the whole field. Only the first slot carries autocomplete="one-time-code" (the rest are off), and a multi-character value arriving in any slot is spread across the field, as a paste already was. Previously such a value stayed in one slot and the submitted value was empty. The slots also gained enterkeyhint, autocorrect="off" and spellcheck="false".
  • Breaking Only the hidden input carries the field’s name. Each visible slot was given <name>-<index> on top of it, so a six-slot field submitted seven keys where the option is documented as the name of the hidden input. The slots are unnamed now, and a name or an id written on a slot in the markup is left alone instead of being overwritten. The hidden input carries whatever the slots show, so slots rendered with their own value attributes are submitted as well, which they were not before.
  • In a right-to-left field, linear now holds the forward move rather than the ArrowRight key. Forward is ArrowLeft when the field reads right-to-left, so that is the key a linear field stops on an empty slot, and ArrowRight walks back freely. Previously the guard was attached to the key name, which let the caret jump ahead over empty slots in RTL and refused to walk back.
  • Breaking The code is validated as one value. It moved from an <input type="hidden">, which the browser leaves out of validation, to a text input laid invisibly over the group, with tabindex="-1" and aria-hidden="true", a pattern for the full length of the code and the required that the slots used to carry. A required code that is empty or incomplete stops the submit, so does an incomplete code that is not required, and the browser puts focus on the first empty slot with its own message over the group. Once a validation reports the code, the group gets .is-invalid and every slot carries aria-invalid="true" and is described by the message after the group while the code stays invalid. The stylesheet colours every slot and shows that message, which it never did for a slot marked invalid. Code that selects input[type="hidden"] inside the field needs .form-otp > input:not(.form-otp-control), a slot no longer gets required, and a data-coreui-invalid-feedback written on the slots goes on the .form-otp element.
  • The code takes a validation state. The new validationState option, or an .is-invalid/.is-valid class on the .form-otp element or on a slot in the markup, shows the state on the group and the slots, and 'invalid' blocks the submit. A class on one slot, which used to colour only that slot, now marks the whole code. The user’s next change of the code drops it, setValidationState() sets it again, and a native form reset puts back the code the field started with. A change of the code sends input and change from the input that holds the value.
  • Breaking The readonly option is removed, and every slot is editable. A code the user cannot type has no use in a form, so data-coreui-readonly, { readonly: true } and a readonly written on a slot no longer make it read-only; use disabled to show a code the user cannot change.

Rating

  • The read-only option is spelled readonly. It was the one component in the library spelling it readOnly, which made its data attribute data-coreui-read-only while every other field component answers to data-coreui-readonly. Both spellings now agree with the HTML attribute: data-coreui-read-only="true" becomes data-coreui-readonly="true", and new Rating(element, { readOnly: true }) becomes { readonly: true }. The .readonly class the component writes is unchanged, and the React and Vue props stay readOnly — that is how those frameworks spell the native attribute.
  • dispose() takes back what the component rendered. The stars and their radios go away, so the form no longer submits the value; content written in the markup stays. A rating left in the page after dispose() used to keep working as a plain radio group.
  • Its options decide .disabled, .readonly, the size class, the radiogroup role and aria-readonly, aria-required and aria-invalid. The same classes and attributes written in the markup are replaced at init, and a rating-sm or rating-lg without size is removed. update({ disabled: false }) used to leave .disabled behind.
  • A read-only rating submits its value under the name you give it. readonly used to disable the radios, so the form left the value out; now they stay enabled, the mouse and the keyboard cannot change them, only the checked star takes focus, and the group carries aria-readonly="true". A read-only rating without name still submits nothing.
  • A star with keyboard focus shows the focus ring.
  • The setters keep the value the user picked and the stars. setValue(), disable() and enable() change their part in place; any option change through update() used to render the stars again and reset the rating to its initial value.
  • Breaking The rating takes a validation state. The new validationState option, or .is-invalid/.is-valid on the rating element read once when it starts, colours the focus ring, and a given 'invalid' sets aria-invalid on the group and blocks the submit until the user picks or clears a star or the form is reset. A class written on the element later is not read; set the state with setValidationState(). See Validation.
  • The rating can be required. required makes every radio required and gives the group aria-required="true".
  • Empty stars are --cui-fg-4: gray-500 in the light scheme and gray-600 in the dark one, the lightest grays that keep 3:1 against the page background. The empty stars of v5 were 38% of the text colour, below 3:1 against the page in the light scheme.

Search Button

  • Breaking The shortcut handlers no longer create search button instances. Every keydown, keyup and window blur anywhere on the page used to initialise each [data-coreui-search-button], so dispose() lasted until the next key press. Now the buttons in the document at DOMContentLoaded are initialised, and a button added later, or one on a page whose script runs after DOMContentLoaded, responds to its shortcut once you call coreui.SearchButton.getOrCreateInstance(element). Activating such a button (a click, or Enter or Space on it) still initialises it.
  • dispose() takes back the shortcut keys the component rendered. A .search-button-keys container written in the markup gets its own content back.

Alert

  • Breaking Every alert fades out when dismissed, and .fade no longer decides that. The alert carries its own transition and --cui-alert-transition-property / -duration / -timing; the JavaScript marks it with .hiding for the length of it, then removes the element. An alert is already on the page, so only the exit is animated — .fade and .show remain the way to fade one in when you insert it at runtime. Add .alert-instant to an alert that should go at once, which is what an alert without .fade used to do. Add .transition-size next to .fade and the exit takes the box with it, so the page rises into the alert’s place instead of jumping.

  • .alert-dismissible is optional now. An alert that contains a close button as a direct child makes room for it and positions it on its own (.alert:has(> .btn-close)). The class keeps working — and is still the way to go when the button is injected later or sits inside a wrapper — so no existing markup changes.

Chip / Chip set / Chip input

  • Breaking A chip starts selected only from the selected option. An active class in the markup no longer selects it; write data-coreui-selected="true" on the chip instead. The role, tabindex and aria-* a chip or a chip set writes come from its options too, so a role or tabindex in the markup is replaced rather than kept, and dispose() leaves them in place for the next instance to rewrite.
  • A chip set is one tab stop. The set keeps tabindex="0" on one chip and -1 on the others and moves it with the focus, so Tab enters and leaves the set once and the arrow keys move between the chips. Every interactive chip used to be a tab stop of its own.
  • Chips expose real roles now. A selectable chip set is a horizontal role="listbox" (with aria-multiselectable in multiple mode) whose chips are role="option" elements carrying aria-selected; a standalone selectable chip is a toggle button (role="button") reflecting its state via aria-pressed instead of aria-selected; a non-selectable set is a role="group". aria-disabled is only stamped on chips that carry a role — on a role-less chip the attribute was invalid and is now removed.
  • clear() and removeSelected() report the change once. Chip set and Chip input fired change and select (.coreui.chip-set/.coreui.chip-input), and Chip input its native input and change, once for every chip removed; they fire once now, after the last chip is gone, and not at all when the chips did not change. The cancelable remove still fires for every chip.
  • Adding and removing chips is announced. Chip set and Chip input announce each change through announce(); the wording is configurable via ariaAddedAnnouncement/ariaRemovedAnnouncement.
  • Every remove button names its chip. Chip and Chip set named each remove button Remove, so a set read the same name for every chip; the button is Remove <text> now, as Multi Select already did; Chip input names it after the chip’s text instead of its value. ariaRemoveLabel (and Multi Select’s ariaChipRemoveLabel) takes a string, where {label} stands for the chip’s text and is appended when missing, or a function of the text for a different word order. A one-word label such as Usuń gains the text; a label that already spelled the text out, such as data-coreui-aria-remove-label="Remove React", now repeats it, so drop it or use {label}. A .chip-remove button you write yourself keeps its aria-label, and gets one from ariaRemoveLabel when it has none.
  • A selectable chip hides its remove affordance. A chip with a role — a selectable chip, which is a toggle button, or an option in a selectable set — gets a <span aria-hidden="true"> instead of a <button> inside it, so its name is its text and a control is no longer nested in a button. Backspace / Delete remove it from the keyboard, the affordance from the pointer.
  • Chip input: aria-disabled/aria-readonly are no longer stamped on the container. The container is a generic element, so those attributes were invalid on it (axe aria-allowed-attr); the native disabled/readonly states on the inner <input> carry the semantics.
  • The remove button gets its size back, and .chip-sm/.chip-lg resize everything they claim to. .chip-remove sized itself from a token that was never declared, so both dimensions were invalid at computed-value time and fell to auto — the button measured 0x0 at every chip size. It reads --cui-chip-remove-size now (16px, 14px on .chip-sm, 20px on .chip-lg), so a chip with a remove button is wider than it was.
  • .chip-dismiss is .chip-remove. The rename landed with the component; a single selector kept the old name and styled nothing, so a disabled chip input never reached its remove buttons through CSS. The button’s disabled attribute was doing the work on its own.
  • --cui-chip-dismiss-size is gone; use --cui-chip-remove-size. The size classes wrote the first name and the glyph read the second, so the remove icon stayed at its base size in every chip size. Nothing read the old token, so an override of it never did anything.
  • .chip-sm and .chip-lg no longer swap the image and icon sizes. They passed the two to chip-size() in the wrong order, so a large chip rendered its icon at 24px and its image at 20px — the reverse of the base sizes, where the image is the larger of the two. Large is now 24px image / 20px icon and small 16px / 14px.
  • Breaking The selected chip’s text follows the palette. $chip-active-color was white at 87% opacity, computed at build time — repaint the selected background and the text stayed near-white. It defaults to var(--cui-primary-contrast) now (solid, slightly brighter than before), and a .theme-* class supplies its own contrast colour through the slot.
  • Hovering a themed, unselected chip no longer previews the selected look. The background steps from bg-subtle to bg-muted and the text colour stays put; only selection paints the solid theme colour. A chip without a theme class hovers exactly as before.

Collapse

  • Breaking The toggle option is gone. It made the constructor change the state the markup declares, which surprised people — so nearly every caller passed toggle: false. The constructor now always keeps the declared state; add .show to start open, and call show(), hide() or toggle() to change it.

    - const collapse = new coreui.Collapse('#myCollapse', { toggle: false })
    + const collapse = new coreui.Collapse('#myCollapse')

    Callers that relied on the constructor toggling need an explicit call: new coreui.Collapse(el).toggle(). data-coreui-toggle on a trigger is unrelated and unchanged.

  • Breaking Triggers no longer get the .collapsed class. Their state was written twice, as the class and as aria-expanded, and only the second is required. Nothing in the stylesheet had matched the class since the accordion stopped using it, so it survived as a hook for custom CSS — style the attribute instead, which is also what assistive technology reads:

    - .accordion-button.collapsed::after { transform: rotate(-90deg); }
    + [data-coreui-toggle="collapse"][aria-expanded="false"]::after { transform: rotate(-90deg); }
  • New hiddenUntilFound option. With data-coreui-hidden-until-found="true" a collapsed area is hidden with hidden="until-found" rather than taken out of the layout, so the browser’s find-in-page searches it and expands it on a match; the component then syncs .show and aria-expanded so the declared state follows what the reader sees. Off by default, and below Chrome 102 / Firefox 148 / Safari 26.2 it hides the content the way hidden always did.

  • Breaking The transition is set through CSS variables, and the Sass variables split in two. $transition-collapse and $transition-collapse-width were shorthands carrying the property, the duration and the easing at once, which meant retiming a collapse required restating the property. They are replaced by $transition-collapse-property, $transition-collapse-duration and $transition-collapse-timing, emitted as --cui-transition-collapse-property / -duration / -timing on .collapse and .collapsing, so all three can also be changed per instance at runtime. $transition-fade splits the same way into $transition-fade-duration / $transition-fade-timing.

  • Breaking CSS owns the collapse animation, and the class choreography follows. Where interpolate-size: allow-keywords is supported the stylesheet interpolates the size to the auto of the open state on its own; elsewhere JavaScript measures the two sizes and the stylesheet still times the move. Three observable changes come with that:

    • .collapse never leaves the element. It used to be swapped out for .collapsing while the transition ran; now .collapsing is added alongside it, so .collapse.collapsing matches during a run and a selector keying on the absence of .collapse no longer fires mid-animation.
    • .show toggles at the start of both directions. It used to land at the end of the opening transition. A rule styling .collapse.show applies for the whole expansion now, and its absence marks the whole closing run.
    • An open .collapse-horizontal is fit-content wide, not as wide as its container — the open size is the animation’s target, and a target of auto would spend most of the run growing an empty box past the content. This also removes the end-of-animation jump to full container width the measured animation used to make. Style the child, as the docs always showed, and nothing changes.

Button

  • A toggle button always exposes aria-pressed now. The attribute has no default value, so a [data-coreui-toggle="button"] that shipped without one was announced as a plain button until the first click. JavaScript adds the missing attribute on DOMContentLoaded, reading the .active class to pick the value; an attribute you wrote yourself is left alone. Write it in your markup anyway, so the state is correct before our JavaScript runs.

  • Breaking .btn is a flex container. It lays its content out in a row, centres it on both axes and separates the parts with --cui-btn-gap, so an icon next to a label no longer depends on a whitespace text node in your markup — and no longer gets a second space where you did leave one. --cui-btn-min-height keeps a button that holds only an icon as tall as one that holds text. Custom CSS that positioned button content with display, line-height or margins needs a second look; the caret of a .dropdown-toggle now takes its distance from the gap instead of its own margin.

  • Breaking A button label no longer wraps. --cui-btn-white-space is nowrap. In v5 $btn-white-space was null, so no declaration was emitted at all and a long label broke onto a second line, leaving the button taller than the ones beside it — while the variable’s own comment told you to set nowrap, which is to say the default was the one nobody wanted. A long label now keeps the button one line tall and makes it wider instead.

  • .btn-toolbar spaces its groups on its own with a .5rem gap (--cui-spacer-3). Drop the me-* utilities that carried the spacing in v5, or they now add to the gap.

    It is still a token, so the old behaviour is one declaration away — on a single button, on a theme, or globally:

    .btn-that-wraps { --cui-btn-white-space: normal; }
    :root { --cui-btn-white-space: normal; }

    In Sass, $btn-white-space: normal. The token is declared on .btn itself, so an override has to reach the button — setting it on an ancestor is not enough.

    Look at rows that hold several buttons: a flex row without .flex-wrap grows wider now instead of taller, so add it where the group should break onto a second line rather than push the layout out.

  • .btn-icon (new). A button that holds nothing but an icon: no padding, and its width follows its height, so it stays square at every size.

  • A variant is a shape, the colour is a theme. .btn-solid, .btn-outline, .btn-subtle (new) and .btn-ghost take their colour from a .theme-* class on the button or on any element above it, and each carries the base button styles on its own — .btn is no longer required next to them:

    - <button class="btn btn-primary">Save</button>
    + <button class="btn-solid theme-primary">Save</button>

    The colour classes keep working and stay the spelling the docs use: .btn-primary is .btn-solid.theme-primary, .btn-outline-primary is .btn-outline.theme-primary, and .btn-subtle-* completes the set. What changes is that one rule per variant now serves every colour, so a theme colour of your own gets the whole set for free.

  • Breaking A bare .btn is a neutral button, not an invisible one. It renders in the surface colours (--cui-bg-2, one step darker on hover) instead of being transparent and unstyled. Buttons that carry a variant or a colour class are unaffected. For the old behaviour use .btn-ghost, which is transparent by design.

  • .btn-xs (new). A fourth size below .btn-sm, reading the shared --cui-btn-input-xs-* tokens.

  • The resting button shadow is gone. $btn-box-shadow and --cui-btn-box-shadow are removed; a .btn draws no box-shadow at rest even with $enable-shadows: true, only the pressed state keeps its inset (--cui-btn-active-shadow). The removed default was the v4 bevel (inset 0 1px 0 rgba(white, .15), 0 1px 1px rgba(black, .075)); put it back with your own .btn { box-shadow: … } if you want it.

Loading button

  • Breaking disabledOnLoading no longer sets the disabled attribute. A disabled button drops focus, so a keyboard or screen reader user who pressed it landed on the page body. While loading, the button now gets aria-disabled="true" and .disabled, keeps focus, and ignores clicks and form submission until it stops. CSS or scripts that read :disabled on a loading button should read [aria-disabled="true"] instead.
  • The start and the end of loading are announced when the button has focus: its name followed by the new ariaLoadingLabel option, then its name again. While loading, the spinner is a role="img" named by ariaLoadingLabel instead of an empty role="status".
  • Breaking .navbar-expand-* reads the navbar’s own width, not the viewport’s. Every .navbar is a query container now, so a navbar in a sidebar or a narrow dashboard column collapses there while a full-width one on the same page stays expanded. For a navbar that spans the page — which is most of them — nothing changes, because its width is the viewport’s.
  • flex-wrap: nowrap and justify-content: flex-start moved from .navbar-expand-* onto its inner .container*. A container query styles descendants of the container, never the container itself, and the inner container is where those two properties took effect anyway (the navbar’s children inherit flex-wrap). A navbar written without an inner .container, .container-fluid or .container-{breakpoint} no longer gets them — wrap its content, the way every example in these docs does.
  • --cui-navbar-text-padding-y and --cui-navbar-toggler-icon-size are new. The padding that lines .navbar-text up with the nav links, and the size of the hamburger glyph, were both compiled into their rules. The padding could not be matched from outside at all: the value it pairs with, --cui-nav-link-padding-y, is declared on .navbar-nav, which is .navbar-text’s sibling and therefore invisible to it. Both are declared on .navbar now, at their current values.
  • An expanded navbar flattens a .drawer, not just an .offcanvas. The reset that turns the collapsed panel back into an inline nav named .offcanvas only, so a .drawer inside a .navbar-expand-* stayed a fixed side panel over the page at every width — measured at 400×868 next to the offcanvas’s 1176×24. Both are handled now.
  • Breaking Three navbar defaults move. .navbar takes --cui-navbar-min-height: 3.5rem, so a navbar with short content no longer collapses below the height of one with a brand in it. .navbar-brand takes --cui-navbar-brand-font-weight, defaulting to medium rather than the body weight. .navbar-nav takes --cui-nav-gap, a .25rem gap between links that the link padding used to carry alone. Set any of the three to its old value to keep the previous rendering.
  • .navbar-translucent is new. It tints and blurs the navbar the way .toast-translucent does for a toast, mixing --cui-navbar-bg — a new token, defaulting to the body background — at --cui-translucent-opacity.
  • A theme beats a navbar token, as everywhere else. --cui-navbar-brand-color, -brand-hover-color and -active-color carried the --cui-theme-* slot inside their own default, so setting one of them inside a .theme-{color} navbar dropped the theme. The slot sits on the property now, so the theme wins — the same order every other component follows.

Checkbox, radio and switch: the class moved onto the input

Breaking

.check, .radio and .switch go on the <input> itself. There is no wrapper and no second element: appearance: none lets the control render a pseudo-element, so the mark is drawn on the input, and its colour is a token (--cui-check-mark-color) rather than a value baked into an SVG. The separate dark-mode switch artwork is gone with it — a thumb painted from a token follows the colour scheme by itself.

- <div class="form-check">
-   <input class="form-check-input" type="checkbox" id="c">
-   <label class="form-check-label" for="c">Label</label>
- </div>
+ <div class="form-field">
+   <input class="check" type="checkbox" id="c">
+   <label for="c">Label</label>
+ </div>

The v5 spelling still works. .form-check, .form-check-input, .form-check-label, .form-switch and .form-check-inline are kept as a compatibility layer — aliases of the new controls, not a second implementation, so there is one engine to maintain and the old markup lays out exactly as it did. Verified by measuring geometry across six markup variants and eight dimensions each: zero differences. It is deprecated in v6 and removed in v7. It ships in coreui-legacy.css, not in coreui.css, so a build whose markup has moved leaves the sheet out and carries no .form-check* selector at all.

.btn-check on the <label> is a current feature and lives with the buttons. The v5 spelling — .btn-check on the <input>, with a sibling <label class="btn"> — is in coreui-legacy.css alongside the rest.

The wrapper it draws is styled from scss/_legacy.scss and goes when the layer does.

Five Sass variables shaped the old artwork and cannot be honoured any more, because the compatibility layer draws through the new engine: $form-check-input-checked-bg-image, $form-check-radio-checked-bg-image, $form-check-input-indeterminate-bg-image and $form-check-transition. Their replacements are --cui-check-mark-checked, --cui-radio-mark, --cui-check-mark-indeterminate and the controls’ own transition tokens. $form-check-label-color and -cursor keep working. $form-check-btn-check-disabled-opacity goes with the toggle button’s move to the buttons — it duplicated $btn-disabled-opacity, which is now what the disabled toggle reads, through --cui-btn-disabled-opacity.

Removed .form-check-reverse is gone. It right-flushed the label and the control as a pair — which order utilities express directly — and it was never the settings row (label on the left, control on the right) that people actually reach for; that one is justify-content: space-between. The React and Vue reverse prop still emits the class and therefore stops having an effect; it is retired with those components in the framework editions.

What replaces the wrapper’s modifiers:

v5v6
.form-check (layout).form-field, or nothing if you only need the control
.form-check-labela plain <label>
.form-check-inlinea flex row: .d-flex.gap-4
.form-check-reversejustify-content: space-between on the field, or order utilities to swap the pair
.form-switch-lg / -xl.switch-lg / .switch-xl

The switch is sized by one token, and gains .switch-sm. --cui-switch-width is derived from --cui-switch-height through the new $switch-aspect-ratio — a plain number, multiplied in the browser — so a size sets the height alone. The size classes now scale the label as well, matching .check-sm / .radio-lg. $switch-sizes replaces the width/height pairs, and the deprecated .form-switch-lg / -xl read it too — $form-switch-widths is gone. Those two classes render exactly as .switch-lg / .switch-xl now, so they grow a little: the track was 1.25em / 1.5em tall on a 2:1 ratio, and is 1.5rem / 1.75rem on the library’s 1.75 ratio.

Fixed: the switch animates again. --cui-switch-transition carried a property name inside it (background-position .15s ease-in-out, inherited from v5’s $form-switch-transition), so transition: inset-inline-start var(…) resolved to something invalid and every switch transition was dropped. It is two tokens now — --cui-switch-transition-duration and -timing — and the thumb slides rather than the background position.

Breaking The switch is redrawn. The track is 1.25rem tall with a 1.75 aspect ratio (was a 2:1, font-relative em track), pill-radiused, on the tertiary surface with an inset shadow; the thumb is white with a drop shadow and turns contrast-coloured when checked. A disabled unchecked switch swaps to the disabled surface with a faded thumb instead of dimming wholesale; a checked one dims. --cui-switch-border splits into -border-width and -border-color, and $form-switch-color, $form-switch-width and $form-switch-border-radius fed only the old look and are removed. The deprecated .form-switch-lg / -xl are redrawn with the rest.

The controls animate on shared knobs. :root carries --cui-control-transition-duration and -timing (an overshoot easing); the checkbox tick and the radio dot pop in on them, the boxes’ colours animate, and the switch thumb slides on the same pair — the three controls move in sync and restyle from one place. Reduced motion disables all of it as usual.

New: checkbox and radio have sizes. .check-sm / .check-lg and .radio-sm / .radio-lg — v5 had none, only the switch was sizeable. Each sets one custom property; the mark scales with the box and the alignment margin is computed from the same value, so any size you set yourself stays on the text’s optical centre too.

Multi Select’s option indicators moved with it — they render as a decorative .check now, so a build with the compatibility layer off still draws them.

Checkbox, radio and switch: one page became four

  • Each control now owns its custom properties. --cui-checkbox-*, --cui-radio-* and --cui-switch-* feed the shared implementation at runtime, so restyling checkboxes no longer drags radios along and no longer needs a Sass rebuild. Set them on the control — a token an element declares for itself cannot be overridden from an ancestor. The old --cui-form-check-* properties still exist and still work; they are the shared layer underneath.

  • The control stays on the text baseline when you resize it. Its margin-top used to be Sass arithmetic over $form-check-input-width, so changing the width token at runtime moved the box off the line. It is a calc() over the size token now.

  • Sized switches are token overrides. .form-switch-lg / -xl set --cui-switch-width and --cui-switch-height instead of writing their own geometry, so a size the map does not name is two custom properties rather than a Sass recompile.

  • The Sass variables behind all of this moved into scss/forms/_checkbox.scss, _radio.scss and _switch.scss. $form-check-* and $form-switch-* keep working — the new per-control variables default to them.

  • The docs split. /forms/checks-radios/ is now Checkbox, Radio and Switch — one page per control, each self-contained down to its own toggle-button and Sass sections. The old URL redirects to Checkbox. No classes changed: .form-check, .form-check-input, .form-check-label, .form-switch and .form-check-inline all work exactly as before; .form-check-reverse is removed.

  • New: .form-field, a grid wrapper that lays a control out with its label, description and validation feedback on one consistent gap, so a form is spaced by the field instead of by margin utilities on its parts. A bare .form-check-input (one written without its .form-check wrapper) switches the field to a two-column layout through :has().

Stacks

  • New: .hstack{-sm|-md|-lg|-xl|-2xl} and .vstack{-sm|-md|-lg|-xl|-2xl} switch direction at a breakpoint. The breakpoint is the width of the nearest query container, so mark an ancestor with the new .stack-container (or the .contains-inline utility) — without one the direction never switches. Plain .hstack and .vstack are unchanged, including .hstack’s vertical centring and its shrink-to-content width.

Images and figures

  • --cui-thumbnail-* and --cui-figure-caption-* are declared, not just read. They existed only as the fallback arm of a var() call, so nothing reported them and an override had to guess the name. .img-thumbnail and .figure-caption declare their full set on the element, padding, border width and caption font size included.
  • --cui-figure-img-margin-bottom is new. The gap between a figure’s image and its caption was compiled into the rule, so the only way to change it was to rebuild from Sass. .figure-img declares it now, and it is the one figure measurement you can retune in the browser.

Figures

  • Breaking .figure is an inline flex column and spaces its parts with --cui-figure-gap. $figure-img-margin-bottom and --cui-figure-img-margin-bottom are gone — the gap (knob: $figure-gap, same .5rem) replaces the image’s bottom margin, and .figure-img carries no styles at all any more — keep it in your markup or drop it, both render the same. Documented markup renders pixel-identically; an <img> without width/height attributes can now stretch to a wider caption (flex stretch), so give figure images explicit dimensions — which they need anyway to avoid layout shift.

Reboot

  • Links change color on hover. --cui-link-hover-color was declared and read by navs and pagination, but a plain <a> only ever changed its underline. It now switches color as well, and inside a themed surface it hovers to --cui-theme-fg-emphasis.
  • The underline sits .2em below the text. <a> reads the new --cui-link-underline-offset token (knob: $link-underline-offset), so underlines clear descenders instead of cutting through them. The .underline-offset-* utilities and .icon-link still override it per element; set the token to auto for the old browser default.
  • accent-color: var(--cui-primary) on :root. Native controls we don’t restyle — a bare checkbox, radio, range or <progress> — follow the brand color instead of the browser’s blue.
  • Every segment of a date or time input gets its padding removed. The list covered five pseudo-elements and one of them (::-webkit-datetime-edit-minute) had been renamed by browsers years ago; seconds, milliseconds, the week number and the AM/PM field were never in it. <input type="time" step="1"> no longer renders taller than a text input.
  • tel, url, email and number inputs stay LTR everywhere. The rule was scoped to [dir="rtl"] elements, so it missed pages that set the direction on <html> through the language rather than the attribute.
  • pre reserves scrollbar space (scrollbar-gutter: stable), so a horizontal scrollbar no longer covers the last line of a code block.
  • Reboot’s type and spacing are CSS variables on :root. Heading margin, family, style, weight and line height, paragraph margin, hr, legend, dt, sub/sup, mark padding, the code font size and --cui-list-padding-x are declared rather than compiled in, so a subtree can be retyped without a class. <kbd> carries its own set on the element, --cui-kbd-border-radius included.
  • .h1–.h6, .small, .mark and .initialism are defined in Reboot, next to the elements they mirror, instead of @extend-ing them from _type.scss. Only .initialism changes cascade layer (content → reboot); the others were already emitted there, because @extend hoists a class to wherever the extended selector is defined. _type.scss is gone with .lead and .display-* (see the type scale below).
  • Hardcoded spacing in Reboot now reads the --cui-spacer-* scale (address, ol/ul/dl, dd, blockquote, pre, figure). Values are unchanged.

Floating labels

  • The label may now come first. .form-floating matches the control’s state in both directions — forward from the label with :has(), backward from the control with ~ — so <label> before the control floats and disables exactly like the v5 order, which keeps working. Writing the label first lets a screen reader announce it before the field’s value, and the docs examples do.
  • A textarea’s label stays at the top. The label is a flex box centred on the control, and label:has(~ textarea) anchors it to the start instead, so it floats correctly however tall the textarea is.
  • Breaking The placeholder shows once the field has focus. It used to stay transparent in every state; it now appears under the floated label while the field, or the control inside a field component, has focus, and stays hidden while the label rests in the field. A read-only .form-control-plaintext keeps it hidden.
  • New: .form-floating-always keeps the label floated and the placeholder showing while the field is empty. It works on .form-floating or on any ancestor. See Always floating.

Lists

  • --cui-list-inline-padding is new. The gap between .list-inline-items was compiled into the rule, so respacing an inline list meant rebuilding from Sass. .list-inline declares it, at the same .5rem.

Tables

  • .table-responsive* is a query container. It still scrolls horizontally below its breakpoint; the addition is that things inside it can now respond to its width.
  • New: .table-stacked{-sm|-md|-lg|-xl|-2xl} turns rows into stacked blocks below the breakpoint, reading the width from the .table-responsive* wrapper. Give every <td> past the first a data-cell attribute for its label; the header is hidden visually and kept in the accessibility tree. See Stacked tables.
  • New: .table-card wraps a table in a card’s border and corner radius, clips the cells, and drops the table’s bottom margin and the borders that would stack on the frame. It goes around the table or on its .table-responsive* wrapper. See Card-like tables.
  • Cell padding, vertical alignment and the border width are CSS variables now. --cui-table-cell-padding-y, --cui-table-cell-padding-x, --cui-table-cell-vertical-align, --cui-table-border-width and --cui-table-group-separator-color are declared on .table instead of being compiled into the cell rule. This also fixes stacked tables, which read --cui-table-cell-padding-* and --cui-table-border-width while nothing declared them, so their rows collapsed to zero padding and lost the separating line.
  • .table-sm sets the padding variables on the table instead of overriding the padding on every cell. Anything that raised specificity to beat .table-sm > :not(caption) > * > * can set --cui-table-cell-padding-y/-x instead.
  • Breaking The contrast factors are percentages. $table-striped-bg-factor, $table-active-bg-factor and $table-hover-bg-factor changed from unit-less fractions (.05) to percentages (5%), because they now ship as the --cui-table-*-bg-factor variables that color-mix() reads at runtime.
  • The state colours follow --cui-table-color. --cui-table-striped-color, --cui-table-active-color and --cui-table-hover-color default to var(--cui-table-color), and the matching backgrounds are mixed from it, so a variant only has to set --cui-table-color, --cui-table-bg and --cui-table-border-color.

Blockquote

  • Breaking The source line reads --cui-secondary-color. .blockquote-footer was painted with --cui-gray-600, a fixed value that never flipped with the colour mode: 4.15:1 on the light background and 3.65:1 on the dark one, both under the 4.5:1 minimum. The secondary colour is defined per mode and clears the threshold in both.
  • The quote lays its children out with flex and gap. Spacing inside a multi-paragraph quote is --cui-blockquote-gap rather than each paragraph’s own bottom margin, and .blockquote > :last-child { margin-bottom: 0 } is gone with the margins it existed to cancel.
  • The footer no longer pulls itself up with a negative margin. A quote followed by .blockquote-footer drops its own bottom margin instead (:has()), so the two spacings can be set independently: --cui-blockquote-margin-y and --cui-blockquote-footer-margin-y.

Toast

  • New: Toaster creates toasts from options and keeps them in a store keyed by id — add() returns the id (an existing id updates in place, a closing one is replaced), update() rewrites a toast, close() hides one or all, promise() follows an asynchronous operation through loading, success and error. It creates its own .toast-container placed by placement, caps visible toasts with limit (the oldest stay in the DOM, inert and hidden), announces each toast through announce() by priority and can render an action button. Toasts slide in from beyond the container’s edge and back out, toasts over the limit fade out instead of vanishing, and the toaster glides the remaining toasts into place when one leaves; stack: true piles them up, newest in front, and spreads them on hover or focus. Nine .toast-container-{block}-{inline} classes pin a container to the viewport in place of the position-fixed top-0 end-0 utility stacks. New classes: .toast-title, .toast-description, .toast-action.
  • Breaking The fade moved to CSS. Toast was the last component driving its animation from JavaScript — it toggled .fade, forced a reflow, then juggled .show, .showing and a deprecated .hide class to fake an exit. Menu and the picker popup already express this with @starting-style and a discrete display transition, and Toast now does the same. JavaScript only toggles .show.
    • The animation option and data-coreui-animation are gone. Add .toast-instant to skip the animation.
    • .showing and the deprecated .hide class are gone, and toasts no longer get the generic .fade class. Replace any CSS or tests that depend on them.
    • The transition is tuned with --cui-toast-transition-duration and --cui-toast-transition-timing.
    • isShown() returns false as soon as hide() is called, rather than when the fade-out ends.
  • Breaking Toasts are opaque by default. --cui-toast-bg and --cui-toast-header-bg resolved to color-mix(in srgb, var(--cui-body-bg) 85%, transparent), so every toast let 15% of whatever sat behind it through — with no blur to settle it, text underneath read straight through the message. Both now resolve to var(--cui-body-bg). Add .toast-translucent for the see-through look, which puts blur(5px) saturate(180%) behind the toast and thins the header to match, the same way .card-translucent and .menu-translucent work.
  • New: .toast-container-{top|middle|bottom}-{start|center|end}. Nine classes pin a .toast-container to the viewport (position: fixed), inset from the edge by --cui-toast-container-inset (--cui-spacer-5, 1rem, by default) and centred with logical insets and auto margins rather than transforms, so they hold in RTL. They replace the position-fixed top-0 end-0 p-3 utility stack; inside a positioned box add .position-absolute to pin the container to the box. --cui-toast-container-inset is declared on .toast-container next to --cui-toast-zindex.

Card

  • Breaking A card is roomier and framed in the solid border colour. --cui-card-spacer-y / -x go from 1rem to --cui-spacer-6 (1.25rem), and the image-overlay padding follows them. The header, the footer and a list group placed directly in the card take --cui-card-spacer-x as their inline padding, so their text lines up with the body; the header and footer keep .5rem of block padding. The border reads --cui-border-color instead of --cui-border-color-translucent, except on .card-translucent, and the radius is --cui-radius-7 (see the radius scale below). A card filled with .text-bg-* keeps that neutral frame; add the matching .border-{color} to frame it in the fill colour. To keep the v5 spacing, set --cui-card-spacer-y and -x to var(--cui-spacer-5) on .card.
  • Breaking .card-header-pills is gone. It only pulled the pill row out to the header’s padding edge, which is wrong for pills — they carry their own background, so flush against the header’s inner edge they touch the border. Drop the class and keep .nav.nav-pills.
  • The footer follows the theme colour like the header does. A themed card (.card.theme-inverse, say) used to paint the footer’s background from the theme while leaving its text at the body colour, so the label sank into the fill. The footer now reads --cui-theme-contrast with --cui-card-cap-color as the fallback, the same expression the header already used.
  • Every card token now carries a real value. --cui-card-title-color, --cui-card-subtitle-color, --cui-card-cap-color, --cui-card-color, --cui-card-height and --cui-card-box-shadow shipped as empty declarations (--cui-card-title-color: ;), because their Sass variables were null and interpolating null yields an empty string rather than dropping the entry. An empty custom property makes every var() that reads it invalid at computed-value time, so the rendered result happened to be right, but the token read as blank in devtools and could not be told apart from one you had cleared yourself. They now ship inherit, auto and none, which compute to the same thing and make the customization surface inspectable.
  • The last card in a .card-group gets its bottom-left corner squared. The rule targeting it was written > card-footer, without the leading dot, so it matched an element type that does not exist and never applied.
  • A theme class works on a single section, not just the whole card. <div class="card-header theme-primary"> used to paint white text on a neutral header, because the background came from --cui-card-cap-bg, a token declared on .card — a custom property resolves where it is declared, and --cui-theme-base was set one level below it, on the header. The theme indirection moved to the point of use, so each section reads the theme in its own scope. A theme class on the .card itself behaves exactly as before.
  • Breaking .card-body is a flex column. It spaces its children with --cui-card-body-gap and zeroes their block margins, so nothing in a card needs .mb-* to sit correctly any more. Two consequences for existing markup: a direct child takes its natural width rather than filling the card (add .w-100 where a block child has to stretch), and text-align no longer moves a button or a link, because they are flex items — use .mx-auto or .ms-auto instead.
  • Stacked sections are divided by a single rule. A body, a list group or a bottom image cap that follows an image cap, a body or a list group draws a divider above itself in the card’s own border, so two bodies in a row, or an image cap above a body, no longer sit with nothing between them, and a border utility or a theme on the card carries over to every divider. A section hidden with hidden or .d-none does not count. A header and a footer draw their own divider, as before.
  • .card-group squares the corners at each seam along the writing direction. The rules named the physical corners, so a right-to-left group squared its outer corners and rounded the ones that meet. A list group at the edge of a grouped card is squared like the other sections.
  • Breaking .card-link is gone, along with --cui-card-title-spacer-y. Both existed to space children the gap now handles; the class’s inline margin only ever worked because the links were inline. Wrap them yourself: <div class="hstack gap-4">…</div>.
  • New: .card-row lays a card out along the inline axis, with .card-img-start and .card-img-end for an image cap on either side. It replaces the .row.g-0 + .col-md-* recipe, which needed a wrapper per section and drew no divider between them.
  • New: .card-subtle swaps the theme’s solid fill for its subtle background, border and emphasis text.
  • New: .card-translucent blurs and saturates whatever is behind the card and thins out the caps to match. The mix and the filter are the shared --cui-translucent-* tokens; the caps read --cui-card-translucent-cap-bg-opacity (Sass default $card-translucent-cap-bg-opacity).
  • Breaking .card-group attaches its cards from a container query, not a media query. The sm breakpoint is now the width of the nearest query container, which is what a group inside a column actually has to fit — the viewport being wide never meant the group was. Mark an ancestor with .contains-inline; without a container the query has nothing to read and the cards stay stacked at every width.

List group

  • Breaking .list-group-horizontal-{sm|md|lg|xl|2xl} turns horizontal from a container query. Same change and same fix as .card-group: put .contains-inline on an ancestor, or the list stays vertical. Plain .list-group-horizontal is unconditional and needs no container.
  • An active item under a .theme-* class is filled, like a page link. .list-group-item.active reads --cui-theme-contrast for its text and --cui-theme-bg for its background and border — the same slots the pagination, the badge and the button use — so the active item of a .theme-danger list is the same red as the active page of a .theme-danger pagination. A pressed action (:active) reads --cui-theme-bg-muted, like hover. Without a theme class nothing changes.

Tab

  • Breaking A tab pane fades on its own — .fade on a .tab-pane does nothing for it. .tab-pane carries the transition and --cui-tab-pane-transition-property / -duration / -timing, and @starting-style supplies the state to fade from. Drop .fade from your panes; add .tab-pane-instant to a pane that should switch without one. .fade itself stays as a generic helper for your own markup.

  • The pane no longer waits a full transition before it starts fading. The JS added .show from inside the callback it had queued on the pane’s own transition — so it waited out a fade that had not begun, then began it. Both classes land in the same frame now, and shown waits on the pane, the element that actually animates, instead of the tab button, which only transitions its colours.

  • Breaking A size modifier applies at every viewport. .modal-lg and .modal-xl were withheld until the lg and xl breakpoints, so on a tablet a large modal was 500 px — the same as one with no modifier at all — and an extra-large modal on a 1100 px screen was 800 px. Both ask for their width everywhere now, capped by max-width: calc(100% - margin * 2) exactly as before, which is what kept horizontal scrollbars away in the first place. At 768 px .modal-lg goes 500 → 712 px, and at 1100 px .modal-xl goes 800 → 1044 px.

  • Breaking .modal-sm is narrow on phones too. It used to fall back to the default width below the sm breakpoint, so on a 360 px screen it filled the width; it is 300 px there now. Drop the modifier for markup that should fill a small screen.

  • $modal-sizes replaces the three breakpoint blocks. Sizes come from a map and a loop, the same shape .dialog-* already used — one model for both windows instead of two.

Pagination

  • Breaking A page link is sized from the shared control scale. Pagination kept its own padding values, so .pagination-lg did not match .btn-lg or .form-control-lg — it measured 56 px against their 48. The tokens read --cui-btn-input-* now, .page-link is a centred flex box with --cui-pagination-min-height, and all three sizes line up. The base and small sizes are unchanged; the large one loses 8 px of height and some horizontal padding.

  • Breaking pagination-size() is removed, together with $pagination-padding-{x|y}-{sm|lg} and $pagination-border-radius-{sm|lg}. Sizes are generated by a loop over $pagination-sizes; add an entry to it and define the matching --cui-btn-input-<size>-* values instead of calling the mixin.

  • Keyboard focus keeps the theme colours. :focus-visible read --cui-pagination-focus-color and -focus-bg with no theme slot, so tabbing to a link inside .theme-success dropped it back to the plain link colour. Both now read --cui-theme-fg-emphasis / --cui-theme-bg-subtle first, like every neighbouring state.

  • $pagination-margin-start is removed. The overlap between links is calc(-1 * var(--cui-pagination-border-width)), so it follows a border width changed at runtime instead of the value frozen at build time. The Sass conditional that compared the variable against that expression is gone with it.

Placeholder

  • The opacities are CSS variables. --cui-placeholder-opacity-max and --cui-placeholder-opacity-min on .placeholder set the resting opacity and the pulse, and --cui-placeholder-wave-opacity-max and -min on .placeholder-wave set its highlight, so both can be retuned on an instance. They were compiled in from $placeholder-opacity-max and -min.
  • Placeholders are paler, taller and rounded. The resting opacity is .18 (was .5) and the pulse dips to .1 (was .2). The default height is 1.25em (was 1em), capped a quarter em below the line height so a placeholder never makes a heading or a tight line taller; .placeholder-xs is .75em (was .6em), .placeholder-sm 1em (was .8em) and .placeholder-lg 1.75em (was 1.2em). Each placeholder has a --cui-radius-3 corner. A button, avatar, badge or chip that takes .placeholder as a modifier keeps its own height and corners.
  • .placeholder-wave is a sweeping highlight and the recommended loading animation. A narrow bright band crosses the placeholders every 1.6s instead of a wide gradient every 2s, and it also works on text. The opacity pulse is .placeholder-pulse now; .placeholder-glow stays as its alias. The keyframes are renamed with it, so custom CSS that names animation: placeholder-glow needs placeholder-pulse. With reduced motion the wave stops and drops its mask.

Popover and Tooltip

  • Deprecated placement takes the logical start and end; left and right are deprecated. The physical names never meant a physical side — both components have always mirrored them in RTL, which their own pages documented — so right was already “the end of the reading direction” wearing the wrong name. The new spellings say it, and match the ones Menu and Dropdown already take. Nothing breaks: left and right keep working and resolve identically, and they are removed in v7.

  • The fade moved to CSS. Neither component renders the fade helper class any more; .show alone drives the transition, with @starting-style supplying the state to animate from. animation: false renders .popover-instant / .tooltip-instant instead of omitting fade, and a custom template opts out with the same class. The option itself is unchanged; only custom CSS targeting .popover.fade or .tooltip.fade needs updating. Tune the motion with --cui-popover-transition-duration / -timing and their tooltip counterparts.

  • A bottom-placed popover’s arrow is filled like the header it points into. The arrow sits above the header and was drawn in the body background, so it read as a notch cut out of the header strip. It takes --cui-popover-header-bg when a header follows it, and stays on --cui-popover-bg when there is none.

  • --cui-popover-header-color reads --cui-heading-color. It resolved $headings-color at build time, so a runtime override of the heading colour skipped the popover header. The default is unchanged.

Progress

  • Breaking $progress-bar-animation-timing is replaced by $progress-bar-animation, which carries the keyframes name too. The stripe animation is a CSS variable now — --cui-progress-bar-animation — so its speed, easing and keyframes can be changed on an instance instead of only at build time. A Sass override that passed just the timing (1s linear infinite) has to pass the whole shorthand (progress-bar-stripes 1s linear infinite).

  • The label font size follows --cui-font-size-sm. It was three quarters of the body font size, a value only this component used; it is the shared small size now, which makes it one step larger. Set --cui-progress-font-size to go back.

  • Breaking .progress-group, .progress-group-prepend, .progress-group-header and .progress-group-bars are removed. Four classes carried a flex row, a 100 px label column, a header row and a 2 px gap between bars — all of which utilities already do. Use .hstack / .vstack with a gap-* and put the label column’s width in a style, as the Grouping bars examples now show.

  • Breaking .progress-thin is removed. It did nothing an inline height does not. Use style="height: 4px" on the .progress, as the Height section shows, or set --cui-progress-height if you want the stripe geometry to follow the height too.

  • .progress-white sets tokens. It was painting background-color past --cui-progress-bg, so nothing could adjust it. It now sets --cui-progress-bg (translucent white) and --cui-progress-bar-bg (--cui-white) and lets the base rules paint; the bar stays white under a .theme-* class, as the variant is meant for dark and brand-coloured surfaces.

  • Gradient bars. --cui-progress-bar-bg-image (default none) paints the bar with an image on top of its colour, so a gradient is one variable on the .progress or the bar. The stripes moved to their own tokens for the same reason: --cui-progress-bar-stripes holds the pattern and --cui-progress-bar-stripe-color its colour, and .progress-bar-striped layers the pattern over the image. The progress-bar-stripes keyframes now shift only the first background layer, so the image underneath stays put.

  • .progress-segmented. A mask that cuts a .progress or .progress-stacked into a row of rounded segments, --cui-progress-segment-min-width wide and --cui-progress-segment-gap apart — or exactly --cui-progress-segments of them when that is set — with corners from --cui-progress-segment-border-radius (--cui-radius-2 by default, 0px for square). Everything under the mask — transition, theme, gradient, stripes — behaves as before.

  • New Progress plugin. Initialized on every .progress-segmented, it reads the bar’s aria-valuenow, aria-valuemin and aria-valuemax and sets --cui-progress-segments-filled, which the stylesheet turns into the bar’s width, so the fill ends on a whole segment. It also counts how many segments of --cui-progress-segment-min-width fit the track, capped at --cui-progress-segments when set, and follows the track as it resizes; a script that changes the bar’s aria-valuenow, aria-valuemin or aria-valuemax calls update(). An inline width on the bar still wins over the segment fill. coreui.Progress is exported next to the other plugins.

Spinner

  • Breaking Spinners are sized in em, not rem. .spinner-border and .spinner-grow are now 1em square with a .125em border, so a spinner takes the font size of whatever it sits in instead of being a fixed 32 px block. Inside a button or a paragraph it lands at the size you previously had to ask for with a modifier; standalone it is half of what it was. Set --cui-spinner-width / -height, or the $spinner-width / $spinner-height Sass variables, to go back to a fixed size.

  • Breaking .spinner-border-sm and .spinner-grow-sm are replaced by one .spinner-sm. The small variant no longer has to correct the border width, so a single class serves both spinners.

  • Breaking --cui-spinner-vertical-align is gone. Spinners are inline-flex and assume a flex parent; the -.125em baseline nudge it used to need no longer applies.

  • The animation timing function is a token. .spinner-border keeps linear, while .spinner-grow now eases with ease-in-out instead of pulsing linearly. Override --cui-spinner-animation-timing-function to change either.

  • .spinner-rotate is new. It applies only the rotation, so you can spin your own SVG or icon without inheriting the bordered circle.

  • .btn-loading sizes its spinner from the button. LoadingButton no longer adds a -sm class to the spinner it injects; the spinner follows the button’s font size, so it now scales with .btn-sm and .btn-lg.

  • Breaking .footer is gone. Every declaration it carried is reachable with utilities, so <footer class="footer"> becomes <footer class="d-flex flex-wrap align-items-center justify-content-between bg-3 border-top px-5 py-3">. The only property without a utility is min-height: 3rem; where a floor matters, set it yourself or pad with py-4. The --cui-footer-* variables and the $footer-* Sass variables go with it.

  • Breaking .footer-sticky is replaced by .sticky-bottom. It pins to the bottom the same way but sits on --cui-z-sticky rather than --cui-z-fixed, so a footer that has to stack above fixed chrome needs its own z-index.

  • Breaking .footer-fixed is replaced by .fixed-bottom. It pins to the bottom of the viewport the same way and, unlike the footer class, is available in both editions.

  • Breaking .header-sticky is replaced by .sticky-top. The two rules were identical — position: sticky, top: 0, --cui-z-sticky — so the utility pins the header exactly as the component class did, and it composes with the responsive variants (.sticky-md-top) the component class never had.

  • .header-fixed never existed. The position property on the framework components accepted fixed and rendered a class the stylesheet did not define, so it did nothing. Use .fixed-top or .fixed-bottom; on the framework components, position renders those utilities now.

  • .prevent-hide on .header-toggler is removed. It forced display: block !important against a hide rule that no longer exists; nothing in the library or the admin templates sets the class.

Callout

  • The callout takes its colour from a .theme-* class, on the callout or on any element above it, like the alert does. The class colours the leading edge and tints the border, and because it exposes every slot, .theme-subtle next to it fills the callout without naming the colour twice.
  • Deprecated .callout-{color} still works and is removed in v7. Each modifier is generated from $theme-colors through the same theme-tokens() mixin as the theme class, so .callout-primary is .callout.theme-primary.
  • Breaking $callout-variants is gone. The modifier classes are generated from $theme-colors, so adding a theme colour adds its callout class — there is no separate map to keep in step.
  • A callout without a colour no longer draws its leading edge in currentColor. --cui-callout-border-left-color was read but never declared: only the modifiers set it, so the declaration was invalid on a bare .callout and the property fell back to its initial value. It now defaults to the callout’s own border colour.
  • The 1px border of a coloured callout is tinted. It reads --cui-theme-border where it used to stay neutral, so a coloured callout is a coherent block rather than a grey box with a coloured tick. The leading edge is unchanged.

The deprecated colour classes are a separate stylesheet

  • .alert-{color}, .btn-{color}, .btn-outline-{color}, .btn-subtle-{color}, .btn-ghost-{color}, .callout-{color}, .chip-{color} and .list-group-item-{color} ship in coreui-legacy.css. Load it after coreui.css and they render exactly as before; leave it out once your markup uses the theme classes. They are removed outright in v7.
  • The buttons carry the colour spelling in two places, not one. Besides the loop that points each class at a theme colour, a variant’s base declarations are repeated for the colour classes — the legacy sheet carries both, so .btn-primary moves whole rather than surviving as a rule with no colour.
  • alert-modifiers and btn-variant-loops each covered two loops, one deprecated and one not. They are split, so the docs can mark the deprecated half without mislabelling the other: the alert variants loop is alert-variant-loop, and the deprecated button classes are btn-deprecated-modifiers.

Helpers

  • .link-{color} is gone; a theme class colours a link. Reboot resolves a link’s colour from --cui-theme-fg, which every .theme-* class sets, so the colour follows the theme instead of needing its own class:

    <!-- v5 -->
    <a href="#" class="link-primary underline-opacity-25">Primary link</a>
    
    <!-- v6 -->
    <a href="#" class="theme-primary underline underline-opacity-25">Primary link</a>

    Two things change beyond the class name. The theme slot is a tone picked for text rather than the raw brand colour — it darkens on a light background and lightens on a dark one, where .link-* gave the same colour in both, so the link is more readable in dark mode and slightly different in light. And the underline needs .underline to carry it; its colour now defaults to currentColor, so it follows the theme unless you name one with .underline-{color}.

    .link-body-emphasis becomes .text-body-emphasis.

    The removed classes also computed their :hover colour in Sass, which froze it at build time — overriding --cui-primary at runtime never reached the hover state. The theme slots are tokens, so it does.

  • .ratio is now a native aspect-ratio utility. The helper built its ratio from a ::before with a percentage padding-top, which required a wrapper and absolutely positioned its only child. The class names are unchanged — .ratio-1x1, .ratio-4x3, .ratio-16x9, .ratio-21x9 — but they now set the CSS aspect-ratio property on the element itself.

    <!-- v5 -->
    <div class="ratio ratio-16x9">
      <iframe src="..."></iframe>
    </div>
    
    <!-- v6 -->
    <iframe class="w-100 ratio-16x9" src="..."></iframe>

    The wrapper and the bare .ratio class are gone; put the ratio class on the element you want sized. A custom ratio moves from a percentage to a ratio: --cui-aspect-ratio: 50% becomes --cui-ratio: 2 / 1, on .ratio-auto. The $aspect-ratios map moved from helpers/_ratio.scss to _config.scss and its values are ratios rather than percentages.

Sass architecture & design tokens

v6 adopts the Bootstrap v6 SCSS architecture: CSS cascade layers, per-component design-token maps, one shared styling surface for every text control, and runtime color math in place of precompiled shades. The global Sass knobs stay the build-time customization API, while a component’s own surface is its token map — several things below are breaking and are called out as such.

A theme class wins over a component’s own colour token

  • Breaking Under a .theme-* class the theme wins over a component colour token. A component reads the --cui-theme-* slot on the property and its own token only as the fallback. In v5 the colour came from the component’s tokens, which a modifier such as .alert-danger set, so a token you set on a coloured alert applied. In v6 a token set on a themed element, or for the whole page in your stylesheet or a $…-tokens map, changes only the components without a theme; the alert, avatar, badge, callout, chip, checkbox, radio, switch, List Box indicator, sidebar and tooltip follow the same order as the card, toast, list group and nav. To recolour one themed element, set the slot rather than the component token: <div class="alert theme-danger" style="--cui-theme-bg-subtle: …">.
  • Variant classes keep the component token as the override point. .btn-solid and the other button variants, .alert-solid, .badge-subtle, .badge-outline, .avatar-subtle and .chip-outline map the component tokens to their own slots and paint from the tokens, so on those elements a component token you set wins over the theme, as on a v5 button. Validation states (.is-invalid, .is-valid) win over a theme class too.

A numeric radius scale replaces the one global radius

  • Breaking Components read stops of a radius scale, and most got rounder. :root emits --cui-radius ($radius, .5rem by default) and the stops --cui-radius-0 through --cui-radius-9 as calc() multiples of it, plus --cui-radius-pill, and component tokens read stops instead of the one global --cui-border-radius: buttons, inputs, list groups, tabs, nav links, progress, tooltips, thumbnails, callouts, combobox options and the range slider’s tooltip sit on --cui-radius-5 (.5rem), alerts, badges, popovers, dialogs, drawers, menus, accordions, toasts, dropdowns, popups and cards on --cui-radius-7 (.75rem), and nav pills on a fixed 3rem, which keeps a pill up to 96px tall fully round and does not follow --cui-radius. Everything that used the .375rem default is visibly rounder. To keep a component on its old radius, set its own token (--cui-card-border-radius: .375rem); to retune the whole page, set --cui-radius (every stop follows) or override single stops. The $border-radius(-sm/-lg/-xl/-xxl/-pill) variables and their --cui-border-radius-* tokens are gone with it: no component read them any more (the modal, the chip, the switch, the chip input, the search button’s key, the sidebar nav, the header toggler, the password-strength segments and the validation tooltip moved to the stop with the same value: --cui-border-radius → --cui-radius-4, -sm → -3, -lg → -5, -pill → --cui-radius-pill), and the .rounded-* utilities now read the scale too (see the utilities section). --cui-border-radius is gone from :root as well: nothing in the library read it, so a stylesheet setting it changed nothing — set --cui-radius or a stop instead.

Interaction surfaces sit two rungs lighter on the ladder

  • Breaking Hover, active and disabled surfaces read lighter ladder rungs. Hover states read --cui-bg-1 (was --cui-bg-3), active and disabled states --cui-bg-2 (was --cui-bg-4), across list groups, menus, navs, pagination, breadcrumb links, the accordion’s open header, the file-input button and disabled controls; the badge’s neutral fill, the popover header, the progress track, the avatar fallback and input-group addons step down the same way. Interaction feedback is a tint rather than a block of gray — the same relationships, two rungs closer to the page. Set the component’s own token to keep a darker surface.
  • Breaking The neutral button steps down the ladder with everything else. A .btn without a theme colour rests on --cui-bg-2 (was --cui-bg-4) and hovers on --cui-bg-3 (was a hard-coded gray-300/gray-700 pair outside the ladder); the neutral subtle variant rests on --cui-bg-2 and the ghost variant’s hover wash is --cui-bg-1. The default button is visibly lighter. Its corners also now read --cui-btn-input-border-radius (--cui-radius-5, .5rem) instead of the global --cui-border-radius, so buttons match the fields they sit next to in input groups; .btn-group clips to the same stop. Set $btn-bg/$btn-hover-bg or the --cui-btn-* tokens to keep the old look.
  • Breaking The rest of the library steps down the same two rungs. The first pass left a group of components on the old surfaces; they now read the rungs their neighbours read. Hover surfaces on --cui-bg-1: the dropdown link, combobox options and the select-all row (Autocomplete, Multi Select), the control-group action button and pagination focus (was --cui-bg-4, three rungs above its own hover). The calendar cell and the time-picker roll cell, which share the date-time picker’s panel, hover one rung higher on --cui-bg-2. Rest and selected surfaces on --cui-bg-2: the active nav link and nav tab (was --cui-bg-4), the selected combobox option, the neutral chip (hovering on --cui-bg-3 instead of a mix with the foreground), the password-strength track and the search button’s key cap (active on --cui-bg-3). The range slider track sits halfway between --cui-bg-2 and --cui-bg-3 like the native range. The card header and footer read --cui-bg-1 instead of a 3 % foreground wash, so a card on a tinted parent keeps a neutral cap. The tab’s hover border reads --cui-border-subtle rather than a surface rung.
  • Foreground roles line up too. The disabled calendar cell and the disabled combobox option read --cui-fg-4 / --cui-fg-3 like every other disabled control, the weekday header and the date-input placeholder read --cui-fg-3 like every other caption and placeholder, and the active breadcrumb reads --cui-fg-1, one rung above the hovered link instead of the same one.
  • Breaking Border tokens move one rung towards the page. --cui-border-color is light-dark(gray-200, gray-700) (was gray-300 / gray-800): lighter in the light scheme, and in the dark scheme one rung further from the body, where the old value sat too close to --cui-bg-body to read as a line. --cui-border-subtle is a half step, mixed from gray-100 and gray-200 (gray-800 / gray-900 in dark) — plain gray-100 had become invisible against --cui-bg-1 on the spread-out light band. --cui-border-emphasized is gray-600 in the dark scheme (was gray-700, one rung from the plain border). Everything that reads the tokens — cards, list groups, tables, inputs, tabs, the switch and check frames — follows.

The neutral ladder is three maps

  • Breaking $theme-bgs, $theme-fgs and $theme-borders replace the neutral scalars. $fg-body, $fg-1 … $fg-4, $bg-body, $bg-1 … $bg-4, $border-subtle, $border-muted and $border-emphasized are gone; each map lives in scss/_theme.scss in the defaults() shape, :root emits every entry as --cui-bg-{key}, --cui-fg-{key} or --cui-border-{key}, and the .bg-*, .fg-* and .border-* utilities are generated from the same maps. $border-color stays a scalar — it is the default every component reads, not a rung. Override an entry instead of a variable:

    - $bg-body: #000;
    - $fg-body: #111;
    + $theme-bgs: ("body": #000);
    + $theme-fgs: ("body": #111);

    The maps also carry white, black, bg/fg (the opposite ladder’s body) and, for backgrounds, transparent, so .bg-fg, .fg-bg, .fg-white, .fg-black, .border-bg, .border-white and .border-black read tokens like every other rung. .border-body reads --cui-border-body, light-dark(gray-300, gray-800), a step heavier than --cui-border-color; it was an alias of the default border before.

  • --cui-link-color is light-dark(var(--cui-primary-base), var(--cui-primary-fg)) (was the base in both schemes), so a link on the dark page reads the lighter foreground stop instead of the fill colour, and --cui-link-hover-color is a 10 % darkening of the link colour (color-mix(in oklch, var(--cui-link-color) 90%, var(--cui-black))) instead of --cui-primary-fg; override --cui-link-color and the hover follows.
  • --cui-hr-border-color is var(--cui-border-color) (was currentcolor): a rule is the same line as every other border, not a faint copy of the text colour.

Theme colours have no bare token

  • Breaking --cui-primary, --cui-secondary, --cui-success, --cui-info, --cui-warning, --cui-danger and --cui-inverse are gone. A theme colour is its set of slots — -base, -bg, -fg, -fg-emphasis, -bg-subtle, -bg-muted, -border, -focus-ring, -contrast — and the colour itself is the -base slot: --cui-primary-base is what --cui-primary was, the 100–900 scale is mixed from it, and -bg (the action background) points at it. Rename reads one to one, and set the palette stop to retint a colour:

    - .brand { color: var(--cui-primary); }
    + .brand { color: var(--cui-primary-base); }
    
    - :root { --cui-primary: #6f42c1; }
    + :root { --cui-primary-500: #6f42c1; }

    Components that filled with the bare colour read -base now; the derived hover and active states of a .theme-* class are computed from -bg.

Breakpoints: lg 1024, xl 1280, 2xl 1536

  • Breaking The three largest breakpoints move and the largest is renamed. lg is 1024px (was 992), xl 1280px (was 1200) and the top tier is 2xl at 1536px (was xxl at 1400); sm and md stay. Every responsive class spells the tier the new way — .col-xxl-6 is .col-2xl-6, .container-xxl .container-2xl, .modal-fullscreen-xxl-down .modal-fullscreen-2xl-down, .hstack-xxl .hstack-2xl, .drawer-xxl .drawer-2xl, .offcanvas-xxl .offcanvas-2xl — and the containers grow with them: .container is 1200px at xl (was 1140) and 1440px at 2xl (was 1320). Only .icon-xxl keeps the spelling: it is a size, not a breakpoint. The Sass map is $breakpoints (was $grid-breakpoints) and the mixins take it under that name; $container-max-widths keeps its name. The responsive placement of menus, tooltips and popovers (data-coreui-placement="lg:end") reads the same values, and the sidebar’s mobile mode, $mobile-breakpoint: lg, now starts below 1024px — a tablet in landscape gets the mobile layout.

Fixed widths and a vertical gutter knob

  • New: .w-1 … .w-12, one $spacer per step from the $sizes map, merged into the width utility next to the percentages.
  • Breaking $grid-gutter-width is $grid-gutter-x, and $grid-gutter-y joins it. make-row() takes both (make-row($gutter-x, $gutter-y)) and writes them to --cui-gutter-x / --cui-gutter-y; the row gutter defaults to 0 as before, so nothing moves unless you set it. make-grid-columns() and the card group read $grid-gutter-x. $grid-gutter-width stays as a deprecated alias of $grid-gutter-x until v7 — set the new name, the alias only reads.

The z-axis names the new components

  • New keys in $zindex-levels: menu, drawer, dialog, on the same values as dropdown, offcanvas and modal (1000, 1045, 1055). Each emits a --cui-z-* token and a .z-* utility, and the menu, drawer and dialog read their own key instead of the legacy component’s. The legacy keys stay; both spellings of a pair always resolve to one number.

Every type stop carries its own line height

  • Breaking $font-sizes sets a line height per stop instead of one value for all headings. xs is 1.25, sm … lg the body leading (--cui-body-line-height, 1.5), xl 2.5⁄1.75, 2xl 3⁄2.25, 3xl 1.2, 4xl and 5xl 1.1, 6xl 1 — the larger the type, the tighter the leading, and the small stops read at body leading instead of 1.2. The sizes are unchanged. Headings follow their stop through --cui-line-height-*: h5 (lg) and h4 (xl) get taller lines, h1 (4xl) a tighter one. $headings-line-height still feeds --cui-heading-line-height for anything that reads it directly.

Layered shadows

  • Breaking Every shadow is a stack of two to four layers with a negative spread instead of one wide blur: a tight layer for contact, wider ones for ambient light, each at 4–9 % alpha, so a card reads as lifted rather than smeared. .shadow-xs and --cui-box-shadow-xs are new (one thin layer). The inset shadow reads 8 % in the light scheme and 26 % in the dark one. --cui-shadow-strength in the dark scheme is 2.4 (was 2.5). The knobs are unchanged: --cui-shadow-color and --cui-shadow-strength on :root, .shadow-{color} and .shadow-opacity-* on the element, $shadows in Sass — a custom entry is now a comma-separated list of layers.

A denser spacing scale

  • Breaking $spacers has thirteen steps, 0–12. From 1 the scale runs .25 / .375 / .5 / .75 / 1 / 1.25 / 1.5 / 1.75 / 2 / 2.25 / 2.5 / 3 rem (v5: .25 / .5 / 1 / 1.5 / 3), so every spacing, gap, space and gutter class changes value under the same number. The v5 steps map to:

    v5 stepvaluev6 step
    1.25rem1
    2.5rem3
    31rem5
    41.5rem7
    53rem12

    .p-3 becomes .p-5, .mb-4 becomes .mb-7, and the same for every margin, padding, gap, space and gutter class and its responsive variants. The components are unchanged: their tokens read the stops that carry the v5 values, so alerts, modals, drawers, popovers, callouts, list groups, the sidebar and the reboot margins keep their size. Cards are the exception, see Card.

Padding tokens carry both axes

  • Breaking A token that sets padding names an axis. The six that v5 also had as custom properties are -padding-y and -padding-x now: --cui-modal-padding, --cui-modal-header-padding, --cui-calendar-nav-padding, --cui-nav-enclosed-padding, --cui-date-picker-footer-padding, --cui-time-picker-footer-padding and --cui-card-img-overlay-padding. The same goes for the ones v6 introduced, whether they are new or were Sass variables before (--cui-btn-close-padding, --cui-dialog-padding, --cui-dialog-header-padding, --cui-dialog-footer-padding, --cui-form-field-card-padding, --cui-list-box-section-label-padding, --cui-mark-padding, --cui-thumbnail-padding, --cui-date-picker-time-body-padding). Defaults are unchanged, except the card overlay’s (see Card), so set both to the old value to keep the same box. A single-value token could not be overridden per axis, and one of them was already a two-value shorthand (--cui-list-box-section-label-padding: .5rem .625rem), which made the horizontal padding unreachable without restating the vertical one.
  • Two tokens keep one value, because neither sets a padding. --cui-switch-padding is the inset between the track and the thumb: the thumb’s size subtracts it on both axes and only the inline offset reads it directly, so a vertical half would be a knob with nothing to set. --cui-list-inline-padding is the space between .list-inline-items and sets a margin.
  • Breaking --cui-modal-header-padding is gone. It only restated --cui-modal-header-padding-y --cui-modal-header-padding-x, and setting it had no effect on the close button, which reads the two axes. The footer no longer reads the modal’s body padding either: it has --cui-modal-footer-padding-y and -padding-x, defaulting to --cui-modal-padding-y / -padding-x.
  • A distance between children is a -gap token. --cui-date-picker-ranges-gap, --cui-date-picker-footer-gap, --cui-time-picker-footer-gap, --cui-drawer-body-gap and --cui-drawer-footer-gap are new; the time picker’s footer used to space its buttons with a .btn + .btn margin, which only worked for buttons and could not be set from outside. The date picker’s ranges column and its time row also stopped borrowing the footer’s padding and border: they read --cui-date-picker-ranges-* and --cui-date-picker-timepickers-*.
  • Breaking $link-decoration, $link-underline-offset and $link-hover-decoration are gone. The underline lives on :root as --cui-link-decoration (underline) and --cui-link-underline-offset (.2em); set the token instead of recompiling. --cui-link-hover-decoration is no longer declared — the hover falls back to --cui-link-decoration, so declare it yourself for a different hover. Nav links, list-group actions, page links, the header, the dropdown item and the button write text-decoration: none outright (they used to skip it when the knob was already none), and .btn-link reads the tokens.

Every theme colour gets an action-background slot

:root emits --cui-{color}-bg for every theme colour, pointing at the colour’s base token. Active and checked states read the slot instead of the base directly — --cui-list-group-active-bg, --cui-menu-item-active-bg, --cui-pagination-active-bg, --cui-nav-pills-link-active-bg, --cui-progress-bar-bg and --cui-range-thumb-bg are all var(--cui-primary-bg) now. The resolved values are unchanged; the slot is a single place to retune what “filled with the theme colour” means without touching the base colour that text and borders derive from. (The secondary colour’s slot takes over the name of the former surface alias — see the ladder entry above.)

  • A .theme-* class reads the colour’s -bg slot, not its base. --cui-theme-bg is var(--cui-danger-bg) now (was var(--cui-danger)), so overriding --cui-danger-bg retunes every checked, active and filled state under the class, as the colour slots page describes. The trade-off: --cui-danger-bg resolves on :root, so a --cui-danger override on a single element no longer reaches the theme class on it — override the colour on :root, or set --cui-danger-bg on the element.

Colour decisions are written, not computed

v6 stops evaluating colours at build time, the way upstream v6 does — Sass assembles maps of CSS expressions and the browser resolves them.

  • The contrast slot holds written values instead of color-contrast() results. The computed and written values are identical, so nothing renders differently; the point is that a theme colour no longer has to be a Sass colour for the contrast pass to run over it. Upstream keeps a "contrast" key per colour and never calls the function either.
  • Table variants read the theme tokens. Breaking .table-primary and friends still exist, but they are generated the way .btn-primary is: the class sets --cui-theme-* and the base .table rules read it. $table-variants is now a list of colour names, not a map of pre-shifted colours, and $table-bg-scale / $table-border-factor are gone. A theme colour you add yourself now gets a table variant without compiling Sass. Two visible consequences: the variant background is the colour’s bg-subtle token rather than a shift-color() of the base, and striped / hover / active are color-mix() overlays on the text colour rather than opaque mixes — so they compose over the row’s own background. Table variants also follow colour modes now, where before they stayed on light-palette values in dark mode.

The theme colours are retuned so white text reads on them

Breaking Four of the five base colours moved, and success, info and danger now carry white contrast text instead of black. The bases were picked at the brightest point where white text still clears both contrast gates: WCAG 4.5:1 and APCA Lc 75, the APCA threshold for body text. Before this, only primary paired with white, because the bases sat at lightnesses between 61 and 81 that no dark-on-light pairing and no light-on-dark pairing could both satisfy.

ColourBeforeAfter
$primaryoklch(52.945% 0.19103 278.34)unchanged
$successoklch(61.237% 0.17298 146.95)oklch(55.1% 0.173 145)
$infooklch(67.55% 0.17646 252.26)oklch(57.4% 0.196 256)
$warningoklch(80.791% 0.16473 78.5)oklch(84% 0.172 85)
$dangeroklch(63.864% 0.18175 23.81)oklch(58.7% 0.237 23.81)

success and info get darker, danger gains chroma, and warning goes the other way: it is the one colour that cannot take white text at any hue, so it stays on black and moved brighter, where black reads at Lc 75 instead of 69. info also shifts four degrees of hue, which sounds cosmetic and is not: at 252 degrees the sRGB gamut caps chroma at 0.171 and the colour reads as petrol, while at 256 the cap rises to 0.196 and it reads as blue.

Every colour is a 500 stop, so the whole scale moves with it and any component tinted from these colours changes tone. If you pinned a base, nothing changes for you. To keep the old look, set the five variables back through @use ... with(); to keep the old contrast text, set "contrast" on the colour in $theme-colors.

Two pairings are still open and are tracked rather than fixed here. Dark-mode fg reads from the 400 stop and lands at Lc 42, well under the gate, and fg-emphasis on bg-muted in light mode tops out at Lc 74 because a 200 background is too dark for any text to clear 75 against it.

RGB channel tokens and the elevation system removed

Breaking
  • Every --cui-{name}-rgb custom property is gone, along with the $theme-colors-rgb and $theme-colors-rgb-dark maps, the to-rgb() function’s role in :root, and the rgba-css-var() and translucent-css-var() helpers (color-var-ref() is the function that stays — see the utilities section). They existed only so rgba(var(--cui-primary-rgb), .5) could apply an alpha to a token; that is what color-mix() does directly:

    - background-color: rgba(var(--cui-primary-rgb), .5);
    + background-color: color-mix(in srgb, var(--cui-primary) 50%, transparent);

    Upstream v6 carries no rgb triplets either. The opacity utilities (.text-opacity-50, .bg-opacity-25, .link-opacity-75) are unchanged — they now scale the token through calc(var(--cui-bg-opacity) * 100%).

  • The elevation system is gone: $elevations, $enable-elevations, $elevation-base-color, the elevation() mixin and the .elevation-* utilities. It was disabled by default ($enable-elevations: false), so the utilities were the only reachable part. Use the shadow utilities (.shadow, .shadow-sm, .shadow-lg) or set box-shadow directly.

  • The button focus ring is the theme ring. --cui-btn-focus-shadow-rgb and the per-variant $btn-*-focus-shadow-rgb Sass variables are gone without a renamed successor, and so is --cui-btn-focus-ring: the button draws the ring from the global width and the theme’s colour, so a colour button rings in its colour and a neutral one gets the global ring. To restyle it, set --cui-focus-ring-color (or --cui-theme-focus-ring) on the button. Two visible consequences: a colour button’s ring uses the focus-ring opacity (fainter than the old 50% mix), and the neutral outline/ghost buttons ring in the global colour instead of a grey.

Focus rings are keyboard-only on activation controls

Breaking

Controls you click to act — checkboxes, radios, switches, both range sliders, pagination links, stepper buttons, the search, close, sidebar and navbar togglers — draw their focus ring on :focus-visible, so clicking no longer flashes it and keyboard focus still shows it, joining the buttons which already worked this way. Fields you type into — text inputs, the OTP cells, the date-time sections — keep :focus: a field shows its ring on mouse focus by design.

Two suppressed rings are restored: the header toggler and the carousel controls declared outline: 0 with no replacement, so keyboard focus was invisible on both.

A validation state is a theme colour, not a palette of its own

Breaking

$form-validation-states carried a colour, a border colour, a focus ring and a pair of tooltip colours per state, and _root.scss emitted four tokens to feed them. A state names a theme colour now, and every value is a slot of it.

  $form-validation-states: (
    "valid": (
-     "color": var(--cui-form-valid-color),
-     "border-color": var(--cui-form-valid-border-color),
-     "tooltip-bg-color": var(--cui-success),
-     "icon": …,
-     …
+     "theme": "success",
    ),
  )
  • --cui-form-valid-color, -valid-border-color, -invalid-color and -invalid-border-color are removed, together with the Sass variables behind them ($form-valid-*, $form-invalid-*, $form-feedback-valid-color, $form-feedback-invalid-color). Retint a state by retinting its theme colour, or override the slot on the map.
  • Feedback text and the invalid border are one step darker, because both read the state colour’s fg slot instead of the raw brand colour. That is a fix, not a preference: measured against the body background, the old pair scored 3.50 (valid) and 3.69 (invalid), both under WCAG AA; the new pair scores 5.13 and 5.39. Text and border stay the same colour, as they were before.
  • $input-btn-focus-color-opacity is removed. The validation ring comes from the colour’s focus-ring slot, which reads --cui-focus-ring-opacity; set that instead.
  • $focus-ring is removed and $focus-ring-color reads the slot. The colour was rgba($primary, $focus-ring-opacity), resolved at build time, so neither retinting --cui-primary nor changing --cui-focus-ring-opacity at runtime moved the default ring. It is var(--cui-primary-focus-ring) now — the slot every .theme-* ring already mixed from. $focus-ring itself only restated --cui-focus-ring, which _root.scss composes, so the two variables that read it — $btn-close-focus-shadow and $sidebar-toggler-focus-shadow — are gone with it; the close button and the sidebar toggler draw their ring with the shared focus-ring() mixin.
  • Breaking --cui-sidebar-toggler-focus-shadow is removed. It fed a box-shadow on .sidebar-toggler:focus-visible next to an outline: 0, and its value was the --cui-focus-ring shorthand — <width> solid <color>, which box-shadow does not accept. The declaration was dropped as invalid and the toggler had no focus indicator at all. The ring is an outline now, like everywhere else; restyle it through --cui-focus-ring-width, --cui-focus-ring-color and --cui-focus-ring-offset.
  • Breaking add() and subtract() are removed. They wrapped calc() to keep a unitless 0 from making the expression invalid. v6 computes derived values in the browser, so the stylesheet reaches for calc() directly — 243 times, and not once for these two. Write the calc() yourself; guard a possible 0 with if($value == 0, …) where it can actually occur. divide() stays, the grid still uses it.
  • Breaking The theme colour tokens finish the rename to the slot names. --cui-{color}-text-emphasis is --cui-{color}-fg-emphasis and --cui-{color}-border-subtle is --cui-{color}-border. The theme slots (--cui-theme-fg-emphasis, --cui-theme-border) already carried these names; the colour tokens they read did not, so the two halves of the same system spelled the same idea differently. --cui-{color}-base is added alongside the bare --cui-{color} (itself gone since, the literal sits in --cui-{color}-500). The Sass keys move with them: "text-emphasis" → "fg-emphasis" and "border-subtle" → "border" in $theme-colors; $theme-colors-text and $theme-colors-border-subtle are gone with the other per-slot maps, and theme-color-refs("fg-emphasis") returns the slot’s token for every colour. No class names change — .text-primary-emphasis and .border-primary-subtle take their suffix from the utility definition, not from the token.
  • form-validation-state() takes $theme where it took $color, and the variables moved from scss/_config.scss into the partials that use them alongside $form-text-* and $form-feedback-*.
  • The validation tooltip is opaque and sits on the state’s -bg slot. It was color-mix(in srgb, var(--cui-danger) 90%, transparent), with the percentage frozen at build time from $form-feedback-tooltip-opacity. It reads var(--cui-danger-bg) (-success-bg, or your state’s -bg) now, the same slot the alert and the badge use, so it follows a theme override with them. $form-feedback-tooltip-opacity is removed; for a translucent tooltip set background-color on .invalid-tooltip / .valid-tooltip.

$component-active-color and $component-active-bg are removed

Breaking

One shared pair in _config.scss fed every active state in the library — the list group, pagination, the menu, nav pills, the dropdown, both range sliders and the checkable controls. Each component names its own default now and points it at the colour’s slots, so nothing is defined in a file that never renders it.

- $list-group-active-color:  $component-active-color;
- $list-group-active-bg:     $component-active-bg;
+ $list-group-active-color:  var(--cui-primary-contrast);
+ $list-group-active-bg:     var(--cui-primary);
  • Active text is fully opaque. $component-active-color was rgba($white, .87), a colour frozen at build time, so an active state recoloured to a light background kept near-white text. It reads the contrast slot now. Against the default primary that lifts the ratio from 4.71 to 5.65, and it matches .text-bg-primary, which already used the slot — the two whites in the library are one white again.
  • Override the colour, not the pair. Set $primary (or --cui-primary-500 at runtime) to move every active state at once, or the component’s own knob to move one. Per instance, .theme-* still wins where a component reads --cui-theme-*.
  • The switch has no SVG knob at all any more. The thumb is painted with background-color in every state, so $switch-thumb-bg and $switch-checked-thumb-bg are ordinary colour knobs; the v5 knob images and the variables that fed them ($form-switch-bg-image and friends) are gone.

A theme colour is one nested map entry

Breaking

$theme-colors carried one value per colour and three further families carried the rest, so a theme colour was defined in four places and adding one meant editing four blocks. It is a map of maps now — one name, eight slots.

  • Twenty-four Sass variables are removed: $primary-text-emphasis and its seven siblings, $primary-bg-subtle and its seven, $primary-border-subtle and its seven. Override a slot through the map instead:

    - $success-bg-subtle: #e8f5ec;
    + @use "@coreui/coreui/scss/coreui" with (
    +   $theme-colors: (
    +     "success": (
    +       "base": var(--cui-success-500),
    +       "bg-subtle": #e8f5ec,
    +       // …the remaining slots of the built-in entry
    +     ),
    +   )
    + );

    The map merges one level deep, so a built-in colour is replaced as a whole: copy its sub-map from scss/_theme.scss and change the slot. $theme-colors goes through defaults() like every other configuration map, so a new colour is one entry passed through with () and null removes one.

    Every --cui-{color}-* custom property keeps its name and its value, so a runtime override is unaffected.

  • $theme-colors is a map of maps, so @each $name, $value in $theme-colors now yields a map. Use map-get-nested($theme-colors, "base") where a flat name → value map is what you want, or theme-color-refs("base") for the :root tokens the utilities read.

  • New slots: fg (the state colour readable as text, one step lighter than text-emphasis), bg-muted (between bg-subtle and the solid colour) and focus-ring (the hue the .theme-* ring mixes from). They emit --cui-{color}-fg, -bg-muted and -focus-ring, and reach components as --cui-theme-fg, --cui-theme-bg-muted and --cui-theme-focus-ring; the emphasis text reaches them as --cui-theme-fg-emphasis.

The OTP input defaults to the shared frame

Twenty-five $form-otp-control-* variables were one-to-one aliases of the $input-* family — $form-otp-control-bg: $input-bg and so on down to the focus ring. They now default to the --cui-btn-input-* tokens on :root instead, so the cell picks up a runtime restyle of the text controls without going through a second Sass tier. The variables themselves stay, and so does every rendered value.

One consequence for Sass customization: setting $input-bg (or another $input-* knob) no longer reaches the OTP cell, because the cell now reads the root token rather than that variable. Restyle the whole family through the --cui-btn-input-* tokens, or set the matching $form-otp-control-* variable alongside it.

The OTP cell is a .form-control frame

.form-otp-control now declares the whole $form-control-tokens map and renders through the same form-control-frame() mixin as .form-control, so a change to the text-control frame reaches the cell without a second copy. The nineteen $form-otp-control-* variables that only re-pointed a --cui-btn-input-* token ($form-otp-control-bg, -font-size, -focus-ring and the rest) are gone with it; what remains is what the cell sets differently — $form-otp-control-width, $form-otp-control-padding-x and the size trios. Rendered values are unchanged. To restyle one cell set the --cui-control-* token on it; to restyle the family, the --cui-btn-input-* token on :root.

The input frame is complete on :root

The shared --cui-btn-input-* frame gains the slots every field was still deriving at Sass level: border-color, border-radius (with -sm/-lg), box-shadow, font-weight and the focus-color/-bg/-border-color trio. The $input-* knobs keep working — they read the tokens now — so a with() override reaches the whole family as before, and a runtime override of one root token restyles every text control at once.

  • Breaking $input-focus-border-color is a var() reference, not a Sass colour. It was tint-color($primary, 50%), frozen at build time — retint the palette and the focus border stayed on the old primary. It reads --cui-btn-input-focus-border-color now (the same 50% mix, computed in the browser). Sass colour functions on it no longer compile; override the token or the variable with a colour of your own instead.

  • Breaking $time-picker-roll-cell-selected-hover-bg is computed in the browser. It was a light-dark() pair of color.scale($primary, …), frozen at build time, so a retinted palette moved the selected cell but left its hover state on the old indigo. It darkens the primary the way the solid button darkens its own background — oklch(from var(--cui-primary) calc(l * .85) c h) — which also drops the separate dark-mode variant, since the formula works in both. Sass colour functions on it no longer compile.

The field transition is three tokens, not one shorthand

Breaking

--cui-control-transition held a whole shorthand (border-color .15s ease-in-out, box-shadow .15s ease-in-out), so retiming a field meant restating the property list. It splits into --cui-control-transition-property, -duration and -timing, and the rules set the three transition-* longhands. $input-transition splits the same way, and so do the components that derived from it — $chip-input-transition, $search-button-transition, $form-otp-control-transition, $accordion-transition, $btn-transition.

The floating label follows the same rule: --cui-form-floating-transition and $form-floating-transition are replaced by --cui-form-floating-transition-property, -duration, -timing (and the matching Sass variables). So does the range thumb — $form-range-thumb-transition becomes $form-range-thumb-transition-property, -duration and -timing.

--cui-btn-input-transition is gone with them. The frame carries the parts instead — --cui-btn-input-transition-duration, -timing, -property (what a plain field animates: border-color, box-shadow) and --cui-btn-input-action-transition-property (what a button or an action part animates: the same plus color and background-color). One declaration retimes every control on the page:

:root { --cui-btn-input-transition-duration: .3s; }

One easing changes with the split: the accordion animated border-radius on ease while everything else used ease-in-out; the longhands take a single easing, so it is ease-in-out throughout.

The split also removes a trap the shorthand carries: a trailing duration binds to the last property only, so adding color to the front of the old list would have left it at 0s. The rendered animation is unchanged.

$form-check-* is now named for what it configures

Breaking

The checkbox, the radio and the switch shared their defaults through variables named after .form-check — the v5 wrapper they no longer use. The names said “deprecated wrapper” while the role was “the three current controls”:

v5v6
$form-check-input-width$control-size
$form-check-input-focus-border$control-focus-border
$form-check-input-active-filter$control-active-filter
$form-check-input-disabled-opacity$control-disabled-opacity
$form-check-label-disabled-opacity$control-label-disabled-opacity
$form-check-input-checked-bg-color$control-checked-bg
$form-check-input-checked-border-color$control-checked-border-color
$form-check-input-indeterminate-bg-color$control-indeterminate-bg
$form-check-input-indeterminate-border-color$control-indeterminate-border-color

All nine sit in _root.scss and are emitted as --cui-control-* on :root, so the three controls can also be retuned together without recompiling:

:root { --cui-control-disabled-opacity: .3; }

$form-check-input-bg, -border, -border-radius, -checked-color and $form-check-radio-border-radius are gone without a replacement: they were one control’s value reached through another control’s name. Set $check-*, $radio-* or $switch-* — the variables of the control you mean.

The five that shape the deprecated .form-check wrapper keep their names and move into forms/_form-check.scss, so they leave the build with it.

The class names are unchanged. .form-check, .form-check-input and .form-check-label still work exactly as before.

The $input-* variables are gone; configure the frame once

Breaking

$input-padding-y, $input-color, $input-bg, $input-border-radius and the 30 others were aliases: each one held nothing but var(--cui-btn-input-…), the token the frame already declares on :root. Components read them in some places and wrote the token out in others, so a Sass override reached part of the library and not the rest — $input-color: lime recoloured the file-input button and the input group addon but not the input’s own text.

There is one place to configure the frame now. $input-btn-* in _root.scss holds the values, and every component reads the --cui-btn-input-* token it is emitted as:

// before
@use "@coreui/coreui-pro" with ($input-padding-y: 1rem);

// after — Sass
@use "@coreui/coreui-pro" with ($input-btn-padding-y: 1rem);
/* after — runtime, no recompile */
:root { --cui-btn-input-padding-y: 1rem; }

$input-height-inner, -half and -quarter are geometry rather than aliases, so they follow the validation mixin they serve into scss/mixins/_form-validation.scss; $input-height-border had one reader and is written where it is used.

The control variables moved out of the shared config

Breaking

$input-* described one thing — the frame every field component draws — but sat in _config.scss next to the breakpoints and the spacing scale. It lives in scss/forms/_form-control.scss now, with .form-control itself.

  • Nothing changes for CSS. The compiled stylesheet is identical; the --cui-control-* and --cui-btn-input-* tokens are untouched, so runtime customisation is unaffected.
  • A Sass override needs the new path when you @use the partial directly. Overriding through @use "@coreui/coreui-pro" with (...) is unchanged.
  • $input-btn-* stays in the config: those are the values the button and the field agree on, and buttons/_button.scss reads them.
  • $input-height, $input-height-sm and $input-height-lg are removed. They existed to be baked into --cui-btn-input-min-height, which now computes from --cui-btn-input-line-height, -padding-y and -border-width in the browser. Set those tokens, or --cui-btn-input-min-height itself, instead of recompiling.

Shadows are computed at runtime

Breaking

A shadow’s alpha is now base * strength * opacity, computed on the element rather than resolved when the stylesheet is built. That is what makes a shadow tintable and lets dark mode deepen every shadow at once.

  • $box-shadow, $box-shadow-sm, $box-shadow-lg and $box-shadow-inset are replaced by the $shadows map. The --cui-box-shadow* custom properties are unchanged in name and in rendered value — they are generated from the map — so runtime overrides keep working. Only the Sass override point moved:

    - $box-shadow-sm: 0 .125rem .25rem rgba($black, .1);
    + $shadows: map.merge($shadows, (sm: 0 .125rem .25rem rgba($black, .1)));

    A literal value like that opts the entry out of the runtime knobs, which is the point of overriding it.

  • New: $shadow-color, $shadow-strength, $shadow-strength-dark and the --cui-shadow-color / --cui-shadow-strength tokens they emit. Setting the colour once at the root recolours every shadow in the page, components included.

  • New utilities: .shadow-{color} for the theme colours plus current/black/white, and .shadow-opacity-{25|50|75|100}. They write --cui-shadow-tint and --cui-shadow-opacity, both registered inherits: false, so a tinted element does not recolour the shadows nested inside it.

  • Dark mode raises --cui-shadow-strength to 2.5 instead of carrying a second set of shadow values. The same alpha reads as weaker against a dark surface, which is why the light-mode values looked almost absent there.

Theme gradients removed

Breaking
  • .bg-{color}-gradient and .text-bg-{color}-gradient are gone, along with the $theme-gradients / $theme-gradients-dark maps that generated them, the per-colour $primary-gradient … $dark-gradient variables (and their -dark twins), and the --cui-{color}-start / --cui-{color}-stop custom properties they read. These were CoreUI-only additions with no Bootstrap counterpart. Replace a removed class with the base colour plus the generic gradient — class="bg-primary bg-gradient" — or write the two-stop gradient yourself if you need the exact 45° ramp.
  • .bg-gradient itself stays, as do $gradient, --cui-gradient, $enable-gradients and the gradient-bg() mixin — that is the gradient system Bootstrap has, and components keep using it.

Cascade layers

  • All library CSS now lives in cascade layers, declared as @layer colors, config, root, reboot, layout, content, forms, components, custom, helpers, utilities;. The palette ships in colors, so your own @layer colors block overrides a token like --cui-blue-500 by source order alone. Two of the layers are empty and reserved for you: config, below root, for a configuration sheet the library should read but never fight, and custom, between components and helpers.

  • Unlayered CSS wins over layered CSS regardless of specificity, so your existing stylesheets now override the library more easily, not less. No action needed — selectors that worked before keep working, and low-specificity overrides that used to lose on specificity now win.

  • Utilities no longer carry !important. Breaking The $enable-important-utilities switch is gone with the suffix, because the layer order above already puts every utility past every component — the suffix was doing nothing our own stylesheet needed. That takes 556 bytes gzip (19 kB uncompressed) off the build.

    The one case it did cover is your own unlayered CSS: since unlayered styles beat every layer, a plain .card-title { text-align: left } in a stylesheet loaded after ours now wins over .text-center. Two ways out, and the choice is yours per rule rather than ours for all of them — move that CSS into @layer custom so utilities win.

  • If your build concatenates CoreUI with other CSS, make sure the layer declaration (emitted at the top of the bundle) is not stripped by a minifier that predates @layer.

Design-token maps

  • Every component that emits CSS custom properties now builds them from a $<component>-tokens Sass map merged over your overrides with the new defaults() function. Override the whole surface at once:

    @use "@coreui/coreui-pro/scss/coreui" with (
      $alert-tokens: (
        --cui-alert-bg: hotpink,
        --cui-alert-margin-bottom: null, // null removes the token entirely
      )
    );
  • Breaking 941 Sass variables are gone, across 64 partials. Every scalar whose only job was carrying a value into a token map now sits in the map, so a value has one home instead of two. Set the token instead of the variable — $popover-tokens: (--cui-popover-bg: hotpink) at build time, or --cui-popover-bg on the element at runtime. Moving a value out of a variable and into the map does not change what compiles out, so this reaches you only if your build set one of the removed variables, and Sass then names it: This variable was not declared with !default in the @used module. Each component page says which variables it still has: 31 lost their ### Sass variables section entirely.

  • Breaking scss/_root.scss declares no variables of its own any more — 62 went, leaving only $root-tokens for overriding the map. The page-wide knobs that lived there are now the tokens they always emitted: $input-btn-* → --cui-btn-input-*, $control-* → --cui-control-*, $focus-ring-* → --cui-focus-ring-*, $backdrop-* → --cui-backdrop-*, $font-family-sans-serif → --cui-font-sans-serif, $line-height-base → --cui-body-line-height. Override them through $root-tokens when you compile, or on :root at runtime:

    @use "@coreui/coreui-pro/scss/coreui" with (
      $root-tokens: (
        --cui-body-line-height: 1.6,
        --cui-btn-input-padding-y: .5rem,
      )
    );
  • Breaking Sixteen more page-wide knobs are gone. scss/_theme.scss declares no variables at all now: the four that set how far a hover or active shade travels are the tokens they fed, --cui-theme-hover-lightness, --cui-theme-hover-chroma, --cui-theme-active-lightness and --cui-theme-active-chroma, and the theme page documents them. From scss/_config.scss, $gradient, $border-style, $font-family-base, $headings-color and $link-color go the same way, to --cui-gradient, --cui-border-style, --cui-body-font-family, --cui-heading-color and --cui-link-color. All seven $font-weight-* scalars are folded into $font-weights, which stays the way to add or drop a weight. dt and the headings read --cui-font-weight-bold and --cui-font-weight-medium instead of a number frozen at build time, so retuning a weight on :root now moves them too. $headings-line-height goes to --cui-heading-line-height, $font-size-base to --cui-body-font-size, and $font-size-root goes entirely: it gated a font-size on the root element at build time and defaulted to null, so it emitted nothing. Set font-size on html in your own CSS to scale rem instead. $border-width and $border-color go the same way, to --cui-border-width and --cui-border-color; the width scale and the .border-{key} classes read the token, not the variable. $blue and $pink are finally gone from the source, which is what the palette entry above already said.

  • Breaking The reboot declares no variables of its own, and 89 more scalars go with the same rule across the content partials, the legacy layer, the form label, text and file blocks, the callout, card, list group, dropdown and navbar dark blocks and the password strength meter. Each was a value on its way into a token map. <th> keeps the browser’s own weight rather than a knob that emitted nothing, <caption> reads --cui-spacer-3 and --cui-fg-3, and code reads --cui-font-mono.

  • mark carries its own tokens. --cui-mark-padding-y/-padding-x, --cui-mark-color and --cui-mark-bg were declared on :root, where nothing else read them. They sit on mark, .mark now, in a $reboot-mark-tokens map you can override like any other component surface. Setting them on :root no longer reaches the element; set them on the element, or on the map when you compile.

  • Breaking 76 more component scalars are gone, the ones whose value reached exactly one custom property and nothing else: the chip and chip input size variants, the nav enclosed set, the OTP input sizes, the badge and rating sizes, the modal widths and margins, the toast surface, the form control group icon sizes and the colour picker width. Each is the token it fed.

  • Breaking The artwork variables are gone too. The accordion chevron, the breadcrumb divider, the four carousel controls, the close button glyph, the check and radio marks, the rating star, the select indicator and the navbar, header and sidebar togglers were each a Sass variable feeding one custom property. Set the property: --cui-accordion-header-icon, --cui-breadcrumb-divider, --cui-carousel-control-prev-icon, --cui-btn-close-icon, --cui-check-mark-checked, --cui-radio-mark, --cui-rating-item-icon, --cui-control-select-bg-img, --cui-navbar-toggler-icon-bg, --cui-header-toggler-icon-bg, --cui-sidebar-toggler-icon.

  • Breaking The validation icon inside the field is gone, and with it $enable-validation-icons, the two glyphs and their colours, the geometry that made room for them and the select’s second background layer. A valid or invalid field still says so through its border, its focus ring and its feedback text. The glyph was a background-image on the input itself with the colour baked into the SVG, so it could never follow a token: an input renders no pseudo-element to hang a mask on, and masking the input erases its own background. It comes back when the field has a wrapper to draw it in.

  • Breaking $radius and $chip-gap are gone, to --cui-radius and --cui-chip-gap. The radius scale still multiplies one value, but that value is the token: set --cui-radius and every stop follows at runtime.

  • $font-weight-semibold and $grid-gutter-width are gone too. Both were kept only because the shared docs engine read them; it reads --cui-font-weight-semibold and $grid-gutter-x now. The weight lives in $font-weights, and $grid-gutter-x is the gutter.

  • What stays in scss/_config.scss stays for a reason. $spacer, the $enable-* switches, the breakpoint, container, spacer and border-width maps and the contrast helpers feed maps, mixins, loops and build-time decisions rather than one token, so they stay Sass. $theme-colors stays for the same reason, in scss/_theme.scss. So does every component variable a rule or a mixin still reads.

  • Component Sass variables moved from _config.scss into their component partials. @use ... with() is unaffected — the entry point forwards every module — but if you imported scss/_config.scss directly for its definitions, component blocks are no longer there.

  • Two silent duplicates were dropped along the way: $nav-link-height was declared twice (navbar and header blocks — the header copy is gone) and the calendar emitted --cui-calendar-cell-week-number-color twice.

  • Time picker: the v1-only tokens are gone. The rewritten shell draws its adornments as inline SVG on currentColor and styles them through .form-control-action / .form-control-cleaner, so nothing read them any more — --cui-time-picker-cleaner-* (5 tokens), --cui-time-picker-indicator-icon, --cui-time-picker-indicator-icon-size, --cui-time-picker-gap and --cui-time-picker-placeholder-color, together with their Sass variables ($time-picker-cleaner-icon-size-sm/-lg were also declared twice). --cui-time-picker-indicator-icon-color stays and now carries the validation color as well. The validation state itself moved off the root element: v1 recoloured the frame from .time-picker.is-invalid with !important overrides, v2 reads it from the field inside the group, so $time-picker-invalid-border-color / $time-picker-valid-border-color and the matching indicator-icon variables are gone — theme the state through the standard $form-invalid-* / $form-valid-* variables instead. Section placeholders come from the field’s own hourPlaceholder / minutePlaceholder / secondPlaceholder options, not a colour token.

  • $input-padding-x-lg was removed: .form-control-lg reads --cui-btn-input-lg-padding-x from the shared button/input token system, and the time picker’s dead gap-lg was the variable’s last consumer. $input-padding-x, $input-padding-x-sm and $input-padding-y-* are unaffected.

Theme tokens

  • New --cui-theme-* indirection plus generated .theme-primary … .theme-inverse classes: a themed container sets --cui-theme-fg, --cui-theme-bg-subtle, --cui-theme-border, and token-driven components (alert, buttons) read them. Contextual classes like .alert-success are now implemented through these tokens — same rendering, new mechanics.
  • --cui-theme-focus-ring joins the set. It carries the state colour mixed at $focus-ring-opacity — a colour, like every other slot; the width stays global. Every component draws its ring as var(--cui-focus-ring-width) solid var(--cui-theme-focus-ring, var(--cui-focus-ring-color)), so a themed ancestor recolours the ring and the global colour applies everywhere else.
  • --cui-{color}-contrast custom properties are new root tokens. Previously only buttons computed their own contrast colour; the root tokens now carry it, written in the contrast slot. .text-bg-* reads the token instead of calling color-contrast().

--cui-mobile-breakpoint removed

Breaking

The token carried the name of the sidebar’s breakpoint (lg), not a length, so it could not be used in a media query or in calc(), and nothing in the library read it. Read --cui-breakpoint-lg for the value.

It also sat one letter away from a mechanism that does work and looks similar. The sidebar tells its plugin about the mobile layout with --cui-is-mobile, which is declared inside the media query, so it is "true" below the breakpoint and empty above — the plugin only tests whether it is set. The removed token was declared on :root unconditionally, so the same Boolean(getPropertyValue(…)) test against it answered “mobile” at every width.

The Sass variable $mobile-breakpoint is unchanged; the sidebar’s own media queries are built on it.

Colour scales, and _variables-dark.scss removed

Breaking

Dark mode no longer has a second set of Sass variables. Every colour is declared once, as a native light-dark() pair, and [data#{$data-infix}theme="dark"] only declares color-scheme: dark.

  • scss/_variables-dark.scss is gone. All 59 $*-dark variables it held are gone with it; do not @import "variables-dark" any more. The values live inline in the light variable:

    - $body-color:      rgba(shift-color($gray-base, +26%), .95);
    - $body-color-dark: rgba($white, .87); // in _variables-dark.scss
    + $body-color:      light-dark(rgba(shift-color($gray-base, +26%), .95), rgba($white, .87));

    One exception remains, because a colour baked into an SVG cannot read light-dark(): the select chevron and the switch knob keep a dark counterpart next to their component ($form-select-indicator-dark, $form-switch-bg-image-dark).

  • Every palette colour gets a 025–975 scale of tokens in :root, mixed at runtime from its 500 stop: --cui-primary-025 … --cui-primary-975, for each key of $colors. Knobs: $color-tints, $color-shades, $color-mix-space (oklch), $tint-color, $shade-color. Override --cui-primary-500 at runtime and the whole scale retints.

  • Breaking The palette is the theme colours. $colors holds primary, success, info, warning, danger and gray; $blue, $indigo, $purple, $pink, $red, $orange, $yellow, $green, $teal, $cyan and every --cui-{hue} and --cui-{hue}-{stop} token are gone. Nothing read them: no utility was generated from them and the theme never pointed at them. $secondary and $inverse are gone too — they read gray stops, not a colour of their own. Each $theme-colors entry points base and bg at its palette’s 500 stop (primary at var(--cui-primary-500)) and the other slots at that scale; secondary reads --cui-gray-200, inverse --cui-gray-900, and their bg is the light-dark() pair that used to be their base. No palette token is bare: the literal sits in the -500 stop, so --cui-gray-500 is what --cui-gray was.

    - :root { --cui-primary-base: #6f42c1; }
    + :root { --cui-primary-500: #6f42c1; }
    
    - .hint { color: var(--cui-teal-600); }
    + .hint { color: var(--cui-success-600); }

    A new theme colour is a colour in $colors plus an entry in $theme-colors that points at it; see adding a theme colour.

  • Breaking The seven $theme-colors-{slot} maps are removed ($theme-colors-fg, $theme-colors-fg-emphasis, $theme-colors-bg-subtle, $theme-colors-bg-muted, $theme-colors-border, $theme-colors-contrast, $theme-colors-focus-ring). They were flat copies of one slot each; theme-color-refs("bg-subtle") returns the same shape as token references, which is what every utility reads.

  • Breaking The palette is written in oklch() and mixes in oklch. The base colours are unchanged — every hex converts to the same sRGB pixel — but the 100–900 steps mixed from them shift, because oklch holds chroma where lab drained it (measured: 5.9 per RGB channel on average, 28 at most). $black is #000 now rather than a near-black with a blue cast: oklch interpolates hue, so mixing a scale towards a tinted black rotated every shade towards blue.

  • Breaking The gray ramp is generated like every other colour. $gray-025 … $gray-975 were thirteen hand-picked values; they are now one $gray base run through the same generator, and gray is an entry of $colors. The Sass variables stay, derived from the base, so $gray-600 keeps working — but the ramp is set by $gray plus $color-tints / $color-shades, not by overriding a stop.

    $color-tints and $color-shades gained 025, 050, 950 and 975, so every colour now carries the full thirteen stops. The curve is 94 / 90 / 80 / 60 / 40 / 20 % of white for 025 … 400 and 16 / 32 / 48 / 64 / 70 / 76 % of black for 600 … 975, and $gray is oklch(60% 0.03948 265.02) — the v5 hue and chroma, a cool gray, at the lightness the curve is tuned for. The light band is far more spread out than the v5 ramp it replaces: against white, gray-100 reads 1.27:1 (was 1.10), gray-200 1.63 (was 1.21), gray-300 2.14 (was 1.34), gray-400 2.87 (was 1.49); gray-600 and below are within a step of where they were. Everything that reads a light stop moves with it — the neutral surfaces bg-1 … bg-4, border-color, theme-secondary, every -bg-subtle and -border slot — which is what makes a plain badge or a secondary button visible on a white page again.

  • -bg-subtle, -border-subtle and -text-emphasis are pairs of those stops rather than hand-picked hexes:

    $primary-bg-subtle:     light-dark(var(--cui-primary-100), var(--cui-primary-900));
    $primary-border-subtle: light-dark(var(--cui-primary-300), var(--cui-primary-600));
    $primary-text-emphasis: light-dark(var(--cui-primary-800), var(--cui-primary-200));

    This fixes a real dark-mode bug. Only light and dark had genuine dark variants before; for the six chromatic colours $primary-bg-subtle-dark was an alias of the light value, so .alert-success, .list-group-item-info and .table-warning rendered a pale light-mode surface in dark mode. They now darken. Light mode shifts slightly too: subtle backgrounds are a shade lighter (an 80% tint rather than ~70%) and emphasis text a shade darker; borders are unchanged for practical purposes. Emphasis-on-subtle contrast improves in every colour — the weakest pair goes from 6.03:1 to 7.57:1.

  • The dark grey palette is gone: $gray-100-dark … $gray-900-dark and the $grays-dark map. Every one of them was defined as exactly its light counterpart, so they generated nothing — read $gray-* directly.

  • $theme-colors-dark is gone, along with $theme-colors-fg-emphasis-dark, $theme-colors-bg-subtle-dark, $theme-colors-border-dark and $theme-colors-contrast-dark, and the eight $primary-dark … $dark-dark variables behind them. Theme colours themselves no longer change between colour modes — the old dark variants were a 10% desaturation, which upstream does not do either. Consequence: the eight [data#{$data-infix}theme="dark"] .text-bg-* overrides are gone.

  • The --cui-{name}-dark root tokens are gone (--cui-body-color-dark, --cui-body-bg-dark, --cui-emphasis-color-dark, --cui-secondary-color-dark, --cui-secondary-bg-dark, --cui-tertiary-color-dark, --cui-tertiary-bg-dark). They existed so a component could pin the dark palette while the page stayed light; declare color-scheme: dark instead and the light-dark() pairs follow — which is exactly what .sidebar-dark now does, having dropped its eight token overrides.

  • $link-shade-percentage no longer drives $link-hover-color, which is now light-dark(var(--cui-primary-600), var(--cui-primary-400)). The variable still exists and still drives the .link-*-* hover helpers.

  • Two dead table knobs were removed: $table-th-font-weight and $table-caption-color. Reboot owns those declarations, so use $th-font-weight and $caption-color.

  • Setting $enable-dark-mode: false still yields a light-only page: :root declares color-scheme: light and nothing ever declares dark, so every light-dark() pair resolves to its light side.

The neutral surfaces are a numbered ladder

.bg-body-secondary, .bg-body-tertiary, .text-body-secondary and .text-body-tertiary are deprecated. The replacements are ordinal: .bg-1 … .bg-4 for surfaces and .fg-body, .fg-1 … .fg-4 for the text hierarchy, with two new rungs (.bg-1, .bg-2 on new gray-025 / gray-050 stops) between the body and the old surfaces. The deprecated names render identically and are removed in v7.

The adjectives ran backwards for backgrounds — “tertiary” sat closer to the page than “secondary” — because for text, where the names came from, “stronger” means more visible, while for a surface it means further from the page. Numbers say how far from the body a rung sits and read the same on both sides.

The deprecated surface names follow the two-rung step of the rest of the library: .bg-body-secondary renders --cui-bg-3 and .bg-body-tertiary --cui-bg-2 (gray-100 / gray-050 in the light scheme, the v5 stops one rung apart), .text-body-secondary stays --cui-fg-2 and .text-body-tertiary --cui-fg-3 — tertiary text was the lighter of the two in v5 and still is.

Two consequences for the tokens:

  • Deprecated The surface tokens move out of the theme-color namespace. --cui-bg-body, --cui-bg-1 … --cui-bg-4 and --cui-fg-body, --cui-fg-1 … --cui-fg-4 are canonical; --cui-body-bg, --cui-body-color, --cui-secondary-color (--cui-fg-2), --cui-tertiary-color (--cui-fg-3) and --cui-tertiary-bg (--cui-bg-2) remain as aliases with identical values, and go away in v7 — at which point the --cui-secondary-* prefix belongs to the secondary theme color alone. One alias is already gone: Breaking --cui-secondary-bg is the secondary theme color’s action-background slot now (see the entry below), not a surface alias. Markup that read it for the surface gets a similar but lighter gray — read --cui-bg-4 instead. This name is exactly why the ladder moved out of the theme-color namespace: a page surface sat next to --cui-secondary-bg-subtle (a theme-color slot) with no way to tell which system a token belonged to.
  • Breaking The dark side of the ladder is palette stops, and the page got darker. The grays gain --cui-gray-950 (#15181f) and --cui-gray-975 (#0e1115), and every dark rung is a stop instead of a color-mix() between gray-800 and gray-900: the body sits on gray-975 (was gray-900), bg-1 on gray-950, bg-2 on gray-900, bg-3 on gray-800 and bg-4 on gray-700. Dark mode reads darker overall and surfaces have more room to step away from the page. --cui-border-color-translucent is one expression for both schemes, color-mix(in oklch, var(--cui-fg-body) 15%, transparent), instead of two rgba() values computed in Sass; the color-translucent() function is gone. Override the $bg-* knobs or the --cui-bg-* tokens to keep the previous palette.
  • Breaking The text ladder is solid grays, and components use all of its rungs. --cui-fg-body through --cui-fg-4 were alpha blends of one color (rgba(…, .95) down to .25), which made the two deepest rungs illegible and left every component crowded onto fg-body/fg-2. Each rung is a palette stop now — fg-body on gray-900/050, fg-1 on 800/200, fg-2 on 700/300, fg-3 on 600/500, fg-4 on 500/600 — so contrast is the same on any surface and can be audited once. Components spread across the rungs: secondary text (breadcrumb links, toast headers, captions, blockquote footers) reads fg-3 instead of fg-2, disabled states read fg-3/fg-4 and stay legible, dividers read fg-4, and the strongest text (tables, active nav links, the tooltip surface) reads fg-body instead of --cui-emphasis-color. Nav links are neutral (fg-2, darkening through fg-1 to fg-body when active) instead of link-colored — a .theme-* class still recolors them. $gray-base fed the alpha blends and is removed. Override the $fg-* knobs or the --cui-fg-* tokens to keep the previous rendering.
  • .text-body and .text-body-emphasis are unaffected — they are not ordinal, any more than .text-primary is.
  • Breaking The Sass knobs follow the ladder. The $theme-fgs and $theme-bgs maps ("body", "1" … "4") replace $body-color, $body-bg, $body-secondary-color, $body-secondary-bg, $body-tertiary-color and $body-tertiary-bg; every entry holds one light-dark() expression, so $body-bg-dark and $body-tertiary-bg-dark are gone too — the translucent tints that needed their Sass colors read the grays inline.

light and dark are replaced by inverse

Breaking

light and dark were theme colours whose names described a colour scheme, and they behaved like it: .theme-dark sat next to data-coreui-theme="dark" meaning something else entirely, and neither followed the reader’s scheme — a .btn-dark stayed near-black on a near-black page. There are seven theme colours now, and the two neutrals swap ends of the gray scale between schemes.

  • inverse replaces dark. It resolves to gray-900 in light mode and gray-100 in dark, so .btn-inverse, .text-bg-inverse and .table-inverse stand against the page in both.
  • secondary takes over what light did. It is a light neutral now (gray-100 in light mode, gray-600 in dark) rather than the mid gray-blue #6b7785, so .btn-secondary and .bg-secondary change appearance.
  • Every -light and -dark class is gone. Rename -dark to -inverse and -light to -secondary across alert, bg, border (and its per-side variants), btn, btn-ghost, btn-outline, btn-subtle, callout, chip, focus-ring, underline, list-group-item, shadow, table, text, text-bg and theme, plus the -subtle and -emphasis families. The colour-scheme opt-ins keep their names: .navbar-dark, .dropdown-menu-dark, .carousel-dark and .sidebar-dark are unaffected, as is .fw-light.
  • A surface is not a theme colour. Where .bg-light was a neutral panel, reach for .bg-4 or .bg-3; where .bg-dark was a fixed dark backdrop, set data-coreui-theme="dark" on the container and let the surface classes follow.
  • --cui-theme-base is now --cui-theme-bg. The contextual token that components read for a fill was named after the map slot rather than the job; the slot is still base, and --cui-{color} is unchanged.
  • Colour utilities read the slot that stays legible. .text-* points at --cui-{color}-fg instead of the raw colour, and .btn-outline-* and .btn-ghost-* take their text from it too. For the five chromatic colours this is a shade darker in light mode and a shade lighter in dark; for the neutrals it is the difference between readable and gray-on-gray. .btn-outline-* also takes its border from the border slot, so the outline is a subtle line rather than a full-strength one. .bg-* and .border-* keep reading the colour itself.
  • $light and $dark are removed; inverse is new. Neither secondary nor inverse is a hue of its own: they read --cui-gray-200 and --cui-gray-900, and there is no Sass colour behind them to pass to color-contrast() or the tint/shade functions — read $gray-* for that.
  • $utilities-colors is removed. It was a flat name → colour copy of the map’s base slot, and no utility reads the raw colour for every family any more. Call map-get-nested($theme-colors, "base") where you need one, or theme-color-refs("fg") for the slot a text utility uses.
  • $theme-lightness-overrides is now $theme-state-colors and carries ready values rather than lightness multipliers. A single multiplier cannot serve a colour that is dark in one scheme and light in the other, so the two neutrals step along the gray scale instead.

Component icons render through CSS masks

Breaking

The accordion chevron and the carousel arrows were background-image SVGs with the colour written inside the markup, which is why each needed a dark-mode rule — the accordion swapped in a second SVG, the carousel inverted the first with a filter. They are now painted through a CSS mask, so the colour lives on background-color as an ordinary token and follows the root colours by itself. Upstream v6 does the same, with a mask-icon() mixin.

  • The accordion’s icon is one shape recoloured per state, not two images. --cui-accordion-header-active-icon no longer has to carry a second copy of the artwork: it defaults to --cui-accordion-header-icon, and the open state changes --cui-accordion-header-active-icon-color instead. Set it only when the open state is a genuinely different shape — a minus against a plus. New tokens: --cui-accordion-header-icon-color and --cui-accordion-header-active-icon-color.
  • A custom icon is now a shape, not a coloured image. Whatever colour you put inside $accordion-header-icon or $carousel-control-prev-icon-bg / $carousel-control-next-icon-bg is ignored — only the alpha of the SVG is used. Set the colour through the token (--cui-accordion-header-icon-color, --cui-carousel-control-color).
  • $accordion-icon-color and $accordion-icon-active-color are currentcolor now, not Sass colours — Sass colour functions on them no longer work. The icon therefore takes the header’s colour in every state, so anything that recolours an open header (--cui-accordion-active-color, a .theme-{color} class) carries the icon with it. Both tokens stay, for the case where the icon is meant to differ from the text.
  • $carousel-control-icon-filter is removed, with its --cui-carousel-control-icon-filter custom property and its two dark companions ($carousel-control-icon-filter-dark, $carousel-dark-control-icon-filter), along with the four accordion dark variables ($accordion-icon-color-dark, $accordion-icon-active-color-dark, $accordion-button-icon-dark, $accordion-button-active-icon-dark). The accordion’s dark-mode block is gone entirely.
  • The carousel’s colours are tokens. --cui-carousel-control-color is new; .carousel-dark pins the dark side of each pair through $carousel-dark-control-color, $carousel-dark-indicator-active-bg and $carousel-dark-caption-color (previously $carousel-*-dark in _variables-dark.scss). RTL still swaps the two arrows, now through mask-image.
  • Visible in dark mode: the old dark accordion chevron was a filled glyph where the light one was stroked. Both modes now use the stroked shape — same colour, same size, slightly different drawing. Worth knowing if you diff screenshots.

Buttons

  • Breaking button-variant() takes a variant name, not a colour pair, and reads it from $button-variants. Each entry of that map names, per state, the theme token a button variable reads and the neutral value behind it; disabled repeats base. button-outline-variant() and button-ghost-variant() are removed (they are entries in the map now), and scss/mixins/_buttons.scss no longer exists — the mixin lives next to the map in scss/buttons/_button.scss.

  • Hover and active shades are computed in CSS with oklch(from var(--cui-primary) calc(l * …) c h) instead of precompiled shade-color()/tint-color(). Shades shift slightly (measured 8–23 per RGB channel). The win: a theme color you add yourself gets correct hover/active states without compiling Sass.

  • The eight shade/tint amount knobs are removed: $btn-hover-bg-shade-amount, $btn-hover-bg-tint-amount, $btn-hover-border-shade-amount, $btn-hover-border-tint-amount and the four $btn-active-* counterparts. They fed the Sass mixes that no longer happen.

  • Breaking The interaction shade belongs to the theme, not to the button. Every .theme-* class emits --cui-theme-hover and --cui-theme-active — its own colour, shifted in lightness — and the button variants read them, so a component that wants “this theme colour, one step darker” no longer has to recompute it. The knobs moved with them: $btn-hover-lightness, $btn-active-lightness and $btn-lightness-overrides are now $theme-hover-lightness, $theme-active-lightness and $theme-state-colors in scss/_theme.scss.

  • The interaction shade is a runtime knob, and it keeps its saturation. The multipliers are :root tokens — --cui-theme-hover-lightness, --cui-theme-hover-chroma, --cui-theme-active-lightness, --cui-theme-active-chroma — so the strength of every hover and active state is one override, on the page or on a subtree, where it used to be a Sass value baked into each theme class. The shade also multiplies chroma (1.1 on hover, 1.15 on active) where it used to keep it, so a pressed button no longer fades as it darkens; the lightness steps are unchanged.

  • The dark-mode button block is gone (~160 lines of CSS) — variants follow the root color tokens, which already switch in dark mode.

  • Breaking The $btn-* variables that only forwarded a shared value are removed: $btn-color, $btn-padding-y, $btn-padding-x, $btn-font-family, $btn-font-size, $btn-line-height, $btn-border-width, $btn-border-radius, $btn-disabled-opacity, $btn-transition-* and the -sm/-lg pairs. They pointed at --cui-btn-input-*, so a value assigned to them was overwritten by the token anyway. Configure the frame once on :root with the $input-btn-* variables, or set --cui-btn-* on the button.

  • Breaking $btn-font-weight is removed, and buttons take their weight from the shared frame. --cui-btn-font-weight reads --cui-btn-input-font-weight, the weight fields and input group addons use, which defaults to --cui-body-font-weight on :root. A page that sets a lighter body weight gets lighter buttons with it; .btn-link keeps the normal weight. Set --cui-btn-font-weight on .btn to change buttons alone, or --cui-btn-input-font-weight on :root to change buttons and fields together.

  • Breaking button-size() is removed. Sizes are a loop over $button-sizes, which points the button’s tokens at the matching --cui-btn-input-{size}-* set. Add a size by adding its tokens on :root and its name to the map.

  • New tokens on .btn: --cui-btn-gap, --cui-btn-min-height, --cui-btn-white-space and --cui-btn-transition-property / -duration / -timing. The transition was read straight from the shared --cui-btn-input-* tokens before, so it could not be retuned per button.

  • Breaking .btn-close draws its focus ring as an outline, like every other control; $btn-close-focus-shadow and --cui-btn-close-focus-shadow are removed. A box-shadow ring was clipped by the overflow: hidden of the modal, toast or drawer the close button usually sits in. Its geometry is tokens now too: --cui-btn-close-width, -height, -padding-x, -padding-y.

  • .btn-primary, .btn-outline-*, .btn-ghost-* class APIs are unchanged.

  • Toggle buttons: .btn-check moves onto the <label>, with the input nested inside. A toggle button was two elements held together by an id/for pair; it is one element now, so nothing can fall out of sync:

    - <input type="checkbox" class="btn-check" id="t" autocomplete="off">
    - <label class="btn btn-primary" for="t">Toggle</label>
    + <label class="btn btn-primary btn-check">
    +   <input type="checkbox" autocomplete="off">
    +   Toggle
    + </label>

    The states read the nested input with :has() instead of sibling selectors, and a .btn-group stops having to reason about a hidden child that is not a button. The old spelling keeps working — the label carries .btn in the new one, so the two select themselves apart — and renders identically; it is deprecated in v6 and removed in v7. One behavior is deliberately carried over: a disabled toggle is dimmed rather than repainted, so a disabled checked toggle still reads as checked.

Utilities API: one property map replaces css-var, css-variable-name and local-vars

Breaking

Three keys said one thing between them — a utility may write custom properties, real properties, or both — and property now says it alone. Give it a map: a null entry receives the utility’s value, everything else is written as declared.

  "background-color": (
-   property: background-color,
-   local-vars: ("bg-opacity": 1),
+   property: (
+     --#{$prefix}bg: null,
+     --#{$prefix}bg-opacity: 1,
+     background-color: color-mix(in srgb, var(--#{$prefix}bg) calc(var(--#{$prefix}bg-opacity) * 100%), transparent),
+   ),
    class: bg,
    values: $utilities-bg-colors
  )

  "bg-opacity": (
-   css-var: true,
+   property: (--#{$prefix}bg-opacity: null),
    class: bg-opacity,
    values: (25: .25, 50: .5, 75: .75)
  )

The class API does not change — .bg-primary and .bg-opacity-50 still exist and still compose. What changes is where the translucency expression lives:

  • $utilities-text-colors, $utilities-bg-colors, $utilities-border-colors and $utilities-links-underline now carry plain token references (var(--cui-primary), var(--cui-primary-fg)) instead of a color-mix() expression per entry. The expression is written once, in the utility’s property map. If you built a map of your own by calling translucent-css-var(), call color-var-ref() instead — same arguments, without the wrapper; translucent-css-var() is removed, along with the unused map-loop() and color-contrast-variables().
  • .text-body-secondary now responds to .text-opacity-*. It rendered the same colour before and renders the same colour now; it simply went through the plain value and so ignored the opacity knob every other colour utility honoured.
  • Every custom property a utility assigns is registered @property { inherits: false }, which now covers --cui-bg, --cui-text, --cui-border and --cui-underline as well — so a colour set on a container cannot leak into a descendant that only carries an opacity class.

at-property: false still opts a utility out of that registration, for the one case that writes an inheriting global token (.focus-ring-*).

Utilities API: child selectors and attribute selectors (new)

Two keys join the $utilities API, ported from Bootstrap’s v6 branch:

  • child-selector makes a utility style the children of the element it is applied to instead of the element itself, wrapped in :where() so it adds no specificity. This is what the new spacing and divider utilities are built on.
  • selector (class, attr-starts, attr-includes) writes an attribute selector rather than a class, so one rule can serve a whole family of classes.

They bring four new utilities with them, all responsive and direction-aware:

classwhat it does
.space-x-*, .space-y-*margin between children — gap without needing a flex or grid container
.divide-x, .divide-y (+ -0)a border between children, none on the outer edges

Nothing existing changes; these are additions. See the utilities API, spacing and borders.

Utilities API: theme variants and the rtl key removed

Breaking
  • The dark: theme-variant utilities are gone (40 classes: .dark\:bg-*, .dark\:text-*). They were generated for the colour utilities only, duplicating every value under a [data-coreui-theme=dark] selector, and were never documented. The palette already resolves per theme through light-dark(), so .bg-body, .text-body-secondary and friends follow the colour mode on their own. The dark-mode key of a $utilities entry is no longer read.
  • The rtl key of a $utilities entry is no longer read. It did two jobs: rtl: false wrapped the output in RTLCSS /* rtl:remove */ directives, and rtl: true accepted ("ltr": …, "rtl": …) value maps that emitted a [dir="rtl"] counterpart. Direction is expressed with logical properties now (the position utilities are inset-inline-start/-end), so the flag has nothing left to do. One behaviour change comes with it: the RTL behaviour of .translate-middle / .translate-middle-x is written as a plain :dir(rtl) rule instead of a generator option — transform is not direction-aware, so centring on a logical inset still needs the flip, but it is the only utility that does and it no longer costs the whole API a key.

RFS removed

Breaking
  • The RFS engine is gone — no vendor/rfs, no font-size() / rfs() mixins, no $enable-rfs, no rfs option in the utilities API, and none of the $rfs-* knobs ($rfs-base-value, $rfs-breakpoint, $rfs-factor, $rfs-mode, $rfs-unit, $rfs-two-dimensional, $rfs-class, …). Fluid type is written as literal clamp() values in the $font-sizes map, $display-font-sizes and $legend-font-size; rendering is unchanged at every viewport width.
  • Headings no longer derive from $font-size-base — a clamp() cannot be multiplied. Override the stop in $font-sizes directly.
  • A temporary scss/vendor/_rfs.scss shim (pass-through font-size()/rfs() mixins) exists only for the docs engine and will be removed — do not depend on it.

One styling surface for all text controls

  • Shared root knobs: :root now carries the control frame (--cui-btn-input-padding-y/-x, -font-family/-size, -line-height, -border-width, -transition, -color, -bg, -disabled-opacity, plus -sm-*/-lg-* size sets) and the selection state (--cui-control-checked-bg/-border-color, --cui-control-indeterminate-bg/-border-color). Restyle these once and every control follows: inputs, selects, checks, switches, OTP, chip input, autocomplete, multi select, and the pickers — including the checkbox-style option indicators inside autocomplete and multi select.
  • Every text control consumes per-element --cui-control-* tokens (padding, font, colors, border, radius, shadows, focus and disabled sets) that default to those knobs. Override a single control on its element; override everything at :root. Breaking The component-namespace custom properties that duplicated this frame are renamed: --cui-autocomplete-padding-y, --cui-date-picker-font-size, --cui-time-picker-bg, --cui-form-multi-select-border-color, --cui-chip-input-padding-x and the rest of their frame sets are now --cui-control-* on the same elements. Runtime overrides written against the old names must be updated. Component-specific tokens (indicators, dropdowns, cells, cleaner buttons) keep their names. Three v5 names that survived as undeclared fallbacks are gone too: --cui-input-placeholder-color (set --cui-control-placeholder-color), --cui-input-focus-border-color in the combobox (set --cui-btn-input-focus-border-color) and --cui-table-caption-color (the caption reads $caption-color).
  • Component Sass variables survive with new defaults: $date-picker-bg, $autocomplete-padding-y and the rest now default to the shared knob (var(--cui-btn-input-bg) …). Untouched, the control follows the shared surface; overridden through with(), it detaches just that control — exactly what reaching for a component knob means.
  • The select’s indicator geometry is computed at runtime. $form-select-indicator-padding and $form-select-feedback-icon-padding-end are now calc() expressions over var(--cui-control-padding-x) instead of Sass products, and $form-select-bg-position carries the var() inside it (the mirrored keyword comes from the :dir(rtl) rule in the same sheet). Breaking Sass arithmetic on these variables in your own stylesheets no longer works — they are CSS calc strings now, not numbers. Override them with your own calc() or a plain length instead.
  • Form tooltips reuse the tooltip’s runtime tokens. Add the .tooltip class to a feedback tooltip (class="tooltip invalid-tooltip") and it follows tooltip theming — --cui-tooltip-padding-*, -font-size, -border-radius — the upstream v6 contract. Bare .invalid-tooltip markup keeps rendering exactly as before through built-in fallbacks. Breaking The Sass-level derivation is gone: $form-feedback-tooltip-* no longer default to $tooltip-*, so overriding $tooltip-padding-y alone no longer moves feedback tooltips at compile time — use the runtime link (add the class) or set $form-feedback-tooltip-* explicitly. The tooltip variables moved to scss/_tooltip.scss, the feedback set to scss/mixins/_form-validation.scss.
  • Validation composes through the tokens: .is-invalid / .is-valid set --cui-control-border-color, -focus-border-color and -focus-ring instead of restating declarations, so state styling follows any control automatically.

The spacing scale is computed from --cui-spacer in the browser

  • Breaking --cui-spacer-1 … -12 are calc() multiples of --cui-spacer, and the spacing utilities read the stops. $spacers used to multiply $spacer when the stylesheet was built and every .m-*, .p-*, .gap-*, .space-* and .g-* class carried the literal length, so the only way to rescale spacing was to recompile. Now :root declares --cui-spacer ($spacer, 1rem) and the stops as calc(var(--cui-spacer) * n), the utilities write var(--cui-spacer-n), the negative margins and the .w-* sizes calc(var(--cui-spacer) * n), and the gutters read the same stops. :root { --cui-spacer: .75rem } rescales all of it at run time, and overriding one stop re-spaces every utility on it. The scale moved next to the breakpoints in scss/layout/_tokens.scss, so the grid, utilities and reboot bundles each declare it; a $root-tokens override of a --cui-spacer-* key no longer reaches it — override the custom property instead.

The border-width scale is computed from --cui-border-width in the browser

  • Breaking --cui-border-width-1 … -5 and -keyline are calc() multiples of --cui-border-width, and the .border-* width classes read them. $border-widths shipped literal pixel values and every .border-2, .border-top-3, .border-start-5 class carried the length, so changing --cui-border-width re-weighted the components and left the utilities where they were. Now :root declares the stops as multiples of --cui-border-width (1px): -keyline is half of it, -1 the base, -2 … -5 its multiples, and the width classes write var(--cui-border-width-n). :root { --cui-border-width: 2px } re-weights utilities and components together; overriding one stop re-weights every class on it. --cui-border-width-keyline keeps its name and moves into the scale.

Spacing utilities write logical shorthands

.mx-*, .my-*, .px-* and .py-* (and their negative-margin variants) emit margin-inline, margin-block, padding-inline and padding-block instead of two physical longhands each. Rendering is identical in both directions; the only visible difference is in DevTools, where one shorthand replaces the pair, and in a stylesheet of your own that overrode one of the longhands after ours — a shorthand still yields to a later longhand.

Opacity utilities no longer cascade to descendants

Breaking

The opacity utilities compose through custom properties — .text-opacity-50 sets --cui-text-opacity and the colour utilities read it. Custom properties inherit, so the value leaked into every descendant. The colour utilities that restate their own opacity (.text-primary declares --cui-text-opacity: 1) were immune; the ones reading it through a var(…, 1) fallback were not.

Reachable consequences in v5: .bg-opacity-25 on a container faded every nested .text-bg-*, and .link-opacity-50 faded every nested link — including bare <a> elements, which read --cui-link-opacity too.

Five names are now registered with @property as inherits: false, so each utility is scoped to the element it is applied to: --cui-text-opacity, --cui-bg-opacity, --cui-border-opacity, --cui-link-opacity, --cui-underline-opacity. Upstream v6 does the same.

  • If you relied on the leak — one .link-opacity-* on a wrapper fading a whole block of links — put the utility on each element, or set the custom property yourself in your own CSS (a hand-written declaration still inherits; only the registration changes how utilities behave).
  • .focus-ring-* is deliberately not registered: it writes --cui-focus-ring-color, which is a global :root token, and components read it without a fallback. A custom utility that writes a :root token name must opt out the same way, with at-property: false in its $utilities entry.
  • Compiled output is otherwise byte-identical — the five @property blocks are the whole diff.

The CSS Grid ships, and its responsive classes are container queries

Breaking

$enable-cssgrid is true by default, so .grid and the .g-col-* / .g-start-* classes are in the compiled CSS instead of waiting behind a Sass flag. Set it to false if you do not want them.

Every .grid is now a query container (container-type: inline-size), so .g-col-md-6 responds to the width of the grid rather than to the viewport — a grid inside a narrow column switches layout on that column’s width. This is what upstream v6 does, and it is the point of having a second grid at all.

container-type implies layout containment, so a .grid is the containing block for absolutely and fixed-positioned descendants: position: fixed inside a grid is positioned against the grid, not the viewport. Our own floating panels render through a portal and are unaffected; your own fixed markup has to live outside.

The CSS Grid declares its variables, and nested grids no longer inherit

Breaking

.grid read --cui-rows, --cui-columns and --cui-gap through a var() fallback and declared none of them. Two consequences: the variables did not appear in dev tools, and — because a custom property that is never declared simply inherits — a nested .grid picked up the column count of the grid around it.

All three are declared on .grid now, so every grid starts from twelve columns, one row and the standard gap regardless of its surroundings. A component built on .grid keeps its own layout wherever it is dropped.

If you relied on a nested grid inheriting, set the variable on that grid too:

<div class="grid" style="--cui-columns: 3;">
  <div>
    <div class="grid" style="--cui-columns: 3;">...</div>
  </div>
</div>

The cq-* grid is gone

Breaking

v5.3.0 shipped a second, experimental grid — .cq-container, .cq-row, .cq-col-*, .cq-offset-*, .cq-g-* — behind $enable-container-queries, with its own $cq-grid-breakpoints map that duplicated $grid-breakpoints value for value. It never shipped in the default build, and maintaining two grids to express one idea is what the CSS Grid above now does properly.

The flag, the map, the classes and the make-c-grid-columns() mixin are all removed. If you configured $enable-container-queries, drop it: @use ... with () errors on a variable the module no longer declares. If you had opted in: .cq-row/.cq-col-* map onto the CSS Grid (.grid + .g-col-*), which is container-based by default, or onto the flexbox grid (.row/.col-*) if you want viewport breakpoints. To keep the container behaviour on markup of your own, mark the element with .contains-inline and write the query with the container-breakpoint-* mixins.

Breakpoint mixins emit range queries

Breaking

media-breakpoint-* now emits (width >= 768px) / (width < 768px) instead of (min-width: 768px) / (max-width: 767.98px). The .02px subtraction existed to work around min-/max- prefixes on fractional viewports; range syntax makes it unnecessary.

breakpoint-max() returns the raw breakpoint value now (768px, not 767.98px). If you call it yourself inside a max-width: query, subtract the .02px there or move that query to range syntax too — otherwise the two breakpoints overlap on exactly 768px.

The container-breakpoint-* mixins use the same syntax, take an optional container name as their second argument, and default to $grid-breakpoints:

@include container-breakpoint-up(md) { … }              // nearest container
@include container-breakpoint-up(md, sidebar) { … }      // @container sidebar (width >= 768px)

set-container($type, $name) is new and marks an element as a query container.

New: container-type utilities

.contains-inline and .contains-size set container-type, so you can make any element a query container from markup. See Container queries.

Sass file layout follows Bootstrap v6

Breaking

Upstream’s directories mirror the cascade layers, so partials now live in the directory named after the layer they emit into. The old paths no longer resolve — point your @use rules at the paths on the right.

WasIs
scss/_variables.scssscss/_config.scss
scss/_grid.scss, scss/_containers.scssscss/layout/
scss/mixins/_breakpoints.scssscss/layout/_breakpoints.scss
scss/_reboot.scss, scss/_type.scss, scss/_images.scss, scss/_tables.scssscss/content/
scss/_buttons.scssscss/buttons/_button.scss
scss/_button-group.scss, scss/_close.scss, scss/_loading-button.scss, scss/_search-button.scssscss/buttons/
scss/_spinners.scssscss/_spinner.scss
scss/_placeholders.scssscss/_placeholder.scss
scss/_mixins.scss, scss/_helpers.scss, scss/_forms.scssscss/mixins/index.scss, scss/helpers/index.scss, scss/forms/index.scss
scss/forms/_form-check.scssscss/forms/_check.scss
scss/forms/_form-otp.scssscss/forms/_otp-input.scss

The list rules from _type.scss moved to content/_lists.scss and the blockquote rules to content/_blockquote.scss, matching upstream’s filenames.

The three aggregators are unaffected: Sass resolves @use "mixins" to mixins/index.scss on its own.

@import is no longer supported

Breaking

The 87 *.import.scss files that let @import reach our modules are gone, so an @import of anything under scss/ no longer resolves. Load the library with @use instead:

// Was
@import "../node_modules/@coreui/coreui/scss/coreui";

// Is
@use "@coreui/coreui/scss/coreui";

Configuration moves with it. Where @import let you set variables by declaring them before the import, @use takes them in a with () clause, and the rule has to be the first thing in the file:

@use "@coreui/coreui/scss/coreui" with (
  $body-bg: #000,
  $body-color: #111
);

Sass deprecated @import and removes it in Dart Sass 3.0.0, so this is where it was heading regardless. The Sass migration guide covers the rules; sass-migrator module automates most of a conversion.

One consequence worth calling out: a module is configured once, at load time. If your change has to read a default before changing it — merging into $utilities, for instance — it goes in a partial you load before the library. The utilities API page shows both patterns.

What we deliberately did not copy. Upstream renamed three components and their classes — .modal, .offcanvas, .dropdown, .dropdown-menu and .dropdown-item are gone from Bootstrap v6, replaced by .dialog, .drawer, .menu and .menu-item. Those are three of the most-used components in any Bootstrap app, so _modal.scss, _offcanvas.scss and _dropdown.scss keep their names and their classes here.

The colour system has its own modules

Breaking

_config.scss no longer defines a single colour. The palette — the base colours, the grays and their maps — lives in scss/_colors.scss; the theme colour system — the $theme-colors map, the runtime colour-scale knobs and the $theme-colors-* slot maps — lives in scss/_theme.scss. scss/_maps.scss is dissolved: the $utilities-* maps and $negative-spacers sit in scss/_utilities.scss next to the $utilities definitions they feed, and $gutters in scss/_config.scss with the rest of the grid settings.

  • Nothing changes for CSS — the compiled stylesheet is byte-identical, and every custom property keeps its name.
  • Overriding through @use "@coreui/coreui-pro/scss/coreui" with (...) is unchanged — the entry point forwards all of these modules.
  • scss/maps is gone and scss/variables is not part of the public surface any more. Load .../scss/config, .../scss/colors, .../scss/theme or .../scss/utilities directly, depending on what you need.

The type scale is one named map

One scale now carries every font size the library renders, each stop with the line height that belongs to it — upstream’s mechanic, with our values, so nothing moves on screen:

$font-sizes: (
  "xs":  ("font-size": .75rem,        "line-height": 1.5),
  ...
  "4xl": ("font-size": clamp(1.375rem, 1.375rem + 1.5vw, 2.5rem), "line-height": 1.2)
);
  • $font-sizes changed shape — it was (1: $h1-font-size, … 6: $h6-font-size) and is now keyed xs, sm, md, lg, xl, 2xl, 3xl, 4xl, 5xl, 6xl, with a nested map per stop. The two top stops sit above the headings — display sizes ($font-size-5xl/-6xl, up to 3rem and 3.5rem) continuing the same fluid progression, numerically level with the former .display-5 and .display-4. Breaking If you overrode $font-sizes, move your values into the new shape. The numeric keys live on in $font-sizes-legacy, which is derived from the named scale.

  • :root gets --cui-font-size-* and --cui-line-height-* per stop, and h1–h6 read the tokens instead of the Sass variables. Restyling a stop at runtime now moves every heading and utility that uses it. $h1-font-size … $h6-font-size, $font-size-sm/-lg, $font-size-5xl/-6xl and $zindex-* are gone: each was read only by the map it fed, so the maps ($font-sizes, $zindex-levels) hold the values and are the override point, in the defaults() shape — set one key and the rest stays. $font-weight-* remain and feed $font-weights the same way.

  • New: .fs-{stop} alongside .fs-1 … .fs-6, which keep working and render identically — .fs-1 is .fs-4xl, down to .fs-6, which is .fs-md.

  • New: .text-{stop}, which sets font-size and line-height together.

  • Breaking .display-1 … .display-6 and .lead are removed, with $display-font-sizes, $display-font-family, $display-font-style, $display-font-weight, $display-line-height, $lead-font-size and $lead-font-weight. They were a fixed size and weight next to a scale that already carries those sizes: .display-4 is .fs-6xl, .display-5 is .fs-5xl, and .lead is .fs-lg; add .fw-light for the weight the old classes carried and .lh-1 for the display line height. The three larger display sizes have no stop of their own — set font-size yourself or extend $font-sizes.

We did not follow upstream’s other typography changes. Bootstrap v6 also deletes .fs-1 … .fs-6 and retunes the sizes — h1–h3 grow up to 10px on small screens while h4–h6 shrink at every width. Those classes stay, at their current sizes.

The focus ring is an outline

Breaking

Focus rings were drawn with box-shadow and are now drawn with outline, following upstream v6. The practical win is that an outline is not clipped by an ancestor’s overflow — the ring stays visible inside scroll containers, where a box-shadow ring was cut off.

  • The per-component ring custom properties are gone, not renamed. Every --cui-*-focus-box-shadow — button, pagination, accordion button, check, radio, switch, calendar cell and nav button, stepper indicator, range-slider thumb, search button, combobox, chip — had exactly one value, and the ring is now drawn from the global width and the theme’s colour wherever it appears. Restyle it with --cui-focus-ring-color / --cui-focus-ring-width for the whole page, or set either on the component itself for one instance.

    - --cui-btn-focus-box-shadow: 0 0 0 .25rem rgba(88, 86, 214, .5);
    + --cui-focus-ring-color: rgba(88, 86, 214, .5);

    A runtime override written against an old name is now inert — there is no fallback path, because the value goes to a different property.

    Form fields draw the same ring: the text input, select, control group, date-time field, OTP cell and List Box option read the theme’s focus ring or --cui-focus-ring-color, and a validation state draws its own ring in its colour. The ring has no per-field colour token.

  • The Sass variables follow: $focus-ring-box-shadow → $focus-ring, and the per-component ones ($btn-focus-box-shadow, $pagination-focus-ring, $combobox-focus-ring, $form-check-input-focus-ring, …) are removed.

  • $focus-ring-blur is gone, along with --cui-focus-ring-x, -y and -blur. An outline has no blur or offset vector. New: $focus-ring-offset / --cui-focus-ring-offset (defaults to 0), which pushes the ring away from the element, and --cui-focus-ring, the composite shorthand the mixin reads.

  • focus-ring() changed signature to focus-ring($offset: false, $color: null). It no longer emits outline: 0. Every ring in the library is drawn through it; with no arguments it draws the themed ring, and $color swaps the hue for one rule — which is how the validation states ring in red and green.

  • --cui-focus-ring-offset actually applies. The mixin emits outline-offset wherever a ring is drawn, so one declaration moves every ring in or out. It was previously read by the .focus-ring helper alone, which made the token look supported while doing nothing on any component.

  • $pagination-focus-outline is gone — the outline is now the ring itself.

  • A component ring is a reference to the global one, not a copy of it. Every ring resolves as var(--cui-focus-ring-width) solid var(--cui-theme-focus-ring, var(--cui-focus-ring-color)), so setting --cui-focus-ring-color or --cui-focus-ring-width at the root reaches every control, and a .theme-{color} region tints the rings inside it. Previously each component baked the resolved value in at build time, which is why a runtime change to the global ring left the components behind.

    - --cui-pagination-focus-ring: .25rem solid rgba(88, 86, 214, .25);
    + /* nothing to declare — the pagination link rings from the global tokens */
  • $input-btn-focus-color no longer reaches the input and control rings. They read the global ring now; set $focus-ring-color (or --cui-focus-ring-color) instead. $input-btn-focus-width is unaffected — it still sizes the validation rings and the button.

Sizes and colours are unchanged, so the ring keeps its width ($focus-ring-width, .25rem) and its translucent theme colour. What changes is the property drawing it, which also means the ring no longer participates in a box-shadow transition.

Everything deprecated before v6 is removed

Breaking

Some of these carried a “to be removed in v6” note since v4.2. This is v6.

Sass mixins — all three were already unreachable, forwarded from nothing: alert-variant(), list-group-item-variant(), avatar(). The contextual classes they used to build are generated by a Sass loop over the theme tokens instead.

Sass variables — $variable-prefix (use $prefix), $border-radius, $border-radius-sm, $border-radius-lg, $border-radius-xl, $border-radius-xxl, $border-radius-2xl and $border-radius-pill (use the $radii map and $radius), $text-muted, $hr-bg-color, $hr-height, $alert-bg-scale, $alert-border-scale, $alert-color-scale, $list-group-item-bg-scale, $list-group-item-color-scale, $tooltip-arrow-color, $popover-arrow-color, $popover-arrow-outer-color, $accordion-button-focus-border-color, $dropdown-header-padding (use -padding-y / -padding-x), $nested-kbd-font-weight, $btn-close-white-filter and $btn-close-filter-dark, $high-emphasis, $medium-emphasis, $disabled, $high-emphasis-inverse, $medium-emphasis-inverse, $disabled-inverse, $dropdown-inner-border-radius (the first and last menu item now take that radius from --cui-dropdown-border-radius, -border-width and -padding-y directly), $calendar-cell-range-hover-bg (the range hover state draws dashed borders, not a background), $body-text-align and $input-btn-font-family (both defaulted to null, so the tokens they fed were never emitted and every font-family / text-align that read them was invalid; buttons and controls inherit the body font, set font-family on the element to change it).

Thirteen of those were already dead — nothing read them, so removing them changed no output at all.

CSS custom properties — --cui-high-emphasis, --cui-medium-emphasis, --cui-disabled, and the three -inverse counterparts, plus --cui-border-radius-sm, -lg, -xl, -xxl, -2xl, -pill and --cui-border-radius itself (use --cui-radius, --cui-radius-0 … -9 and --cui-radius-pill), --cui-dropdown-inner-border-radius (the edge-item radius is computed from the dropdown’s own tokens now) and --cui-calendar-cell-range-hover-bg (declared and never read), --cui-font-monospace (renamed --cui-font-mono), --cui-highlight-color and --cui-highlight-bg (renamed --cui-mark-color / --cui-mark-bg), --cui-btn-input-color (renamed --cui-btn-input-fg) and --cui-control-color (renamed --cui-control-fg, on .form-control and everything sharing its frame), --cui-body-text-align, --cui-btn-input-font-family, --cui-btn-font-family, --cui-control-font-family and --cui-search-button-font-family (never emitted, see the Sass variables above). Optional knobs that stay null by default — --cui-nav-link-font-size, --cui-nav-link-font-weight, --cui-modal-footer-bg, --cui-form-feedback-font-style, --cui-form-feedback-tooltip-line-height — are now read with a fallback (inherit / transparent), so the declaration is valid until you set them.

Classes — eleven CoreUI-only additions, plus the three text utilities Bootstrap deprecated in 5.0 and removes in 6:

RemovedUse instead
.text-high-emphasis, .text-medium-emphasis.text-body, .fg-2
.text-disabled.fg-3
.text-high-emphasis-inverse, .text-medium-emphasis-inverse, .text-disabled-inverse.text-white with an opacity utility
.text-muted.fg-2
.text-black-50, .text-white-50.fg-black.fg-50, .fg-white.fg-50

(The .dark: variants of the emphasis utilities go with them.) The $navbar-light-* Sass variables stay.

What we deliberately kept. .sidebar-brand-full, .sidebar-brand-narrow and .d-narrow-none stay, though they are CoreUI’s own. They are the brand pair every admin template ships in its sidebar header, and they are exact aliases of .d-sidebar-narrow-none / .d-sidebar-narrow — two extra selectors in a rule that already exists. Removing them would break the most recognisable piece of CoreUI markup, silently: both logos would render at once in the narrow state, with nothing to indicate why. Prefer the .d-sidebar-narrow* names in new markup.

Sass & CSS

  • The picker shell styling is a lean partial (_date-picker.scss, ~110 lines / 16 CSS-var tokens vs v1’s 369 lines / 79 Sass variables per picker): the shell styles only the dropdown panel, the input-group frame, the footer/ranges layout, and the indicator. The field is styled by the date input partial, the popup content by the calendar, projected footer/ranges content is userland (style your own buttons). Sass overrides targeting the v1 input-layer classes (.date-picker-input, .date-picker-input-wrapper, .date-picker-input-preview, .date-picker-cleaner, .date-picker-separator icon masks) will not apply to the v2 markup — those layers no longer exist. Overrides on the surviving classes (.date-picker-popup, .date-picker-footer, .date-picker-ranges) keep working — but the frame and the indicator now come from the shared primitive below.

  • The frame of every field component is .form-control-group. Autocomplete, chip input, multi select, password input and all four pickers each drew their own copy of it before; they now share one. What this changes for you:

    v5v6
    .date-picker-input-group, .time-picker-input-group, .autocomplete-input-group, .form-multi-select-input-group, the frame on .chip-input, the .form-password wrapper.form-control-group
    .date-picker-indicator, .time-picker-indicator, .autocomplete-indicator, .form-multi-select-indicator, .form-password-action.form-control-action
    .autocomplete-cleaner, .form-multi-select-cleaner.form-control-cleaner
    .autocomplete-inputplain .form-control — the group strips its frame, so the editable needs no class of its own
    .autocomplete-buttons, .form-multi-select-buttonsgone: the adornments are children of the group, which lays them out
    .form-multi-select-tag, .form-multi-select-tag-delete.chip, .chip-remove
    .date-picker-separator.form-control-icon
    .date-picker-sm/-lg, .time-picker-sm/-lg, .autocomplete-sm/-lg, .form-multi-select-sm/-lg.form-control-sm/-lg on the group (the components apply it from size themselves)
  • Multi Select renders every selection as a Chip. selectionType: 'tags' and 'chips' used to be the same idea built twice, the default 'tags' being a private copy of the chip without the roles chips carry. Both values are still accepted and now produce the same markup, so a selection is a chip everywhere in the library — style it through the --cui-chip-* properties. The $form-multi-select-tag-* variables are gone with it. 'chips' is the name and the default now; 'tags' renders the same and is deprecated until v7. The selection container .form-multi-select-selection-tags is .multi-select-selection-chips, with no alias.

  • Multi Select’s classes drop the form- prefix, so they carry the component’s name like .autocomplete, .chip-input and .date-picker. There is no alias for styling:

    v5v6
    .form-multi-select.multi-select
    .form-multi-select-selection, .form-multi-select-selection-tags.multi-select-selection, .multi-select-selection-chips
    .form-multi-select-search, .form-multi-select-placeholder.multi-select-search, .multi-select-placeholder
    --cui-form-multi-select-selection-tags-gap, -padding-y--cui-multi-select-selection-chips-gap, -padding-y

    The filled state is .multi-select-filled, the Sass partial is scss/forms/_multi-select.scss with $multi-select-tokens, and the component stylesheet is css/components/forms/multi-select.css.

  • Multi Select starts from data-coreui-multi-select only. A v5 <select class="form-multi-select"> was initialized on page load by its class; in v6 it stays a plain native select. Replace the class with the attribute, as every other field component is started:

    - <select class="form-multi-select" multiple>
    + <select data-coreui-multi-select multiple>
  • The per-family frame variables are gone. $autocomplete-font-size, $date-picker-border-color, $time-picker-focus-bg, $form-multi-select-padding-y and the rest of each family’s copy of the input look mirrored the shared frame value for value — and since the frame declares the --cui-control-* properties on itself, an override through them never reached it. Theme the frame once through the $input-* variables, or per instance through --cui-control-* on .form-control-group. Each family keeps only the variables for what is genuinely its own (placeholder colours, the pickers’ footers, the chip row).

    Markup you write by hand has to be updated: the password input wrapper (<div class="form-control-group"> with a .form-control-action button) and the chip input (<div class="form-control-group chip-input">). The components that build their own DOM do it for you.

    The adornment icons are inline SVG rendered by the component now, not CSS masks, so they take their colour from the button (currentColor) and follow it through hover, validation and disabled — the per-family icon, icon colour and icon size variables are gone with them. Every adornment across the library is the same mark at the same size.

    Sass overrides move with the classes. Because the group is the control frame, theming .form-control — or the --cui-control-* custom properties — now reaches all of these components at once, which is what the per-family variables were emulating. The families keep the variables for what they actually own (calendars, the time roll, listboxes, chips), and the ones that only aliased $input-* are gone.

  • The floating panel of every field component is the .popup primitive. The pickers’ dropdowns and the combobox listbox drew the same box three times; the generated panel now carries .popup (alongside its structural class), and the chrome lives in one place. Sass-wise, $date-picker-dropdown-*, $time-picker-dropdown-*, the chrome half of $combobox-popup-* and the per-family -zindex variables are replaced by $popup-zindex/-bg/-border-width/-border-color/-border-radius/-box-shadow and the --cui-popup-* custom properties — theme the popup once and every field component’s panel follows. The panel fades in and out, the way Menu already animated: @starting-style supplies the state to animate from and a discrete display transition keeps it laid out through the fade, tuned with --cui-popup-transition-duration / -timing. Its anchored placement is unchanged; a fullscreen mode for mobile is designed at this primitive and lands separately.

  • Validation and sizing ride the standard machinery. .is-invalid / .is-valid on the group behave exactly as on a .form-control, and the state also propagates from the control inside it. The per-family !important overrides that forced the invalid border in v5 are gone.

  • Markers are hidden with list-style-type: "", not list-style: none. Safari drops the list role — and VoiceOver stops announcing the item count — when a list is styled with list-style: none. The empty string hides the markers with the same visual result while keeping the list exposed to assistive technologies. Swept through nav, navbar, header, sidebar-nav, dropdown, breadcrumb and the list-unstyled mixin; your own list-style: none overrides still win, they just reintroduce the problem.

  • Transitions are emitted inside prefers-reduced-motion: no-preference. The transition() mixin used to emit a transition and then switch it off again under prefers-reduced-motion: reduce, so every animated rule shipped an override block. It now emits the transition inside a no-preference query instead, which drops those blocks. The trade-off: a browser that does not support the media feature gets no transition at all. Rules that force transition: none stay outside the query — they disable motion on purpose, so they have to reach every reader.

  • The sizing utilities spell the property out: .mw-100 → .max-w-100, .mh-100 → .max-h-100. The abbreviations were the only ones on that axis: the floor classes are .min-vw-100, .min-vh-100, .min-dvw-100, and the new .min-w-* / .min-h-* join them, so .mw sitting next to .min-w read as two conventions for one property. Both ends are now written the same way.

  • .link-offset-* is .underline-offset-*. Same three steps, same -hover variants — .link-offset-2-hover becomes .underline-offset-2-hover. The class sets text-underline-offset on any element, not just a link, and .underline-thickness-* next to it already carried the property-based name.

  • New utilities: .border-x / .border-y (and their -0 forms) set the border-inline / border-block shorthands, so a facing pair of borders takes one class instead of two; .min-w-{0|100} and .min-h-{0|100} set a floor — .min-w-0 is what releases the min-width: auto on a flex child so a .text-truncate sibling can actually truncate; .animation-{shake|pop} ship their keyframes with them, prefixed so they cannot be redefined by a bare shake or pop of your own, and stand down under prefers-reduced-motion: reduce; .color-scheme-{light|dark} sets CSS color-scheme, which is what the browser reads to paint form controls and scrollbars — it changes no colours of its own.

  • .link-underline-* is .underline-*. .link-underline-danger becomes .underline-danger, and the opacity steps become .underline-{n} (.link-underline-opacity-50-hover → .underline-50-hover). Like .underline-offset-*, the class sets a text-decoration-* property on any element rather than only on a link, and it now sits beside .underline-thickness-*, which already carried that name.

  • Breaking The colour utilities take upstream’s names, and opacity is a step of the same family. Text colour is .fg-{color} (.fg-emphasis-{color}, .fg-contrast-{color}, .fg-reset, plus the .fg-body / .fg-1 … .fg-4 ladder that already carried the name); the light surfaces are .bg-subtle-{color} and .bg-muted-{color}, the light border .border-subtle-{color}. Opacity is no longer a separate -opacity- family with its own token: .fg-{n}, .bg-{n}, .border-{n}, .link-{n} (.link-{n}-hover) and .underline-{n} (.underline-{n}-hover) run from 10 to 100 in steps of ten and mix the colour the companion class wrote into --cui-fg / --cui-bg / --cui-border / --cui-link-color / --cui-underline with transparent through color-mix(). .lh-md is the body line height (.lh-sm and .lh-lg read the type scale’s stops) and .shadow-opacity-* counts the same ten steps.

    Every v5 spelling still ships as a deprecated alias and renders the same, so existing markup is unaffected until v7: .text-{color}, .text-{color}-emphasis, .text-body, .text-black, .text-white, .text-reset, .text-body-secondary, .text-body-tertiary, .bg-{color}-subtle, .bg-{color}-muted, .border-{color}-subtle, .text-opacity-*, .bg-opacity-*, .border-opacity-*, .link-opacity-*(-hover), .underline-opacity-*(-hover), .lh-base, .shadow-opacity-25 and -75. The deprecated opacity steps keep their old values (25, 75); the new scale rounds them to 30 and 70.

    What does change: the --cui-text, --cui-text-opacity, --cui-bg-opacity, --cui-border-opacity, --cui-underline-opacity and --cui-link-opacity tokens are gone (--cui-text is --cui-fg), so an inline style="--cui-bg-opacity: .5" no longer fades a .bg-* — use .bg-50, or set --cui-bg to a translucent colour. Reboot’s a reads var(--cui-theme-fg, var(--cui-link-color)) directly instead of mixing the link colour through --cui-link-opacity on every link.

  • Breaking .rounded-* counts the stops of the radius scale. The numbers are the keys of $radii (--cui-radius-0 … -9) instead of the five v5 sizes, so every numbered class changes value and four new ones appear:

    classv5v6closest v6 class for the old value
    .rounded.375rem.5rem (--cui-radius-5).rounded-4
    .rounded-1.25rem.125rem.rounded-3
    .rounded-2.375rem.1875rem.rounded-4
    .rounded-3.5rem.25rem.rounded-5
    .rounded-41rem.375rem.rounded-9
    .rounded-52rem.5remnone (.rounded-9 is 1rem)
    .rounded-6 … .rounded-9—.625rem, .75rem, .875rem, 1rem—

    The same numbering applies to .rounded-size-* and the side classes (.rounded-top-*, .rounded-end-*, .rounded-bottom-*, .rounded-start-*); .rounded-0, .rounded-circle and .rounded-pill are unchanged. Each class writes var(--cui-radius-{n}) rather than the stop’s value, so a stop overridden on :root re-rounds the utilities along with the components. The side classes set logical corners (border-start-start-radius, …), so .rounded-top flips with the writing direction like .rounded-start already did.

  • Buttons and controls carry a line height per size. :root emits --cui-btn-input-xs-line-height (1.125), -sm-line-height (--cui-line-height-sm) and -lg-line-height (--cui-line-height-md), the size classes hand them to --cui-btn-line-height / --cui-control-line-height, and each size’s min-height is computed from its own line height. sm and lg keep the body value; the new .btn-xs is the only tighter size. --cui-border-width-keyline (.5px) is a root token now and .border-keyline reads it.

  • New .rounded-size-*. It writes the radius into --cui-rounded-size without setting border-radius itself, and the unprefixed side classes (.rounded-top, .rounded-end, .rounded-bottom, .rounded-start) read that token, so one size class drives whichever corners you round. With no size in scope they fall back to --cui-radius-5. A numbered .rounded-{n} writes the token as well, so .rounded-3 .rounded-top keeps the top on size 3; a side class with its own number (.rounded-top-3) sets that side directly.

  • The derived colour utilities compose with their opacity classes. .bg-*-subtle, .bg-*-muted, .border-*-subtle and .text-*-emphasis each wrote the property directly, so .bg-opacity-50 on a .bg-primary-subtle did nothing while the same class on .bg-primary worked. They now declare --cui-bg / --cui-border / --cui-text and resolve through the same color-mix() as the base families. Nothing changes at full opacity.