Skip to main content

Theme config

Theme mode

Color palette

Grays

Border radii/radiuses/radiopedes/you know
Border radius
Field border radius
Button border radius
All
Components
Guides
API
Recent

Components

Range

Full support Supported since v125. Full support Supported since v128. Full support Supported since v18.
---
import { Range } from "@opui/astro"
---
<Range label="Label" startText="Min" />
<label class="range">
<span class="label" id="range-label-6">Label</span>
<span class="start-text" id="range-start-1"> Min </span>
<input
aria-describedby="range-start-1"
aria-labelledby="range-label-6"
id="range-6"
type="range"
/>
</label>

Start text & End text

---
import { Range } from "@opui/astro"
---
<Range label="Label" startText="Start helper text" endText="End helper text" />
<label class="range">
<span class="label" id="range-label-7">Label</span>
<span class="start-text" id="range-start-2"> Start helper text </span>
<input
aria-describedby="range-start-2 range-end-1"
aria-labelledby="range-label-7"
id="range-7"
type="range"
/>
<span class="end-text" id="range-end-1"> End helper text </span>
</label>

Value

Pass the valueSuffix prop (or use the value named slot) to render a live readout of the slider's current value next to the label. The component wires up an <output> element and keeps its text in sync with the input.

---
import { Range } from "@opui/astro"
---
<Range label="Hue" min="0" max="360" value="250" valueSuffix="°" />
<label class="range">
<span class="label" id="range-label-8">Hue</span>
<output class="value" for="range-8" data-suffix="°"> 250 </output>
<input
aria-labelledby="range-label-8"
id="range-8"
max="360"
min="0"
type="range"
value="250"
/>
</label>

Tick marks

Pass an id to the list prop together with an options array — options=[{ value, label }] — and the component renders a matching <datalist>.

---
import { Range } from "@opui/astro"
---
<Range
label="Tick marks with labels"
list="labeled-markers"
options={[
{ value: 0, label: "0%" },
{ value: 25, label: "25%" },
{ value: 50, label: "50%" },
{ value: 75, label: "75%" },
{ value: 100, label: "100%" },
]}
/>
<label class="range">
<span class="label" id="range-label-9">Tick marks with labels</span>
<input
aria-labelledby="range-label-9"
id="range-9"
list="labeled-markers"
type="range"
/>
<datalist id="labeled-markers">
<option value="0" label="0%"></option>
<option value="25" label="25%"></option>
<option value="50" label="50%"></option>
<option value="75" label="75%"></option>
<option value="100" label="100%"></option>
</datalist>
</label>

Variants

Use the variant prop to swap the track surface for better contrast on different backgrounds.

---
import { Range } from "@opui/astro"
---
<Range label="Default" />
<Range variant="default">
<code>default</code> = <code>var(--surface-default)</code>
</Range>
<Range variant="filled">
<code>filled</code> = <code>var(--surface-filled)</code>
</Range>
<Range variant="tonal">
<code>tonal</code> = <code>var(--surface-tonal)</code>
</Range>
<label class="range">
<span class="label" id="range-label-10">Default</span>
<input aria-labelledby="range-label-10" id="range-10" type="range" />
</label>
<label class="range default">
<span class="label" id="range-label-11">
<code>default</code> = <code>var(--surface-default)</code>
</span>
<input aria-labelledby="range-label-11" id="range-11" type="range" />
</label>
<label class="range filled">
<span class="label" id="range-label-12">
<code>filled</code> = <code>var(--surface-filled)</code>
</span>
<input aria-labelledby="range-label-12" id="range-12" type="range" />
</label>
<label class="range tonal">
<span class="label" id="range-label-13">
<code>tonal</code> = <code>var(--surface-tonal)</code>
</span>
<input aria-labelledby="range-label-13" id="range-13" type="range" />
</label>

Disabled

---
import { Range } from "@opui/astro"
---
<Range disabled label="Disabled" />
<label class="range">
<span class="label" id="range-label-14">Disabled</span>
<input aria-labelledby="range-label-14" disabled id="range-14" type="range" />
</label>

Validation

