},
{
"path": "./dist/js/bootstrap.bundle.js",
- "maxSize": "81.0 kB"
+ "maxSize": "82.5 kB"
},
{
"path": "./dist/js/bootstrap.bundle.min.js",
- "maxSize": "52.25 kB"
+ "maxSize": "53.25 kB"
},
{
"path": "./dist/js/bootstrap.js",
- "maxSize": "52.25 kB"
+ "maxSize": "53.75 kB"
},
{
"path": "./dist/js/bootstrap.min.js",
- "maxSize": "30.25 kB"
+ "maxSize": "31.25 kB"
}
],
"ci": {
export { default as OtpInput } from './src/otp-input.js'
export { default as Chips } from './src/chips.js'
export { default as Popover } from './src/popover.js'
+export { default as Range } from './src/range.js'
export { default as ScrollSpy } from './src/scrollspy.js'
export { default as Tab } from './src/tab.js'
export { default as Toast } from './src/toast.js'
--- /dev/null
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap range.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import BaseComponent from './base-component.js'
+import EventHandler from './dom/event-handler.js'
+import SelectorEngine from './dom/selector-engine.js'
+
+/**
+ * Constants
+ */
+
+const NAME = 'range'
+const DATA_KEY = 'bs.range'
+const EVENT_KEY = `.${DATA_KEY}`
+const DATA_API_KEY = '.data-api'
+
+const EVENT_CHANGED = `changed${EVENT_KEY}`
+const EVENT_DOM_CONTENT_LOADED = `DOMContentLoaded${EVENT_KEY}${DATA_API_KEY}`
+
+// `input` is not in EventHandler's native-event list, so it can't be namespaced; bind it raw
+const EVENT_INPUT = 'input'
+const EVENT_CHANGE = 'change'
+
+const SELECTOR_RANGE = '.form-range'
+const SELECTOR_INPUT = '.form-range-input'
+
+const CLASS_NAME_BUBBLE = 'form-range-bubble'
+const CLASS_NAME_TICKS = 'form-range-ticks'
+const CLASS_NAME_TICK = 'form-range-tick'
+const CLASS_NAME_TICK_LABEL = 'form-range-tick-label'
+
+// Shipped (`--bs-`-prefixed) custom properties; the build prefixes the SCSS tokens, so the
+// plugin must write the prefixed names to interoperate with the rendered CSS.
+const PROPERTY_FILL = '--bs-range-fill'
+
+const Default = {
+ bubble: false, // Show a value bubble above the thumb
+ formatter: null // (value) => string, for the bubble and tick labels
+}
+
+const DefaultType = {
+ bubble: '(boolean|null)',
+ formatter: '(function|null)'
+}
+
+/**
+ * Class definition
+ */
+
+class Range extends BaseComponent {
+ constructor(element, config) {
+ super(element, config)
+
+ // BaseComponent bails (no `_element`) when the element can't be resolved
+ if (!this._element) {
+ return
+ }
+
+ this._input = SelectorEngine.findOne(SELECTOR_INPUT, this._element)
+
+ if (!this._input) {
+ return
+ }
+
+ this._bubble = null
+ this._bubbleText = null
+ this._ticks = null
+ this._updateHandler = () => this._update()
+
+ if (this._config.bubble) {
+ this._createBubble()
+ }
+
+ this._createTicks()
+ this._addEventListeners()
+ this._update()
+ }
+
+ // Getters
+ static get Default() {
+ return Default
+ }
+
+ static get DefaultType() {
+ return DefaultType
+ }
+
+ static get NAME() {
+ return NAME
+ }
+
+ // Public
+ update() {
+ this._update()
+ }
+
+ dispose() {
+ EventHandler.off(this._input, EVENT_INPUT, this._updateHandler)
+ EventHandler.off(this._input, EVENT_CHANGE, this._updateHandler)
+
+ this._bubble?.remove()
+ this._ticks?.remove()
+
+ super.dispose()
+ }
+
+ // Private
+ _configAfterMerge(config) {
+ // A bare `data-bs-bubble` attribute normalizes to `null`; treat it as enabled
+ if (config.bubble === null) {
+ config.bubble = true
+ }
+
+ return config
+ }
+
+ _addEventListeners() {
+ EventHandler.on(this._input, EVENT_INPUT, this._updateHandler)
+ EventHandler.on(this._input, EVENT_CHANGE, this._updateHandler)
+ }
+
+ _min() {
+ return this._input.min === '' ? 0 : Number.parseFloat(this._input.min)
+ }
+
+ _max() {
+ return this._input.max === '' ? 100 : Number.parseFloat(this._input.max)
+ }
+
+ _value() {
+ return Number.parseFloat(this._input.value)
+ }
+
+ _ratio() {
+ const span = this._max() - this._min()
+ return span > 0 ? (this._value() - this._min()) / span : 0
+ }
+
+ _update() {
+ // The fill ratio drives the track gradient and the bubble/tick positions, all in CSS
+ this._element.style.setProperty(PROPERTY_FILL, `${this._ratio()}`)
+
+ if (this._bubbleText) {
+ this._bubbleText.textContent = this._format(this._value())
+ }
+
+ EventHandler.trigger(this._input, EVENT_CHANGED, { value: this._value() })
+ }
+
+ _format(value) {
+ return typeof this._config.formatter === 'function' ? this._config.formatter(value) : String(value)
+ }
+
+ _createBubble() {
+ // Reuse the tooltip markup so we don't duplicate the pill and arrow styles
+ this._bubble = document.createElement('output')
+ this._bubble.className = `${CLASS_NAME_BUBBLE} tooltip bs-tooltip-top show`
+ this._bubble.setAttribute('aria-hidden', 'true')
+
+ // Match the Tooltip template's block-level markup: `.tooltip-inner` has no `display` rule,
+ // so an inline `<span>` would let its padding bleed outside the bubble and clip the arrow.
+ const arrow = document.createElement('div')
+ arrow.className = 'tooltip-arrow'
+ this._bubbleText = document.createElement('div')
+ this._bubbleText.className = 'tooltip-inner'
+ this._bubble.append(arrow, this._bubbleText)
+
+ this._input.insertAdjacentElement('afterend', this._bubble)
+ }
+
+ _createTicks() {
+ const listId = this._input.getAttribute('list')
+ const datalist = listId ? document.getElementById(listId) : null
+
+ if (!datalist) {
+ return
+ }
+
+ const min = this._min()
+ const span = this._max() - min || 1
+
+ const points = []
+ for (const option of SelectorEngine.find('option', datalist)) {
+ const value = Number.parseFloat(option.value)
+
+ if (!Number.isNaN(value)) {
+ // Clamp to [0, 1] so out-of-range options can't produce negative `fr` tracks
+ const ratio = Math.min(Math.max((value - min) / span, 0), 1)
+ points.push({ ratio, label: option.label })
+ }
+ }
+
+ if (points.length === 0) {
+ return
+ }
+
+ points.sort((a, b) => a.ratio - b.ratio)
+
+ this._ticks = document.createElement('div')
+ this._ticks.className = CLASS_NAME_TICKS
+ this._ticks.setAttribute('aria-hidden', 'true')
+
+ // Columns are the gaps between 0, each tick, and 1, so every tick lands on a grid line
+ const stops = [0, ...points.map(point => point.ratio), 1]
+ this._ticks.style.gridTemplateColumns = stops.slice(1).map((stop, index) => `${stop - stops[index]}fr`).join(' ')
+
+ for (const [index, point] of points.entries()) {
+ const tick = document.createElement('span')
+ tick.className = CLASS_NAME_TICK
+ tick.style.gridColumnStart = `${index + 2}`
+
+ if (point.label) {
+ const label = document.createElement('span')
+ label.className = CLASS_NAME_TICK_LABEL
+ label.textContent = point.label
+ tick.append(label)
+ }
+
+ this._ticks.append(tick)
+ }
+
+ this._element.append(this._ticks)
+ }
+}
+
+/**
+ * Data API implementation
+ */
+
+EventHandler.on(document, EVENT_DOM_CONTENT_LOADED, () => {
+ for (const element of SelectorEngine.find(SELECTOR_RANGE)) {
+ Range.getOrCreateInstance(element)
+ }
+})
+
+export default Range
--- /dev/null
+import Range from '../../src/range.js'
+import { clearFixture, createEvent, getFixture } from '../helpers/fixture.js'
+
+describe('Range', () => {
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ })
+
+ const getRangeHtml = (wrapperAttributes = '', inputAttributes = '') => {
+ return `
+ <div class="form-range" ${wrapperAttributes}>
+ <input type="range" class="form-range-input" min="0" max="100" value="50" ${inputAttributes}>
+ </div>
+ `
+ }
+
+ describe('VERSION', () => {
+ it('should return plugin version', () => {
+ expect(Range.VERSION).toEqual(jasmine.any(String))
+ })
+ })
+
+ describe('DATA_KEY', () => {
+ it('should return plugin data key', () => {
+ expect(Range.DATA_KEY).toEqual('bs.range')
+ })
+ })
+
+ describe('Default', () => {
+ it('should return default config', () => {
+ expect(Range.Default).toEqual(jasmine.any(Object))
+ expect(Range.Default.bubble).toBeFalse()
+ })
+ })
+
+ describe('DefaultType', () => {
+ it('should return default type config', () => {
+ expect(Range.DefaultType).toEqual(jasmine.any(Object))
+ })
+ })
+
+ describe('constructor', () => {
+ it('should take care of element either passed as a CSS selector or DOM element', () => {
+ fixtureEl.innerHTML = getRangeHtml()
+
+ const rangeEl = fixtureEl.querySelector('.form-range')
+ const rangeBySelector = new Range('.form-range')
+ expect(rangeBySelector._element).toEqual(rangeEl)
+
+ rangeBySelector.dispose()
+
+ const rangeByElement = new Range(rangeEl)
+ expect(rangeByElement._element).toEqual(rangeEl)
+ })
+
+ it('should find the range input inside the wrapper', () => {
+ fixtureEl.innerHTML = getRangeHtml()
+
+ const rangeEl = fixtureEl.querySelector('.form-range')
+ const inputEl = fixtureEl.querySelector('.form-range-input')
+ const range = new Range(rangeEl)
+
+ expect(range._input).toEqual(inputEl)
+ })
+
+ it('should set the --bs-range-fill custom property on init', () => {
+ fixtureEl.innerHTML = getRangeHtml()
+
+ const rangeEl = fixtureEl.querySelector('.form-range')
+ new Range(rangeEl) // eslint-disable-line no-new
+
+ expect(rangeEl.style.getPropertyValue('--bs-range-fill')).toEqual('0.5')
+ })
+
+ it('should honor min/max when computing the fill ratio', () => {
+ fixtureEl.innerHTML = `
+ <div class="form-range">
+ <input type="range" class="form-range-input" min="0" max="200" value="50">
+ </div>
+ `
+
+ const rangeEl = fixtureEl.querySelector('.form-range')
+ new Range(rangeEl) // eslint-disable-line no-new
+
+ expect(rangeEl.style.getPropertyValue('--bs-range-fill')).toEqual('0.25')
+ })
+
+ it('should do nothing when there is no range input', () => {
+ fixtureEl.innerHTML = '<div class="form-range"></div>'
+
+ const rangeEl = fixtureEl.querySelector('.form-range')
+ const range = new Range(rangeEl)
+
+ expect(range._input).toBeNull()
+ })
+ })
+
+ describe('update', () => {
+ it('should update the --bs-range-fill custom property on input', () => {
+ fixtureEl.innerHTML = getRangeHtml()
+
+ const rangeEl = fixtureEl.querySelector('.form-range')
+ const inputEl = fixtureEl.querySelector('.form-range-input')
+ new Range(rangeEl) // eslint-disable-line no-new
+
+ inputEl.value = '75'
+ inputEl.dispatchEvent(createEvent('input'))
+
+ expect(rangeEl.style.getPropertyValue('--bs-range-fill')).toEqual('0.75')
+ })
+ })
+
+ describe('bubble', () => {
+ it('should create a tooltip-based value bubble when enabled', () => {
+ fixtureEl.innerHTML = getRangeHtml('data-bs-bubble')
+
+ const rangeEl = fixtureEl.querySelector('.form-range')
+ new Range(rangeEl) // eslint-disable-line no-new
+
+ const bubble = fixtureEl.querySelector('.form-range-bubble')
+ expect(bubble).not.toBeNull()
+ expect(bubble).toHaveClass('tooltip')
+ expect(bubble.querySelector('.tooltip-inner').textContent).toEqual('50')
+ })
+
+ it('should not create a bubble by default', () => {
+ fixtureEl.innerHTML = getRangeHtml()
+
+ const rangeEl = fixtureEl.querySelector('.form-range')
+ new Range(rangeEl) // eslint-disable-line no-new
+
+ expect(fixtureEl.querySelector('.form-range-bubble')).toBeNull()
+ })
+
+ it('should update the bubble text on input', () => {
+ fixtureEl.innerHTML = getRangeHtml('data-bs-bubble')
+
+ const rangeEl = fixtureEl.querySelector('.form-range')
+ const inputEl = fixtureEl.querySelector('.form-range-input')
+ new Range(rangeEl) // eslint-disable-line no-new
+
+ inputEl.value = '80'
+ inputEl.dispatchEvent(createEvent('input'))
+
+ expect(fixtureEl.querySelector('.tooltip-inner').textContent).toEqual('80')
+ })
+
+ it('should format the bubble text with a custom formatter', () => {
+ fixtureEl.innerHTML = getRangeHtml('data-bs-bubble')
+
+ const rangeEl = fixtureEl.querySelector('.form-range')
+ new Range(rangeEl, { formatter: value => `${value}%` }) // eslint-disable-line no-new
+
+ expect(fixtureEl.querySelector('.tooltip-inner').textContent).toEqual('50%')
+ })
+ })
+
+ describe('ticks', () => {
+ const getTicksHtml = () => {
+ return `
+ <div class="form-range">
+ <input type="range" class="form-range-input" min="0" max="100" value="50" list="ticksList">
+ </div>
+ <datalist id="ticksList">
+ <option value="0" label="Low"></option>
+ <option value="10"></option>
+ <option value="100" label="High"></option>
+ </datalist>
+ `
+ }
+
+ it('should render a tick for each datalist option', () => {
+ fixtureEl.innerHTML = getTicksHtml()
+
+ const rangeEl = fixtureEl.querySelector('.form-range')
+ new Range(rangeEl) // eslint-disable-line no-new
+
+ const ticks = fixtureEl.querySelectorAll('.form-range-tick')
+ expect(ticks.length).toEqual(3)
+ })
+
+ it('should place each tick on a grid line via grid-template-columns (handles uneven values)', () => {
+ fixtureEl.innerHTML = getTicksHtml()
+
+ const rangeEl = fixtureEl.querySelector('.form-range')
+ new Range(rangeEl) // eslint-disable-line no-new
+
+ // datalist values 0/10/100 -> gaps between 0, .1, 1, and 1
+ const ticksEl = fixtureEl.querySelector('.form-range-ticks')
+ expect(ticksEl.style.gridTemplateColumns).toEqual('0fr 0.1fr 0.9fr 0fr')
+
+ const ticks = fixtureEl.querySelectorAll('.form-range-tick')
+ expect(ticks[0].style.gridColumnStart).toEqual('2')
+ expect(ticks[1].style.gridColumnStart).toEqual('3')
+ expect(ticks[2].style.gridColumnStart).toEqual('4')
+ })
+
+ it('should render labels from the option label only', () => {
+ fixtureEl.innerHTML = getTicksHtml()
+
+ const rangeEl = fixtureEl.querySelector('.form-range')
+ new Range(rangeEl) // eslint-disable-line no-new
+
+ const labels = fixtureEl.querySelectorAll('.form-range-tick-label')
+ expect(labels.length).toEqual(2)
+ expect(labels[0].textContent).toEqual('Low')
+ expect(labels[1].textContent).toEqual('High')
+ })
+
+ it('should do nothing when there is no linked datalist', () => {
+ fixtureEl.innerHTML = getRangeHtml()
+
+ const rangeEl = fixtureEl.querySelector('.form-range')
+ const range = new Range(rangeEl)
+
+ expect(range._ticks).toBeNull()
+ expect(fixtureEl.querySelector('.form-range-ticks')).toBeNull()
+ })
+ })
+
+ describe('events', () => {
+ it('should trigger a changed event with the current value', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = getRangeHtml()
+
+ const rangeEl = fixtureEl.querySelector('.form-range')
+ const inputEl = fixtureEl.querySelector('.form-range-input')
+ new Range(rangeEl) // eslint-disable-line no-new
+
+ inputEl.addEventListener('changed.bs.range', event => {
+ expect(event.value).toEqual(90)
+ resolve()
+ })
+
+ inputEl.value = '90'
+ inputEl.dispatchEvent(createEvent('input'))
+ })
+ })
+ })
+
+ describe('dispose', () => {
+ it('should dispose the instance and remove decorations', () => {
+ fixtureEl.innerHTML = getRangeHtml('data-bs-bubble')
+
+ const rangeEl = fixtureEl.querySelector('.form-range')
+ const range = new Range(rangeEl)
+
+ expect(Range.getInstance(rangeEl)).not.toBeNull()
+ expect(fixtureEl.querySelector('.form-range-bubble')).not.toBeNull()
+
+ range.dispose()
+
+ expect(Range.getInstance(rangeEl)).toBeNull()
+ expect(fixtureEl.querySelector('.form-range-bubble')).toBeNull()
+ })
+ })
+
+ describe('getInstance', () => {
+ it('should return range instance', () => {
+ fixtureEl.innerHTML = getRangeHtml()
+
+ const rangeEl = fixtureEl.querySelector('.form-range')
+ const range = new Range(rangeEl)
+
+ expect(Range.getInstance(rangeEl)).toEqual(range)
+ expect(Range.getInstance(rangeEl)).toBeInstanceOf(Range)
+ })
+
+ it('should return null when there is no instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Range.getInstance(div)).toBeNull()
+ })
+ })
+
+ describe('getOrCreateInstance', () => {
+ it('should return existing instance', () => {
+ fixtureEl.innerHTML = getRangeHtml()
+
+ const rangeEl = fixtureEl.querySelector('.form-range')
+ const range = new Range(rangeEl)
+
+ expect(Range.getOrCreateInstance(rangeEl)).toEqual(range)
+ expect(Range.getOrCreateInstance(rangeEl)).toBeInstanceOf(Range)
+ })
+
+ it('should create new instance when none exists', () => {
+ fixtureEl.innerHTML = getRangeHtml()
+
+ const rangeEl = fixtureEl.querySelector('.form-range')
+
+ expect(Range.getInstance(rangeEl)).toBeNull()
+ expect(Range.getOrCreateInstance(rangeEl)).toBeInstanceOf(Range)
+ })
+ })
+})
@use "../mixins/gradients" as *;
@use "../mixins/tokens" as *;
+// stylelint-disable custom-property-no-missing-var-function
$range-tokens: () !default;
// scss-docs-start range-tokens
--range-track-cursor: pointer,
--range-track-bg: var(--bg-3),
--range-track-border-radius: 1rem,
- --range-track-box-shadow: var(--box-shadow-inset),
+ --range-track-fill-bg: var(--primary-base),
+ --range-track-disabled-bg: color-mix(in oklch, var(--bg-4), var(--fg-3)),
--range-thumb-width: 1rem,
--range-thumb-height: var(--range-thumb-width),
--range-thumb-bg: var(--primary-base),
--range-thumb-transition-property: "background-color, border-color, box-shadow",
--range-thumb-transition-timing: .15s ease-in-out,
--range-thumb-transition: var(--range-thumb-transition-property) var(--range-thumb-transition-timing),
+ --range-tick-width: var(--border-width),
+ --range-tick-height: .5rem,
+ --range-tick-bg: var(--border-color),
),
$range-tokens
);
// scss-docs-end range-tokens
+// stylelint-enable custom-property-no-missing-var-function
// scss-docs-start range-mixins
@mixin range-thumb() {
@mixin range-track() {
width: var(--range-track-width);
height: var(--range-track-height);
- color: transparent; // Why?
+ color: transparent;
cursor: var(--range-track-cursor);
+ // Fill (progress) up to the thumb. The Range plugin keeps `--range-fill` (0–1) in sync.
background-color: var(--range-track-bg);
- border-color: transparent; // Firefox specific?
+ background-image:
+ linear-gradient(
+ to right,
+ var(--range-track-fill-bg) calc(var(--range-fill, 0) * 100%),
+ transparent calc(var(--range-fill, 0) * 100%)
+ );
+ border-color: transparent;
@include border-radius(var(--range-track-border-radius));
@include box-shadow(var(--range-track-box-shadow));
}
.form-range {
@include tokens($range-tokens);
+ position: relative;
+ display: block;
+ width: 100%;
+ }
+
+ .form-range-input {
+ display: block;
width: 100%;
height: calc(var(--range-thumb-height) + (var(--focus-ring-width) * 2));
- padding: 0; // Need to reset padding
+ padding: 0;
appearance: none;
background-color: transparent;
&:focus-visible {
outline: 0;
- // Pseudo-elements must be split across multiple rulesets to have an effect.
&::-webkit-slider-thumb {
@include focus-ring(true);
--focus-ring-offset: 0;
&::-webkit-slider-thumb {
@include range-thumb();
- margin-top: calc((var(--range-track-height) - var(--range-thumb-height)) * .5); // Webkit specific
+ margin-top: calc((var(--range-track-height) - var(--range-thumb-height)) * .5);
}
&::-moz-range-thumb {
&::-moz-range-thumb {
background-color: var(--range-thumb-disabled-bg);
}
+
+ &::-webkit-slider-runnable-track {
+ --range-track-fill-bg: var(--range-track-disabled-bg);
+ }
+
+ &::-moz-range-track {
+ --range-track-fill-bg: var(--range-track-disabled-bg);
+ }
}
}
+
+ // Value bubble: reuses the tooltip styles (`.tooltip` markup) so we don't duplicate the
+ // pill and arrow. We only add the static positioning the Tooltip plugin would normally do.
+ .form-range-bubble {
+ position: absolute;
+ bottom: 100%;
+ left: calc((var(--range-thumb-width) * .5) + var(--range-fill, 0) * (100% - var(--range-thumb-width)));
+ margin-bottom: var(--tooltip-arrow-height);
+ pointer-events: none;
+ transform: translateX(-50%);
+
+ .tooltip-arrow {
+ position: absolute;
+ bottom: calc(-1 * var(--tooltip-arrow-height));
+ left: 50%;
+ transform: translateX(-50%);
+ }
+ }
+
+ // Tick marks generated from the linked <datalist>. Plugin builds `grid-template-columns`
+ // from the gaps between values so each tick lands on a grid line (handles uneven values).
+ // Track is inset by 1/4th of the thumb width to keep alignment.
+ .form-range-ticks {
+ display: grid;
+ padding-inline: calc(var(--range-thumb-width) * .25);
+ }
+
+ .form-range-tick {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-self: start;
+ // Zero-width items so labels never widen their `fr` column; the tick line and label
+ // overflow centered on the grid line via `align-items`.
+ width: 0;
+
+ &::before {
+ width: var(--range-tick-width);
+ height: var(--range-tick-height);
+ content: "";
+ background-color: var(--range-tick-bg);
+ }
+
+ &:first-child {
+ align-items: flex-start;
+ }
+
+ &:last-child {
+ align-items: flex-end;
+ }
+ }
+
+ .form-range-tick-label {
+ margin-top: .125rem;
+ font-size: var(--font-size-sm);
+ color: var(--fg-2);
+ white-space: nowrap;
+ }
}
}
}
- // Range — .form-range IS the <input>, so the mixin applies directly.
- .form-range {
+ // Range — the validation class lives on .form-range-input, while feedback sits outside
+ // the .form-range wrapper, so we use :has() to toggle it.
+ .form-range-input {
@include form-validation-state-selector($state) {
&::-webkit-slider-thumb { background: var(--#{$theme}-bg); }
&::-moz-range-thumb { background: var(--#{$theme}-bg); }
@include focus-ring(true, $color: var(--#{$theme}-focus-ring));
}
}
-
- ~ .#{$state}-feedback,
- ~ .#{$state}-tooltip { display: block; }
}
}
+ .form-range:has(.form-range-input.is-#{$state}) {
+ ~ .#{$state}-feedback,
+ ~ .#{$state}-tooltip { display: block; }
+ }
+
// Input group — feedback lives outside the input-group in the parent
// .form-field, so we use :has() to toggle display.
.form-field:has(.input-group .form-control.is-#{$state}) {
- title: Radio
- title: Switch
- title: Range
+ meta:
+ - added: 6.0.0
- title: Input group
- title: Floating labels
- title: Form adorn
</div>
<div class="mb-3">
<label for="customRange3" class="form-label">Example range</label>
- <input type="range" class="form-range" min="0" max="5" step="0.5" id="customRange3">
+ <div class="form-range">
+ <input type="range" class="form-range-input" min="0" max="5" step="0.5" id="customRange3">
+ </div>
</div>
<button type="submit" class="btn-solid theme-primary">Submit</button>
</form>`} />
</div>
<div class="mb-3">
<label for="disabledRange" class="form-label">Disabled range</label>
- <input type="range" class="form-range" min="0" max="5" step="0.5" id="disabledRange">
+ <div class="form-range">
+ <input type="range" class="form-range-input" min="0" max="5" step="0.5" id="disabledRange">
+ </div>
</div>
<button type="submit" class="btn-solid theme-primary">Submit</button>
</fieldset>
---
title: Range
-description: Use our custom range inputs for consistent cross-browser styling and built-in customization.
+description: Use our custom range inputs for consistent cross-browser styling with a filled track, value bubble, and tick marks.
toc: true
css_layer: forms
+js: required
mdn: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/range
---
## Overview
-Create custom `<input type="range">` controls with `.form-range`. The track (the background) and thumb (the value) are both styled to appear the same across browsers. As only Firefox supports “filling” their track from the left or right of the thumb as a means to visually indicate progress, we do not currently support it.
+The native `<input type="range">` is hard to style and can’t show a filled track in a consistent, cross-browser way using CSS alone—only Firefox offers `::-moz-range-progress`, and CSS can’t read an input’s value. So our range is a small **JavaScript component**: wrap an `<input class="form-range-input">` in `.form-range` and the plugin keeps a `--bs-range-fill` custom property (0–1) in sync, which the CSS uses to draw the fill, the value bubble, and tick marks.
+
+The `.form-range` wrapper owns the component’s tokens, so the input and any decorations inherit them. Every `.form-range` on the page is initialized automatically.
<Example code={`<label for="range1" class="form-label">Example range</label>
-<input type="range" class="form-range" id="range1">`} />
+<div class="form-range">
+ <input type="range" class="form-range-input" id="range1">
+ </div>`} />
## Disabled
-Add the `disabled` boolean attribute on an input to give it a grayed out appearance, remove pointer events, and prevent focusing.
+Add the `disabled` boolean attribute on the input to give it a grayed out appearance, remove pointer events, and prevent focusing.
<Example code={`<label for="disabledRange" class="form-label">Disabled range</label>
-<input type="range" class="form-range" id="disabledRange" disabled>`} />
+<div class="form-range">
+ <input type="range" class="form-range-input" id="disabledRange" disabled>
+ </div>`} />
## Min and max
Range inputs have implicit values for `min` and `max`—`0` and `100`, respectively. You may specify new values for those using the `min` and `max` attributes.
<Example code={`<label for="range2" class="form-label">Example range</label>
-<input type="range" class="form-range" min="0" max="5" id="range2">`} />
+<div class="form-range">
+ <input type="range" class="form-range-input" min="0" max="5" id="range2">
+ </div>`} />
## Steps
By default, range inputs “snap” to integer values. To change this, you can specify a `step` value. In the example below, we double the number of steps by using `step="0.5"`.
<Example code={`<label for="range3" class="form-label">Example range</label>
-<input type="range" class="form-range" min="0" max="5" step="0.5" id="range3">`} />
+<div class="form-range">
+ <input type="range" class="form-range-input" min="0" max="5" step="0.5" id="range3">
+ </div>`} />
+
+## Value bubble
+
+Add `data-bs-bubble` to the wrapper to show a value bubble that follows the thumb. The bubble reuses our [tooltip](/docs/[[config:docs_version]]/components/tooltips/) styles.
+
+<Example code={`<label for="rangeBubble" class="form-label">Brightness</label>
+<div class="form-range" data-bs-bubble>
+ <input type="range" class="form-range-input" min="0" max="100" value="60" id="rangeBubble">
+ </div>`} />
+
+## Tick marks
+
+Link a `<datalist>` to the input with the `list` attribute and the plugin renders a tick mark for each `<option>`, positioned at its value (even when the values are unevenly spaced). An `<option>`’s `label` is shown beneath its tick.
+
+<Example code={`<label for="rangeTicks" class="form-label">Temperature</label>
+<div class="form-range">
+ <input type="range" class="form-range-input" min="0" max="100" step="25" value="50" list="rangeTicksList" id="rangeTicks">
+ </div>
+ <datalist id="rangeTicksList">
+ <option value="0" label="Cold"></option>
+ <option value="25"></option>
+ <option value="50" label="Mild"></option>
+ <option value="75"></option>
+ <option value="100" label="Hot"></option>
+ </datalist>`} />
## With form field
-Wrap a `.form-range` in a `.form-field` to pair it with a label and description.
+Wrap the `.form-range` in a `.form-field` to pair it with a label and description.
<Example code={`<div class="form-field">
<label for="rangeField" class="form-label">Volume</label>
- <input type="range" class="form-range" id="rangeField">
+ <div class="form-range" data-bs-bubble>
+ <input type="range" class="form-range-input" id="rangeField">
+ </div>
<small class="form-text">Adjust the volume level.</small>
</div>`} />
-## Output value
+## Usage
+
+Every `.form-range` is initialized automatically on page load—the component is JavaScript-driven, so the fill won’t render without the plugin.
+
+### Via data attributes
+
+<BsTable>
+| Attribute | Description |
+| --- | --- |
+| `data-bs-bubble` | Add to the `.form-range` wrapper to show a [value bubble](#value-bubble). |
+| `list` (on the input) + `<datalist>` | Renders [tick marks](#tick-marks) from the datalist options. |
+</BsTable>
+
+```html
+<div class="form-range" data-bs-bubble>
+ <input type="range" class="form-range-input" min="0" max="100" value="40">
+</div>
+```
+
+### Via JavaScript
+
+```js
+const element = document.querySelector('.form-range')
+const range = new bootstrap.Range(element, {
+ bubble: true,
+ formatter: value => `${value}%`
+})
+```
+
+### Options
+
+<BsTable>
+| Name | Type | Default | Description |
+| --- | --- | --- | --- |
+| `bubble` | boolean | `false` | Show a value bubble above the thumb. |
+| `formatter` | function | `null` | `(value) => string` used for the bubble and tick label text. |
+</BsTable>
+
+### Methods
-The value of the range input can be shown using the `output` element and a bit of JavaScript.
+<BsTable>
+| Method | Description |
+| --- | --- |
+| `update()` | Recompute the fill from the input’s current value. Useful after setting the value programmatically. |
+| `dispose()` | Destroys the instance and removes the bubble and tick marks it created. |
+| `getInstance()` | Static method to get the instance associated with a `.form-range` element. |
+| `getOrCreateInstance()` | Static method to get the instance, or create one if it doesn’t exist. |
+</BsTable>
-<Example code={`<label for="range4" class="form-label">Example range</label>
- <input type="range" class="form-range" min="0" max="100" value="50" id="range4">
- <output for="range4" id="rangeValue" aria-hidden="true"></output>
+### Events
- <script>
- // This is an example script, please modify as needed
- const rangeInput = document.getElementById('range4');
- const rangeOutput = document.getElementById('rangeValue');
+<BsTable>
+| Event | Description |
+| --- | --- |
+| `changed.bs.range` | Fired on the input when the value updates (on `input` and `change`). The event’s `value` property holds the current numeric value. |
+</BsTable>
- // Set initial value
- rangeOutput.textContent = rangeInput.value;
+```js
+const input = document.querySelector('.form-range-input')
- rangeInput.addEventListener('input', function() {
- rangeOutput.textContent = this.value;
- });
- </script>`} />
+input.addEventListener('changed.bs.range', event => {
+ console.log('Value:', event.value)
+})
+```
## CSS
### Sass mixins
-Two mixins are used to style the range input to generate vendor-specific styles for the range’s track and thumb elements. Please be aware that selectors for pseudo-elements in WebKit and Firefox browsers cannot be combined—each must be used in a separate CSS rule otherwise the styles will not apply.
+Two mixins generate the vendor-specific styles for the range’s track and thumb. Note that selectors for pseudo-elements in WebKit and Firefox cannot be combined—each must be used in a separate CSS rule otherwise the styles will not apply.
<ScssDocs name="range-mixins" file="scss/forms/_form-range.scss" />
- `.check` checkboxes
- `.radio` radios
- `.switch` switches
-- `.form-range` range inputs
+- `.form-range-input` range inputs
- `.form-floating` floating labels
- `.form-adorn` adorned inputs
- `.chip-input` chip inputs
<div class="form-field">
<label for="validationRange" class="form-label">Range</label>
- <input type="range" class="form-range is-invalid" id="validationRange" min="0" max="5" required aria-describedby="validationRangeFeedback">
+ <div class="form-range">
+ <input type="range" class="form-range-input is-invalid" id="validationRange" min="0" max="5" required aria-describedby="validationRangeFeedback">
+ </div>
<div id="validationRangeFeedback" class="invalid-feedback">Example invalid range feedback</div>
</div>
- Removed `.form-select`—use `.form-control` on `<select>` elements now. Too much abstraction and duplication at the same time.
- Adds new CSS variables on `.form-control` for easier customization without Sass compilation.
- `.form-control` now has a `min-height` at all times as opposed to just on `<textarea>` elements. This reduces some CSS for us.
+- **Range is now a JavaScript component.** The native range can’t draw a filled track cross-browser in CSS alone, so `.form-range` is JS-driven.
+ - `.form-range` is now a **wrapper** element; the `<input type="range">` takes `.form-range-input` (previously `.form-range` went directly on the input). The wrapper owns the component’s tokens, so the input and decorations inherit them. Every `.form-range` is initialized automatically.
+ - Draws a filled track by default, plus an optional value bubble (`data-bs-bubble`, which reuses the tooltip styles) and tick marks generated from a linked `<datalist>`.
+ - The fill amount is exposed as `--bs-range-fill` (`0`–`1`), kept in sync by the plugin; the fill color token is `--range-track-fill-bg`.
+ - Validation classes (`.is-invalid` / `.is-valid`) now go on `.form-range-input`.
- **Added new Combobox form component.** A searchable, filterable select with single and multi-select modes, built on top of the Menu component. See the [Combobox docs]([[docsref:/forms/combobox]]).
- **New `.form-field` layout component.** Replaces `.checkgroup` and `.radiogroup` wrappers with a unified grid-based layout primitive for label + control + help text + validation feedback. Use `.form-field`, `.form-field-content`, and `.form-field-card` for structured form layouts, and `.form-group` for grouping related fields. See the [Field docs]([[docsref:/forms/field]]).
- **Overhauled form validation.**
- **Chip and Chip Input** — new `.chip` component for tags/tokens and `.chip-input` for interactive chip entry. `Chips` JavaScript plugin with events: `add.bs.chips`, `remove.bs.chips`, `change.bs.chips`.
- **OTP Input** — new `.otp` component for one-time password fields. Built on a single `<input>` rendered as separate digit slots for full accessibility. `OtpInput` JavaScript plugin with events: `input.bs.otpInput`, `complete.bs.otpInput` (both expose `event.value`).
- **Password Strength** — `Strength` JavaScript plugin for password strength metering with `strengthChange.bs.strength` event.
+- **Range** — `Range` JavaScript plugin that turns `.form-range` into a styled slider with a filled track, optional value bubble, and `<datalist>` tick marks, with a `changed.bs.range` event.
- **Toggler** — `Toggler` JavaScript plugin for toggling classes or attributes on elements via `data-bs-toggle="toggler"`.
- **Datepicker** — `Datepicker` JavaScript plugin built on Vanilla Calendar Pro, with events: `change.bs.datepicker`, `show.bs.datepicker`, `hide.bs.datepicker`.
- **Form Adorn** — new `.form-adorn` component for adding icons or text decoration to form inputs.