Components
Range
Full support Supported since v125. Full support Supported since v128. Full support Supported since v18.
<script setup lang="ts">import { Range } from "opui-css/vue"</script>
<template> <Range label="Label" startText="Min" /></template><label class="ui-range" ><span class="ui-label" id="s0-1"><!--[-->Label<!--]--></span ><!----><span class="ui-start-text" id="s0-2"><!--[-->Min<!--]--></span ><input aria-describedby="s0-2" aria-labelledby="s0-1" id="s0-0" type="range" /><!----><!----></label>Start text & End text
<script setup lang="ts">import { Range } from "opui-css/vue"</script>
<template> <Range label="Label" startText="Start helper text" endText="End helper text" /></template><label class="ui-range" ><span class="ui-label" id="s1-1"><!--[-->Label<!--]--></span ><!----><span class="ui-start-text" id="s1-2" ><!--[-->Start helper text<!--]--></span ><input aria-describedby="s1-2 s1-3" aria-labelledby="s1-1" id="s1-0" type="range" /><!----><span class="ui-end-text" id="s1-3" ><!--[-->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.
<script setup lang="ts">import { Range } from "opui-css/vue"</script>
<template> <Range label="Hue" min="0" max="360" value="250" valueSuffix="°" /></template><label class="ui-range" ><span class="ui-label" id="s2-1"><!--[-->Hue<!--]--></span ><output class="ui-value" for="s2-0" data-suffix="°" ><!--[-->250<!--]--></output ><!----><input aria-labelledby="s2-1" id="s2-0" type="range" min="0" max="360" /><!----><!----></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>.
<script setup lang="ts">import { Range } from "opui-css/vue"</script>
<template> <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%' }, ]" /></template><label class="ui-range" ><span class="ui-label" id="s3-1"><!--[-->Tick marks with labels<!--]--></span ><!----><!----><input aria-labelledby="s3-1" id="s3-0" 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.
<script setup lang="ts">import { Range } from "opui-css/vue"</script>
<template> <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></template><!--[--><label class="ui-range" ><span class="ui-label" id="s4-1"><!--[-->Default<!--]--></span ><!----><!----><input aria-labelledby="s4-1" id="s4-0" type="range" /><!----><!----></label><label class="ui-range ui-default" ><span class="ui-label" id="s4-5" ><!--[--><code>default</code> = <code>var(--surface-default)</code ><!--]--></span ><!----><!----><input aria-labelledby="s4-5" id="s4-4" type="range" /><!----><!----></label><label class="ui-range ui-filled" ><span class="ui-label" id="s4-9" ><!--[--><code>filled</code> = <code>var(--surface-filled)</code ><!--]--></span ><!----><!----><input aria-labelledby="s4-9" id="s4-8" type="range" /><!----><!----></label><label class="ui-range ui-tonal" ><span class="ui-label" id="s4-13" ><!--[--><code>tonal</code> = <code>var(--surface-tonal)</code ><!--]--></span ><!----><!----><input aria-labelledby="s4-13" id="s4-12" type="range" /><!----><!----></label><!--]-->Disabled
<script setup lang="ts">import { Range } from "opui-css/vue"</script>
<template> <Range disabled label="Disabled" /></template><label class="ui-range" ><span class="ui-label" id="s5-1"><!--[-->Disabled<!--]--></span ><!----><!----><input aria-labelledby="s5-1" id="s5-0" type="range" disabled /><!----><!----></label>Validation
<script setup lang="ts">import { Range } from "opui-css/vue"</script>
<template> <Range label="Invalid Range" data-invalid endText="This value is incorrect." /></template><label class="ui-range" ><span class="ui-label" id="s6-1"><!--[-->Invalid Range<!--]--></span ><!----><!----><input aria-describedby="s6-3" aria-labelledby="s6-1" id="s6-0" type="range" data-invalid /><!----><span class="ui-end-text" id="s6-3" ><!--[-->This value is incorrect.<!--]--></span ></label>Spread
<script setup lang="ts">import { Range } from "opui-css/vue"</script>
<template> <Range spread> Spread Layout <template #start-text>Start text</template> <template #end-text>End text</template> </Range>
<Range spread disabled> Disabled <template #start-text>Start text</template> <template #end-text>End text</template> </Range>
<Range spread data-invalid endText="This value is incorrect."> Invalid Range <template #start-text>Start text</template> </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%' }, ]" /></template><!--[--><label class="ui-range ui-spread" ><span class="ui-label" id="s7-1" ><!--[--> Spread Layout <!--]--></span ><!----><span class="ui-start-text" id="s7-2"><!--[-->Start text<!--]--></span ><input aria-describedby="s7-2 s7-3" aria-labelledby="s7-1" id="s7-0" type="range" /><!----><span class="ui-end-text" id="s7-3" ><!--[-->End text<!--]--></span ></label><label class="ui-range ui-spread" ><span class="ui-label" id="s7-5" ><!--[--> Disabled <!--]--></span ><!----><span class="ui-start-text" id="s7-6"><!--[-->Start text<!--]--></span ><input aria-describedby="s7-6 s7-7" aria-labelledby="s7-5" id="s7-4" type="range" disabled /><!----><span class="ui-end-text" id="s7-7" ><!--[-->End text<!--]--></span ></label><label class="ui-range ui-spread" ><span class="ui-label" id="s7-9" ><!--[--> Invalid Range <!--]--></span ><!----><span class="ui-start-text" id="s7-10" ><!--[-->Start text<!--]--></span ><input aria-describedby="s7-10 s7-11" aria-labelledby="s7-9" id="s7-8" type="range" data-invalid /><!----><span class="ui-end-text" id="s7-11" ><!--[-->This value is incorrect.<!--]--></span ></label><label class="ui-range ui-spread" ><span class="ui-label" id="s7-13" ><!--[-->Tick marks with labels<!--]--></span ><!----><!----><input aria-labelledby="s7-13" id="s7-12" 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
<script setup lang="ts">import { Range } from "opui-css/vue"</script>
<template> <Range label="Volume" :min="0" :max="100" :value="50" spread disabled /></template><label class="ui-range ui-spread" ><span class="ui-label" id="s8-1"><!--[-->Volume<!--]--></span ><!----><!----><input aria-labelledby="s8-1" id="s8-0" type="range" min="0" max="100" disabled /><!----><!----></label>Validation
<script setup lang="ts">import { Range } from "opui-css/vue"</script>
<template> <Range label="Volume" :min="0" :max="100" :value="50" spread data-invalid /></template><label class="ui-range ui-spread" ><span class="ui-label" id="s9-1"><!--[-->Volume<!--]--></span ><!----><!----><input aria-labelledby="s9-1" id="s9-0" type="range" min="0" max="100" data-invalid /><!----><!----></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
- Container
- Label (optional)
- Value (optional)
- Start text (optional)
- Input
- 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(.ui-range) { --_motion: var(--motion, 1); 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;
.ui-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(.ui-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; }
.ui-start-text { color: var(--text-muted); font-size: var(--font-size-0); grid-column: 1/-1; grid-row: 2; line-height: 1.5; }
:where(.ui-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;
.ui-label, .ui-value, .ui-start-text, .ui-end-text { cursor: not-allowed; } }
/* Color */ &.ui-filled input[type="range"] { --_track-color: var(--surface-filled); }
&.ui-default input[type="range"] { --_track-color: var(--surface-default); }
&.ui-tonal input[type="range"] { --_track-color: var(--surface-tonal); }
/* Orientation */ &.ui-spread { align-items: center; column-gap: var(--size-4); container-type: inline-size; grid-template-columns: 1fr auto;
.ui-label { grid-column: 1; grid-row: 1; }
.ui-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(.ui-value) { grid-column: 2; grid-row: 2; }
:where(.ui-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(.ui-end-text) { grid-row: 3; }
&:has(.ui-value) :where(.ui-end-text) { grid-row: 3; }
&:has(.ui-value):has(datalist) :where(.ui-end-text) { grid-row: 4; }
@container (width < 400px) { grid-template-columns: 1fr;
* { grid-column: 1/-1; }
.ui-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(.ui-value) { grid-row: 1; justify-self: end; text-align: end; }
.ui-start-text { grid-row: 2; }
input[type="range"] { grid-column: 1/-1; grid-row: 3; }
datalist { grid-column: 1/-1; grid-row: 4; }
:where(.ui-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%);
.ui-start-text, .ui-end-text, .ui-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);
transition: box-shadow calc(0.1s * var(--_motion)) ease;
.ui-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);
transition: box-shadow calc(0.1s * var(--_motion)) ease;
.ui-fieldset-item:focus-within & { border-color: var(--gray-14); } }
/* Element state */ .ui-range:has(:user-invalid) &, .ui-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; } } } }}