---
import { Range } from "@opui/astro"
---
<Range label="Invalid Range" data-invalid endText="This value is incorrect." />
<label class="range">
<span class="label" id="range-label-15">Invalid Range</span>
<input
aria-describedby="range-end-2"
aria-labelledby="range-label-15"
id="range-15"
type="range"
data-invalid="true"
/>
<span class="end-text" id="range-end-2"> This value is incorrect. </span>
</label>

Spread

---
import { Range } from "@opui/astro"
---
<Range spread>
Spread Layout
<Fragment slot="start-text">Start text</Fragment>
<Fragment slot="end-text">End text</Fragment>
</Range>
<Range spread disabled>
Disabled
<Fragment slot="start-text">Start text</Fragment>
<Fragment slot="end-text">End text</Fragment>
</Range>
<Range spread data-invalid endText="This value is incorrect.">
Invalid Range
<Fragment slot="start-text">Start text</Fragment>
</Range>
<Range
label="Tick marks with labels"
list="labeled-markers-spread"
spread
options={[
{ value: 0, label: "0%" },
{ value: 25, label: "25%" },
{ value: 50, label: "50%" },
{ value: 75, label: "75%" },
{ value: 100, label: "100%" },
]}
/>
<label class="range spread">
<span class="label" id="range-label-16"> Spread Layout </span>
<span class="start-text" id="range-start-3"> Start text </span>
<input
aria-describedby="range-start-3 range-end-3"
aria-labelledby="range-label-16"
id="range-16"
type="range"
/>
<span class="end-text" id="range-end-3"> End text </span>
</label>
<label class="range spread">
<span class="label" id="range-label-17"> Disabled </span>
<span class="start-text" id="range-start-4"> Start text </span>
<input
aria-describedby="range-start-4 range-end-4"
aria-labelledby="range-label-17"
disabled
id="range-17"
type="range"
/>
<span class="end-text" id="range-end-4"> End text </span>
</label>
<label class="range spread">
<span class="label" id="range-label-18"> Invalid Range </span>
<span class="start-text" id="range-start-5"> Start text </span>
<input
aria-describedby="range-start-5 range-end-5"
aria-labelledby="range-label-18"
id="range-18"
type="range"
data-invalid="true"
/>
<span class="end-text" id="range-end-5"> This value is incorrect. </span>
</label>
<label class="range spread">
<span class="label" id="range-label-19">Tick marks with labels</span>
<input
aria-labelledby="range-label-19"
id="range-19"
list="labeled-markers-spread"
type="range"
/>
<datalist id="labeled-markers-spread">
<option value="0" label="0%"></option>
<option value="25" label="25%"></option>
<option value="50" label="50%"></option>
<option value="75" label="75%"></option>
<option value="100" label="100%"></option>
</datalist>
</label>

Disabled

---
import { Range } from "@opui/astro"
---
<Range label="Volume" min={0} max={100} value={50} spread disabled />
<label class="range spread">
<span class="label" id="range-label-20">Volume</span>
<input
aria-labelledby="range-label-20"
disabled
id="range-20"
max="100"
min="0"
type="range"
value="50"
/>
</label>

Validation

---
import { Range } from "@opui/astro"
---
<Range label="Volume" min={0} max={100} value={50} spread data-invalid />
<label class="range spread">
<span class="label" id="range-label-21">Volume</span>
<input
aria-labelledby="range-label-21"
id="range-21"
max="100"
min="0"
type="range"
value="50"
data-invalid="true"
/>
</label>

Accessibility

  • Right Arrow: Increase the value of the slider by one step.
  • Up Arrow: Increase the value of the slider by one step.
  • Left Arrow: Decrease the value of the slider by one step.
  • Down Arrow: Decrease the value of the slider by one step.
  • Home: Set the slider to the first allowed value in its range.
  • End: Set the slider to the last allowed value in its range.
  • Page Up (Optional): Increase the slider value by an amount larger than the step change made by Up Arrow.
  • Page Down (Optional): Decrease the slider value by an amount larger than the step change made by Down Arrow.

Anatomy

  1. Container
  2. Label (optional)
  3. Value (optional)
  4. Start text (optional)
  5. Input
  6. End text (optional)

API

