]> git.ipfire.org Git - thirdparty/bootstrap.git/commitdiff
Add Range plugin for track fill, value bubble, and tick marks (#42525)
authorMark Otto <markd.otto@gmail.com>
Mon, 22 Jun 2026 17:51:31 +0000 (10:51 -0700)
committerGitHub <noreply@github.com>
Mon, 22 Jun 2026 17:51:31 +0000 (10:51 -0700)
* Add Range plugin for track fill, value bubble, and tick marks

Adds an opt-in JavaScript plugin (`data-bs-range`) that enhances
`<input type="range">`. A consistent cross-browser fill can't be done
with pseudo-elements alone (only Firefox has `::-moz-range-progress`),
so the plugin publishes the current value as a `--bs-range-value`
custom property that the track gradient consumes.

- Fill: colored track up to the thumb, themeable via `--range-fill-bg`
- Value bubble (`data-bs-bubble`): floating value that tracks the thumb
- Tick marks (`data-bs-ticks`): generated from a linked `<datalist>`

The bubble and ticks are siblings of the input, so they don't inherit
the input's `--range-fill-bg`; the plugin copies the resolved value onto
them and the CSS falls back to `--primary-base` so they're never blank.

Plain `.form-range` inputs are untouched. Includes unit tests and docs.

* Range: anchor positioning on .form-range and rename track fill token

- Make `.form-range` the positioning context (`position: relative`) and
  drop the JS-added `.range-anchored` class; the bubble and ticks are
  siblings of the input, so they already share its offset parent.
- Rename `--range-fill-bg` to `--range-track-fill-bg` for accuracy.
- Keep SCSS custom properties unprefixed (the build adds `--bs-`) and
  have the plugin read/write the prefixed names, matching the codebase.
- Trim obvious comments.

* Bump bundlewatch JS size limits for the Range plugin

* Range: make it a JS component with a wrapper, consolidate the CSS

Restructure range as an always-JS component so the fill, value bubble,
and tick marks are driven by a single `--bs-range-fill` ratio in CSS.

- `.form-range` is now a wrapper that owns the tokens; the input takes
  `.form-range-input`. Children inherit the tokens, so the old JS color
  copying and px positioning are gone.
- The plugin only sets `--bs-range-fill` (0–1) and the bubble text; the
  track gradient, bubble position, and tick positions are pure CSS calc.
  Drops `_thumbWidth`, `_inheritFillColor`, the resize listener, and the
  `.range-anchored` class.
- Value bubble reuses the tooltip markup/styles instead of duplicating a
  pill and arrow. Tick marks are generated from the linked `<datalist>`
  and positioned per-value (handles uneven values); tick coloring dropped.
- Auto-inits every `.form-range`; rename fill token to
  `--range-track-fill-bg`.
- Validation moves to `.form-range-input` with a `:has()` feedback toggle.
- Add the "New" badge to the Range sidebar item and document the breaking
  changes in the migration guide.

* Range: lay out tick marks with CSS grid instead of absolute positioning

Build `grid-template-columns` from the gaps between the datalist values
so each tick lands on a grid line — this handles unevenly-spaced values
just like the old per-tick calc did, but keeps the ticks and their labels
in normal flow (the container sizes to fit the labels instead of them
overflowing an absolutely-positioned box). Inset by half the thumb so the
end ticks align with the thumb travel.

Also wraps the token map in the `custom-property-no-missing-var-function`
stylelint disable (matching _strength.scss). Includes the disabled-track
fill styling.

* Nudge bundlewatch JS limits after rebasing onto v6-dev

* fix bubble, grid, ticks, and more

* comment

.bundlewatch.config.json
js/index.js
js/src/range.js [new file with mode: 0644]
js/tests/unit/range.spec.js [new file with mode: 0644]
scss/forms/_form-range.scss
scss/forms/_validation.scss
site/data/sidebar.yml
site/src/assets/examples/cheatsheet/index.astro
site/src/content/docs/forms/range.mdx
site/src/content/docs/forms/validation.mdx
site/src/content/docs/guides/migration.mdx

index 9da0126d36f945255a4b17db4dc4774bb2d6d76b..2c89f7ea47a8b2429c943ca75c8568f22dc41373 100644 (file)
     },
     {
       "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": {
index 7030887c229d6db469dd504823628d3748543bdc..b40e647c33dd8b696f242b3cfadf8f80f182b69d 100644 (file)
@@ -19,6 +19,7 @@ export { default as Strength } from './src/strength.js'
 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'
diff --git a/js/src/range.js b/js/src/range.js
new file mode 100644 (file)
index 0000000..b93fea0
--- /dev/null
@@ -0,0 +1,240 @@
+/**
+ * --------------------------------------------------------------------------
+ * 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
diff --git a/js/tests/unit/range.spec.js b/js/tests/unit/range.spec.js
new file mode 100644 (file)
index 0000000..73a8da0
--- /dev/null
@@ -0,0 +1,304 @@
+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)
+    })
+  })
+})
index a784b0dd2a46cc41de99ce230914e47b97a13692..fa0515497ca3722e829a3cefcffa66653064b9da 100644 (file)
@@ -6,6 +6,7 @@
 @use "../mixins/gradients" as *;
 @use "../mixins/tokens" as *;
 
+// stylelint-disable custom-property-no-missing-var-function
 $range-tokens: () !default;
 
 // scss-docs-start range-tokens
@@ -17,7 +18,8 @@ $range-tokens: defaults(
     --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),
@@ -29,10 +31,14 @@ $range-tokens: defaults(
     --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() {
@@ -53,10 +59,17 @@ $range-tokens: defaults(
 @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));
 }
@@ -66,9 +79,16 @@ $range-tokens: defaults(
   .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;
 
@@ -84,7 +104,6 @@ $range-tokens: defaults(
     &: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;
@@ -101,7 +120,7 @@ $range-tokens: defaults(
 
     &::-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 {
@@ -126,6 +145,72 @@ $range-tokens: defaults(
       &::-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;
+  }
 }
index 1337be222d650bf1285e81359ab702ff5fc68c6e..acf51fd20b8be1d093b70c5e8bef6635870b6db6 100644 (file)
@@ -281,8 +281,9 @@ $validation-states: defaults(
     }
   }
 
-  // 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); }
@@ -295,12 +296,14 @@ $validation-states: defaults(
           @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}) {
index 3be5874321d8ebaa9416d5edc5688e827efe3542..0eea5431ff3e38d1310674a60cb8eb34156af145 100644 (file)
@@ -81,6 +81,8 @@
     - title: Radio
     - title: Switch
     - title: Range
+      meta:
+        - added: 6.0.0
     - title: Input group
     - title: Floating labels
     - title: Form adorn
index 707d73b797bd97fadf628e53ff1b2ce4e34334d2..09df41763c717274a705f8bd3b53a9176550d44b 100644 (file)
@@ -359,7 +359,9 @@ export const body_class = 'bg-body-tertiary'
           </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>`} />
@@ -414,7 +416,9 @@ export const body_class = 'bg-body-tertiary'
             </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>
index f1e47bbbb240ea0eebf523a1d2bc9c9531cba297..f87bafac8d220cc18b06e890389ef2e391c4f44c 100644 (file)
 ---
 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
 
@@ -75,6 +157,6 @@ The value of the range input can be shown using the `output` element and a bit o
 
 ### 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" />
index 43dee7092d70653f3cd1b56e1d6bdee562d7a32a..7e734bb21ba6d63250d9d1592d5e65a932cf6aeb 100644 (file)
@@ -271,7 +271,7 @@ Validation styles are available for the following form controls and components:
 - `.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
@@ -345,7 +345,9 @@ Validation styles are available for the following form controls and components:
 
     <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>
 
index fef0b6c4a984b879d14ad7d764baed993b000159..1d5d4cfab95a6b4de3d21ee69aaa7559db5594f4 100644 (file)
@@ -259,6 +259,11 @@ Bootstrap 6 is a major release with many breaking changes to modernize our codeb
   - 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.**
@@ -407,6 +412,7 @@ Bootstrap 6 is a major release with many breaking changes to modernize our codeb
 - **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.