oriUI

Slider

A styled, accessible range control built on a native <input type="range">. Because the underlying element is real HTML, the browser supplies role="slider", the full keyboard contract (arrow keys, Home/End, PageUp/PageDown), and screen-reader value announcements for free — no JavaScript bridge needed. Colour and the filled-track portion are driven by CSS custom properties; v-model carries a number.

The examples are organised by layer: the class reference is the standalone @oriui/css layer, and the Framework API is the @oriui/vue component. Every example is live — flip its code between HTML (the standalone classes, also your htmx / Astro / Svelte / plain-HTML usage), Vue; HTML is the default.

Classes

A slider is a block wrapper plus BEM part classes. The ori-color_* utility repoints the one token the component reads everywhere (--ori-color). The Vue props in Framework API map 1:1 to these.

ClassTypeDescription
ori-sliderBlockRequired base class (wrapper div). Full-width by default.
ori-color_*Colorprimary · secondary · success · warn · danger · info · surface
ori-slider__labelPartFlex row wrapping the visible label text and optional current value.
ori-slider__valuePartCurrent value badge inside the label row; tabular-nums, slightly dimmed.
ori-slider__inputPartThe native ; receives the inline --ori-slider-pct custom property.
data-disabledStateSet on the wrapper when disabled is true; dims the control and changes the cursor to not-allowed.

Live demo

Drag either handle to see v-model update in real time. The two sliders share no state — each drives its own ref.

html
<!--
  The filled portion is an inline CSS custom property — set --ori-slider-pct
  to ((value - min) / (max - min)) * 100 from your own script.
-->
<div class="ori-slider ori-color_primary" style="--ori-slider-pct: 60%">
    <label class="ori-slider__label" for="vol">
        <span>Volume</span>
        <span class="ori-slider__value">60</span>
    </label>
    <input id="vol" type="range" class="ori-slider__input" min="0" max="100" step="1" value="60" />
</div>
vue
<script setup>
import { ref } from 'vue';
const volume = ref(60);
const brightness = ref(40);
</script>

<template>
    <div style="display: flex; flex-direction: column; gap: 1.5rem; width: 100%; max-width: 24rem">
        <OriSlider v-model="volume" label="Volume" :show-value="true" />
        <OriSlider v-model="brightness" label="Brightness" color="warn" :show-value="true" :max="200" />
    </div>
</template>

Colors

Every semantic role. The filled track, the thumb, and the focus ring all read --ori-color.

html
<!-- swap ori-color_primary for any semantic role -->
<div class="ori-slider ori-color_danger" style="--ori-slider-pct: 60%">
    <label class="ori-slider__label" for="s-danger"><span>danger</span></label>
    <input id="s-danger" type="range" class="ori-slider__input" min="0" max="100" step="1" value="60" />
</div>
vue
<OriSlider :model-value="60" color="primary" label="primary" />
<OriSlider :model-value="60" color="secondary" label="secondary" />
<OriSlider :model-value="60" color="warn" label="warn" />
<OriSlider :model-value="60" color="danger" label="danger" />

Range (min / max / step)

min, max, and step map directly to the native <input> attributes. The component recomputes --ori-slider-pct from these, so the filled portion stays accurate.

html
<div class="ori-slider ori-color_primary" style="--ori-slider-pct: 40%">
    <label class="ori-slider__label" for="rating">
        <span>Rating</span>
        <span class="ori-slider__value">4</span>
    </label>
    <input id="rating" type="range" class="ori-slider__input" min="0" max="10" step="1" value="4" />
</div>
vue
<!-- 0–10 in whole steps -->
<OriSlider v-model="rating" :min="0" :max="10" :step="1" label="Rating" :show-value="true" />

<!-- 0–200 in steps of 10 -->
<OriSlider v-model="brightness" :min="0" :max="200" :step="10" label="Brightness" color="warn" :show-value="true" />

<!-- negative range -->
<OriSlider v-model="balance" :min="-50" :max="50" :step="5" label="Balance" color="secondary" :show-value="true" />

Show value

show-value renders the current number on the trailing end of the label row in a tabular-nums span. Useful when the exact value matters to the user.

html
<!-- with value -->
<div class="ori-slider ori-color_primary" style="--ori-slider-pct: 72%">
    <label class="ori-slider__label" for="sv1">
        <span>Volume</span>
        <span class="ori-slider__value">72</span>
    </label>
    <input id="sv1" type="range" class="ori-slider__input" min="0" max="100" step="1" value="72" />
</div>

<!-- without value — omit the span -->
<div class="ori-slider ori-color_primary" style="--ori-slider-pct: 72%">
    <label class="ori-slider__label" for="sv2"><span>Volume</span></label>
    <input id="sv2" type="range" class="ori-slider__input" min="0" max="100" step="1" value="72" />
</div>
vue
<!-- value visible -->
<OriSlider v-model="vol" label="Volume" :show-value="true" />

<!-- value hidden (default) -->
<OriSlider v-model="vol" label="Volume" />

Disabled

disabled sets the native disabled attribute on the <input> and adds data-disabled on the wrapper, which applies 50 % opacity and a not-allowed cursor.

html
<div class="ori-slider ori-color_primary" data-disabled style="--ori-slider-pct: 30%">
    <label class="ori-slider__label" for="dis"><span>Locked</span></label>
    <input id="dis" type="range" class="ori-slider__input" min="0" max="100" step="1" value="30" disabled />