Prop Type Default Description
label string - The label for the range input.
startText string - Informational text between label and the range input.
endText string - Informational text below the range input.
spread boolean false Spreads the label/text and input to opposite ends.
variant 'filled' | 'default' | 'tonal' 'default' Adjusts the track color for better contrast on different surfaces.
min number | string - The minimum value.
max number | string - The maximum value.
step number | string - The step increment.
value number | string - The current value.
valueSuffix string - Renders a live <output> next to the label showing the current input value. The suffix (e.g. °, px) is appended to the value and the component keeps the readout in sync on every input event.

Slots

Slot Description
default Slot for the label element.
start-text Slot for the text between label and input.
end-text Slot for the text below the input.
value Custom content for the live value readout. When set, replaces the default rendering of the value prop inside <output>. The auto-update script still mirrors the input's value into the element's text content.

Browser support

Full support Supported since v125. Full support Supported since v128. Full support Supported since v18.

See also the full browser support guide.

Installation

@layer components.root {
:where(.range) {
display: grid;
gap: var(--size-1) 0;
/* Two-column base so the optional .value cell can sit next to the
label on row 1. Children that span the full width keep using
`grid-column: 1/-1`, so this is backwards compatible. */
grid-template-columns: 1fr auto;
position: relative;
.label {
color: var(--text-primary);
font-size: var(--font-size-05);
font-weight: 600;
grid-column: 1;
grid-row: 1;
}
/* Live current-value readout. Pairs with the <output> element
rendered by the Range component (or hand-written .value markup). */
:where(.value) {
color: var(--text-muted);
font-size: var(--font-size-0);
font-variant-numeric: tabular-nums;
font-weight: 600;
grid-column: 2;
grid-row: 1;
line-height: 1.5;
text-align: end;
}
.start-text {
color: var(--text-muted);
font-size: var(--font-size-0);
grid-column: 1/-1;
grid-row: 2;
line-height: 1.5;
}
:where(.end-text) {
color: var(--text-muted);
font-size: var(--font-size-0);
grid-column: 1/-1;
grid-row: 5;
line-height: 1.5;
}
/* Datalist (tick mark labels) */
:where(datalist) {
color: var(--text-muted);
display: flex;
font-size: var(--font-size-0);
grid-column: 1/-1;
grid-row: 4;
justify-content: space-between;
margin-top: calc(var(--size-1) * -1);
padding-inline: calc(3ex / 2);
&>option {
display: flex;
inline-size: 0;
justify-content: center;
padding: 0;
white-space: nowrap;
}
}
/* Disabled */
&:has([disabled]) {
cursor: not-allowed;
opacity: 0.64;
user-select: none;
.label,
.value,
.start-text,
.end-text {
cursor: not-allowed;
}
}
/* Color */
&.filled input[type="range"] {
--_track-color: var(--surface-filled);
}
&.default input[type="range"] {
--_track-color: var(--surface-default);
}
&.tonal input[type="range"] {
--_track-color: var(--surface-tonal);
}
/* Orientation */
&.spread {
align-items: center;
column-gap: var(--size-4);
container-type: inline-size;
grid-template-columns: 1fr auto;
.label {
grid-column: 1;
grid-row: 1;
}
.start-text {
color: var(--text-muted);
display: block;
font-size: var(--font-size-0);
grid-column: 1;
grid-row: 2;
line-height: 1.5;
}
input[type="range"] {
align-self: center;
grid-column: 2;
grid-row: 1;
min-inline-size: 25ch;
}
/* In spread mode, the value sits below the slider in col 2
(where end-text would normally land). When end-text is also
present it shifts down a row to avoid collision. */
:where(.value) {
grid-column: 2;
grid-row: 2;
}
:where(.end-text) {
grid-column: 2;
grid-row: 2;
text-align: end;
}
datalist {
grid-column: 2;
grid-row: 2;
}
/* When both datalist and end-text exist, push end-text below the datalist
so they don't collide on the same grid cell. */
&:has(datalist) :where(.end-text) {
grid-row: 3;
}
&:has(.value) :where(.end-text) {
grid-row: 3;
}
&:has(.value):has(datalist) :where(.end-text) {
grid-row: 4;
}
@container (width < 400px) {
grid-template-columns: 1fr;
* {
grid-column: 1/-1;
}
.label {
grid-row: 1;
}
/* Share row 1 with the label: the label's text stays left-aligned
in its full-width cell, while the value shrinks to fit and
right-aligns. Visually reads as a "[name ........ value]" row. */
:where(.value) {
grid-row: 1;
justify-self: end;
text-align: end;
}
.start-text {
grid-row: 2;
}
input[type="range"] {
grid-column: 1/-1;
grid-row: 3;
}
datalist {
grid-column: 1/-1;
grid-row: 4;
}
:where(.end-text) {
grid-row: 5;
text-align: start;
}
}
}
/* Validation */
&[data-invalid],
&:has(:user-invalid) {
--_thumb-bg: var(--color-9);
--_thumb-highlight-color: oklch(from var(--color-9) 70% 100% h / 20%);
.start-text,
.end-text,
.value {
color: var(--color-9);
}
}
/* Range input */
:where(& > input[type="range"]) {
--_thumb-bg: var(--primary);
--_thumb-highlight-color: oklch(from var(--primary) 70% 100% h / 20%);
--_thumb-highlight-size: 0px;
--_thumb-offset: -1.125ex;
--_thumb-size: 3ex;
--_track-color: var(--field-border-color);
--_track-fill: 0%;
--_track-fill-color: var(--primary);
--_track-height: 0.75ex;
--_track-radius: 1e5px;
appearance: none;
background: transparent;
block-size: var(--size-4);
display: block;
grid-column: 1/-1;
grid-row: 3;
inline-size: 100%;
outline-offset: 1ex;
@media (hover: none) {
--_thumb-offset: -11.5px;
--_thumb-size: 30px;
}
/* Track */
&::-webkit-slider-runnable-track {
appearance: none;
background: linear-gradient(to right,
var(--_track-fill-color) var(--_track-fill, 0%),
var(--_track-color) 0%);
block-size: var(--_track-height);
border-radius: var(--_track-radius);
}
&::-moz-range-track {
appearance: none;
background: var(--_track-color);
block-size: var(--_track-height);
border-radius: var(--_track-radius);
}
&::-moz-range-progress {
appearance: none;
background: var(--_track-fill-color);
block-size: var(--_track-height);
border-radius: var(--_track-radius);
}
/* Ring */
&::-webkit-slider-thumb {
appearance: none;
background: var(--_thumb-bg);
block-size: var(--_thumb-size);
border: 3px solid var(--surface-default);
border-radius: 50%;
box-shadow: 0 0 0 var(--_thumb-highlight-size) var(--_thumb-highlight-color);
cursor: ew-resize;
inline-size: var(--_thumb-size);
margin-block-start: var(--_thumb-offset);
@media (prefers-reduced-motion: no-preference) {
transition: box-shadow 0.1s ease;
}
.fieldset-item:focus-within & {
border-color: var(--gray-14);
}
}
&::-moz-range-thumb {
appearance: none;
background: var(--_thumb-bg);
block-size: var(--_thumb-size);
border: 3px solid var(--surface-default);
border-radius: 50%;
box-shadow: 0 0 0 var(--_thumb-highlight-size) var(--_thumb-highlight-color);
cursor: ew-resize;
inline-size: var(--_thumb-size);
margin-block-start: var(--_thumb-offset);
@media (prefers-reduced-motion: no-preference) {
transition: box-shadow 0.1s ease;
}
.fieldset-item:focus-within & {
border-color: var(--gray-14);
}
}
/* Element state */
.range:has(:user-invalid) &,
.range[data-invalid] & {
--_track-fill-color: var(--color-9);
}
&:not([disabled]) {
&:hover {
--_thumb-highlight-size: var(--size-1);
}
&:active {
--_thumb-highlight-size: var(--size-2);
--_track-color: light-dark(oklch(from var(--field-border-color) calc(l * 0.9) c h),
oklch(from var(--field-border-color) calc(l * 1.1) c h));
}
}
&[disabled] {
--_thumb-bg: oklch(from var(--text-primary) l c h / 50%);
--_track-color: var(--field-border-color);
cursor: not-allowed;
&::-webkit-slider-thumb {
cursor: not-allowed;
}
&::-moz-range-thumb {
cursor: not-allowed;
}
}
}
}
}