Slider
A slider lets users pick a value within a range by moving a thumb along a rail.
<cinq-slider> is the rail (geometry + CSS variables). A nested
[role="slider"] thumb is the focusable control.
Implementation follows the
WAI-ARIA Authoring Practices slider pattern.
Inspired by @19h47/slider.
Install
Section titled “Install”pnpm add @agencecinq/sliderOne import registers both tags:
import "@agencecinq/slider";<label id="volume-label" for="volume-slider">Volume</label><cinq-slider class="volume-rail"> <button type="button" id="volume-slider" role="slider" tabindex="0" aria-labelledby="volume-label" aria-orientation="horizontal" aria-valuemin="0" aria-valuemax="100" aria-valuenow="50" aria-valuetext="50%" ></button></cinq-slider>HTML is the source of truth. The component will not auto-set
role, auto-migrate attributes, or warn about missing labels. Use an a11y linter (axe-core, Lighthouse) to catch invalid markup.
The package implements APG behaviour (keyboard, pointer, ARIA sync, events). Layout, hit area, contrast, and focus rings are your CSS.
Required markup, single
Section titled “Required markup, single”| Attribute / element | Required | Role |
|---|---|---|
<cinq-slider> |
Yes | Rail. Exactly one thumb. |
[role="slider"] |
Yes | Focusable thumb ($thumb). |
tabindex="0" |
Yes | On the thumb. |
aria-orientation="horizontal" or "vertical" |
Yes | On the thumb. |
aria-valuemin / aria-valuemax / aria-valuenow |
Yes | On the thumb. |
aria-label or aria-labelledby |
Yes | On the thumb, per APG. |
Range, <cinq-slider-range>
Section titled “Range, <cinq-slider-range>”Two thumbs in APG order: max (first in DOM), min (second).
For three shares that must sum to a fixed total, use three <cinq-slider> hosts
and rebalance in app code. See Party loot (1000 gp) in the playground: each
slider is one hero's share in gp. When one share grows, the script takes gp
from the others (rogue first, then cleric or paladin) so the total stays 1000.
Options
Section titled “Options”| Attribute | Type | Default | Description |
|---|---|---|---|
data-step |
number | 1 |
Arrow key increment. |
data-page |
number | 10 |
Page Up / Page Down increment. |
Thumb and track layout are consumer CSS. The component only sets host CSS variables.
Keyboard
Section titled “Keyboard”Range: both thumbs are focusable. Tab / Shift+Tab moves between max (first in DOM) and min (second); arrow keys move the focused thumb. Tab order is DOM order (APG), not left-to-right when handles overlap.
Vertical: Up increases, Down decreases. RTL does not apply to the vertical axis.
| Key | Horizontal LTR | Horizontal RTL | Vertical |
|---|---|---|---|
| Right | + step | − step | + step |
| Left | − step | + step | − step |
| Up / Down | + / − step | same | + / − step |
| Home / End | min / max (single); APG multi-thumb (range) | same | same |
| Page Up / Down | + / − page | same | same |
Set dir="rtl" on <cinq-slider> or an ancestor for horizontal sliders.
Thumb position and pointer mapping mirror. Left / Right keys swap. Vertical
sliders ignore direction on the value axis. aria-valuenow and --ratio
stay semantic (0 = min, 1 = max).
Events
Section titled “Events”Both hosts emit slider:change with the same detail:
| Field | Description |
|---|---|
min |
Current low end (single: current value) |
max |
Current high end (single: same as min) |
$thumb |
Handle that moved or has focus |
import { EVENTS } from "@agencecinq/utils";
document.querySelector("cinq-slider-range")?.addEventListener( EVENTS.SLIDER_CHANGE, (event) => { const { min, max, $thumb } = event.detail; console.log(min, max, $thumb); },);Positioning with CSS variables
Section titled “Positioning with CSS variables”The package does not position thumbs or track in JS. It syncs ARIA and sets CSS custom properties on the host. You implement layout in your stylesheet.
Variables
Section titled “Variables”| Host | Properties | Meaning |
|---|---|---|
<cinq-slider> |
--value, --ratio |
Current value, ratio 0 to 1 within bounds |
<cinq-slider-range> |
--min, --max, --min-ratio, --max-ratio |
Current range; semantic ratios within global bounds |
Ratios are semantic (0 = min, 1 = max), including in RTL. Prefer logical properties (inset-inline-start) so layout mirrors with dir="rtl".
Define thumb size on the host (your naming, not set by the lib):
cinq-slider { position: relative; --thumb-size: 2.75rem;}Single thumb
Section titled “Single thumb”Rail (inset by half a thumb so the handle reaches both ends):
cinq-slider::before { content: ""; position: absolute; inset-inline: calc(var(--thumb-size) / 2); top: 50%; block-size: 0.375rem; border-radius: 9999px; background: #e5e7eb; transform: translateY(-50%);}
cinq-slider::after { content: ""; position: absolute; inset-inline-start: calc(var(--thumb-size) / 2); top: 50%; width: calc(var(--ratio) * (100% - var(--thumb-size))); block-size: 0.375rem; border-radius: 9999px; background: currentColor; transform: translateY(-50%);}Thumb:
cinq-slider [role="slider"] { position: absolute; top: calc(50% - var(--thumb-size) / 2); width: var(--thumb-size); height: var(--thumb-size); inset-inline-start: calc(var(--ratio) * (100% - var(--thumb-size)));}Range (two thumbs)
Section titled “Range (two thumbs)”DOM order: first = max, second = min.
cinq-slider-range { position: relative; --thumb-size: 1rem;}
cinq-slider-range::after { content: ""; position: absolute; inset-inline-start: calc( var(--thumb-size) / 2 + var(--min-ratio) * (100% - var(--thumb-size)) ); top: 50%; width: calc( (var(--max-ratio) - var(--min-ratio)) * (100% - var(--thumb-size)) ); block-size: 0.375rem; border-radius: 0; background: currentColor; transform: translateY(-50%);}
cinq-slider-range [role="slider"]:first-child { inset-inline-start: calc(var(--max-ratio) * (100% - var(--thumb-size)));}
cinq-slider-range [role="slider"]:last-child { inset-inline-start: calc(var(--min-ratio) * (100% - var(--thumb-size)));}When thumbs share the same value, the host sets [data-collapsed]. Offset the min
handle by one thumb width on the low side so both stay visible (see README).
When they differ but meet, use [data-active="min"] / [data-active="max"] to
raise z-index on the active handle.
| Hook | Use |
|---|---|
[dragging] |
Grabbing cursor, active state |
[data-active="min"|"max"] |
Range overlap stacking |
[data-collapsed] |
min === max, side-by-side thumb layout |
See the playground below for working examples (compact thumbs, seek label, price range).
Advanced encounter builder
Section titled “Advanced encounter builder”The Encounter builder (advanced) playground demo combines:
- preset loading with
setValues(min, max, { emit: false })(silent sync, no event flood) - reading
event.detail.$thumband comparing tohost.$min/host.$max - hidden inputs synced from the range for
FormDataon submit formatValuefor readablearia-valuetexton both thumbs
host.setValues(4, 10, { active: "min", emit: false });
host.addEventListener(EVENTS.SLIDER_CHANGE, (event) => { const { min, max, $thumb } = event.detail; const which = $thumb === host.$min ? "min" : $thumb === host.$max ? "max" : null;});Styling hooks (summary)
Section titled “Styling hooks (summary)”| Hook | Host |
|---|---|
[dragging] |
Both |
--value / --ratio |
<cinq-slider> |
[data-active] / --min-ratio / --max-ratio |
<cinq-slider-range> |
Consumer styling and accessibility
Section titled “Consumer styling and accessibility”Not enforced by the package.
- Touch targets: WCAG recommends at least 44×44 CSS px for pointer targets. Use a large hit area with a smaller visual knob if needed.
- Focus: style
:focus-visibleon the thumb. - Touch AT: the APG warns that some touch-based assistive technologies may not yet synthesize key events for custom sliders. Test on real devices with VoiceOver / TalkBack.
Slider, <cinq-slider>
Section titled “Slider, <cinq-slider>”| Method / property | Description |
|---|---|
setValue, sync, rect, init, destroy |
Single-thumb API. |
$thumb, value, min, max, ratio |
Read from thumb ARIA. |
formatValue |
(value) => string for aria-valuetext. |
SliderRange, <cinq-slider-range>
Section titled “SliderRange, <cinq-slider-range>”| Method / property | Description |
|---|---|
setValues(min, max, { active?, emit? }) |
Anti-cross update. |
$max, $min, min, max, boundsMin, boundsMax |
Range state. |
formatValue |
Applied to both thumbs. |
Playground
Section titled “Playground”Tabard colors
Three sliders mix crimson, verdant, and azure dye for your character token.
Encounter difficulty
Rate the last fight from 0 to 10 with readable aria-valuetext.
Battle replay
Scrub through a five-minute combat log. Values read as minutes and seconds.
Loot split
Two thumbs pick a gold piece range for the party treasury split. Tab between handles. Arrow keys move the focused one.
Party loot (1000 gp)
Split a fixed 1000 gp treasury between three characters. Each slider is that hero's share. The three values always sum to 1000 (app code on slider:change takes from the others when one share grows).
Party treasury
Multi-thumb range with data-step="50". Gold snaps on pointer, keyboard, and Page Up/Down (data-page="150").
Potion potency
Vertical slider for a healing potion strength from 1 (weak) to 10 (legendary).
HP comfort band
Vertical range for hit points you want to keep between rests. High values at the top (aria-valuemax), low at the bottom. Tick labels follow the same order.
Underdark torch
Horizontal slider with dir="rtl". Pointer and arrow keys mirror on the rail.
Encounter builder (advanced)
Presets via setValues(..., { emit: false }), last moved thumb fromevent.detail.$thumb, and hidden inputs for form submit.