</div>
vue
<OriSlider :model-value="30" disabled label="Locked" />

Commit on release

update:modelValue fires on every tick of a drag — ideal for a live preview, but it would flood an undo history (or re-run an expensive effect) on every intermediate value. change fires once, when the user releases the thumb or commits a keyboard step, carrying the settled number. Bind both: v-model for the live value, @change for the commit.

vue
<script setup>
import { ref } from 'vue';

const opacity = ref(100);
const undoStack = [];

function commit(value) {
    // record ONE undo step for the whole drag — not one per tick
    undoStack.push({ opacity: value });
}
</script>

<template>
    <!-- v-model tracks the live drag (thumb + fill); @change commits the settled value -->
    <OriSlider v-model="opacity" label="Opacity" :show-value="true" :min="0" :max="100" @change="commit" />
</template>

A drag from 100 → 40 streams update:modelValue the whole way, so the fill animates live — but change emits a single 40 on release, so history gets one entry, not sixty. Keyboard adjustments commit per step, though: each arrow-key press is its own change (one undo entry per press), since the browser settles the value on every keystroke.

Common patterns

A settings panel with labelled sliders — the most common real-world composition.

html
<div style="display: flex; flex-direction: column; gap: 1.25rem; max-width: 24rem">
    <div class="ori-slider ori-color_primary" style="--ori-slider-pct: 75%">
        <label class="ori-slider__label" for="p-vol">
            <span>Volume</span><span class="ori-slider__value">75</span>
        </label>
        <input id="p-vol" type="range" class="ori-slider__input" min="0" max="100" step="1" value="75" />
    </div>
    <div class="ori-slider ori-color_secondary" style="--ori-slider-pct: 75%">
        <label class="ori-slider__label" for="p-bal">
            <span>Balance</span><span class="ori-slider__value">50</span>
        </label>
        <input id="p-bal" type="range" class="ori-slider__input" min="-100" max="100" step="5" value="50" />
    </div>
</div>
vue
<div style="display: flex; flex-direction: column; gap: 1.25rem; max-width: 24rem">
    <OriSlider v-model="volume" label="Volume" :show-value="true" />
    <OriSlider v-model="balance" label="Balance" color="secondary" :show-value="true" :min="-100" :max="100" :step="5" />
    <OriSlider v-model="bass" label="Bass boost" color="info" :show-value="true" :max="40" />
</div>

Accessibility

The accessibility contract holds across every layer — the standalone classes and the Vue component render the same attributes and keyboard behaviour.

  • Renders a real <input type="range">, so the browser supplies role="slider", the accessible value (aria-valuenow, aria-valuemin, aria-valuemax), and value announcements to screen readers with no extra ARIA needed.
  • The label prop renders a <label> linked to the input via for/id (auto-generated via useId()). Without a label, pass an aria-label or aria-labelledby attribute — OriSlider sets inheritAttrs: false and binds $attrs to the <input>, so the name lands on the real control. showValue is a visual supplement, not a name source — pair it with label or aria-label.
  • disabled is the real native attribute; the wrapper receives data-disabled for CSS only.
  • The focus ring appears on the thumb (:focus-visible) and uses --ori-color, so it always matches the active colour role.
  • For the HTML layer, set --ori-slider-pct inline to ((value - min) / (max - min)) * 100% so the WebKit gradient fill reflects the current value; Firefox reads ::-moz-range-progress directly from the element value and needs no custom property.
KeyAction
ArrowRightIncreases value by one step.
ArrowUpIncreases value by one step.
ArrowLeftDecreases value by one step.
ArrowDownDecreases value by one step.
HomeJumps to min.
EndJumps to max.
PageUpIncreases value by a larger step (browser-determined).
PageDownDecreases value by a larger step (browser-determined).

Framework API

The props, events, and slots of the Vue component. The standalone CSS layer has no component API — its surface is the classes above. (Svelte bindings are planned.)

Props

PropTypeDefaultDescription
colorThemeColor'primary'Semantic accent: primary · secondary · success · warn · danger · info · surface.
disabledbooleanSets native disabled on the input and data-disabled on the wrapper.
labelstringVisible <label> text, linked to the input via for/id.
maxnumber100Maximum value — maps to the native max attribute.
minnumber0Minimum value — maps to the native min attribute.
modelValuenumberControlled value; bind with v-model. Defaults to min when omitted.
showValuebooleanRenders the current value in an ori-slider__value span beside the label.
stepnumber1Increment per arrow-key press — maps to the native step attribute.

Events & attributes

EventPayloadDescription
update:modelValuenumberFired live on every input (each drag tick / arrow press); drives v-model.
changenumberFired once when the value is committed — pointer release, or a keyboard step. Use it to record undo history or run a per-release side effect that must not fire per tick (see Commit on release).
vue
<OriSlider v-model="volume" label="Volume" :show-value="true" />

@change carries the committed number, not a DOM Event — it is a declared emit, so it no longer falls through to the <input>. If you need the raw native event (e.g. event.target.validity), attach a listener to the underlying <input> via a template ref.

OriSlider sets inheritAttrs: false, so undeclared native attributes (aria-label, aria-describedby, name, id, …) fall through to the <input>, not the wrapper — they name, describe, and submit the real control. label auto-generates the input id via useId(), so an explicit id is only needed to override it.

Slots

SlotDescription
labelRich label content (an icon + text, markup). Falls back to the label prop; keeps the for/id a11y wiring.