diff --git a/.bundlewatch.config.json b/.bundlewatch.config.json index cd2034c8f8fd..fc4f3a119348 100644 --- a/.bundlewatch.config.json +++ b/.bundlewatch.config.json @@ -34,19 +34,19 @@ }, { "path": "./dist/js/bootstrap.bundle.js", - "maxSize": "80.25 kB" + "maxSize": "82.0 kB" }, { "path": "./dist/js/bootstrap.bundle.min.js", - "maxSize": "52.25 kB" + "maxSize": "53.25 kB" }, { "path": "./dist/js/bootstrap.js", - "maxSize": "51.5 kB" + "maxSize": "53.25 kB" }, { "path": "./dist/js/bootstrap.min.js", - "maxSize": "30.25 kB" + "maxSize": "31.25 kB" } ], "ci": { diff --git a/js/index.js b/js/index.js index 7030887c229d..b40e647c33dd 100644 --- a/js/index.js +++ b/js/index.js @@ -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 index 000000000000..6c1d90ee4c4f --- /dev/null +++ b/js/src/range.js @@ -0,0 +1,236 @@ +/** + * -------------------------------------------------------------------------- + * 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') + + const arrow = document.createElement('span') + arrow.className = 'tooltip-arrow' + this._bubbleText = document.createElement('span') + 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)) { + points.push({ ratio: (value - min) / span, 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 index 000000000000..73a8da0365a5 --- /dev/null +++ b/js/tests/unit/range.spec.js @@ -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 ` +
+ +
+ ` + } + + 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 = ` +
+ +
+ ` + + 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 = '
' + + 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 ` +
+ +
+ + + + + + ` + } + + 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 = '
' + + 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) + }) + }) +}) diff --git a/scss/forms/_form-range.scss b/scss/forms/_form-range.scss index a784b0dd2a46..2005d8274a51 100644 --- a/scss/forms/_form-range.scss +++ b/scss/forms/_form-range.scss @@ -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: .375rem, + --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,22 +59,37 @@ $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)); } // scss-docs-end range-mixins @layer forms { + // Wrapper: owns the tokens (so the input, bubble, and ticks all inherit them) + // and is the positioning context for the bubble and ticks. .form-range { @include tokens($range-tokens); + position: relative; + display: block; + width: 100%; + } + + .form-range-input { 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; @@ -126,6 +147,63 @@ $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 . The plugin builds `grid-template-columns` + // from the gaps between values so each tick lands on a grid line (handles uneven values), + // inset by half the thumb so the ends line up with the thumb travel. + .form-range-ticks { + display: grid; + padding-inline: calc(var(--range-thumb-width) * .5); + margin-top: .25rem; + } + + .form-range-tick { + display: flex; + flex-direction: column; + align-items: center; + justify-self: start; + transform: translateX(-50%); + + &::before { + width: var(--range-tick-width); + height: var(--range-tick-height); + content: ""; + background-color: var(--range-tick-bg); + } + } + + .form-range-tick-label { + margin-top: .125rem; + font-size: var(--font-size-sm); + color: var(--fg-2); + white-space: nowrap; + } } diff --git a/scss/forms/_validation.scss b/scss/forms/_validation.scss index 1337be222d65..acf51fd20b8b 100644 --- a/scss/forms/_validation.scss +++ b/scss/forms/_validation.scss @@ -281,8 +281,9 @@ $validation-states: defaults( } } - // Range — .form-range IS the , 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}) { diff --git a/site/data/sidebar.yml b/site/data/sidebar.yml index 3be5874321d8..0eea5431ff3e 100644 --- a/site/data/sidebar.yml +++ b/site/data/sidebar.yml @@ -81,6 +81,8 @@ - title: Radio - title: Switch - title: Range + meta: + - added: 6.0.0 - title: Input group - title: Floating labels - title: Form adorn diff --git a/site/src/assets/examples/cheatsheet/index.astro b/site/src/assets/examples/cheatsheet/index.astro index 25e247553b27..4d772ae1090d 100644 --- a/site/src/assets/examples/cheatsheet/index.astro +++ b/site/src/assets/examples/cheatsheet/index.astro @@ -359,7 +359,9 @@ export const body_class = 'bg-body-tertiary'
- +
+ +
`} /> @@ -414,7 +416,9 @@ export const body_class = 'bg-body-tertiary'
- +
+ +
diff --git a/site/src/content/docs/forms/range.mdx b/site/src/content/docs/forms/range.mdx index f1e47bbbb240..f87bafac8d22 100644 --- a/site/src/content/docs/forms/range.mdx +++ b/site/src/content/docs/forms/range.mdx @@ -1,69 +1,151 @@ --- 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 `` 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 `` 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 `` 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 range -`} /> +
+ +
`} /> ## 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. Disabled range -`} /> +
+ +
`} /> ## 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 range -`} /> +
+ +
`} /> ## 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 range -`} /> +
+ +
`} /> + +## 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. + +Brightness +
+ +
`} /> + +## Tick marks + +Link a `` to the input with the `list` attribute and the plugin renders a tick mark for each `