Skip to content

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.

Terminal window
pnpm add @agencecinq/slider

One 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.

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.

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.

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.

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).

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);
},
);

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.

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;
}

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)));
}

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).

The Encounter builder (advanced) playground demo combines:

  • preset loading with setValues(min, max, { emit: false }) (silent sync, no event flood)
  • reading event.detail.$thumb and comparing to host.$min / host.$max
  • hidden inputs synced from the range for FormData on submit
  • formatValue for readable aria-valuetext on 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;
});
Hook Host
[dragging] Both
--value / --ratio <cinq-slider>
[data-active] / --min-ratio / --max-ratio <cinq-slider-range>

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-visible on 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.
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.
Method / property Description
setValues(min, max, { active?, emit? }) Anti-cross update.
$max, $min, min, max, boundsMin, boundsMax Range state.
formatValue Applied to both thumbs.

Tabard colors

Three sliders mix crimson, verdant, and azure dye for your character token.

Crimson
Verdant
Azure

Heraldry preview: #804020

Encounter difficulty

Rate the last fight from 0 to 10 with readable aria-valuetext.

aria-valuetext: Rate the encounter from 1 to 10, where 10 is a total party kill

Battle replay

Scrub through a five-minute combat log. Values read as minutes and seconds.

Round timeline

aria-valuetext: 1 minute 30 seconds

Loot split

Two thumbs pick a gold piece range for the party treasury split. Tab between handles. Arrow keys move the focused one.

Treasure share (gp)

Split: 200 gp to 800 gp

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).

Split: 300 + 350 + 350 = 1000 gp

Party treasury

Multi-thumb range with data-step="50". Gold snaps on pointer, keyboard, and Page Up/Down (data-page="150").

Shared fund (gp)

Fund: 100 gp to 350 gp (step 50 gp)

Potion potency

Vertical slider for a healing potion strength from 1 (weak) to 10 (legendary).

Potion strength

aria-valuetext: Potency 5 out of 10

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.

Target HP band

Band: 18 HP to 26 HP

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.

Challenge rating band

Band: CR 4 to CR 10

Last moved: -

Waiting for slider:change…

Hidden fields stay in sync with the range. Submit to read FormData.

FormData:
  cr_min=4
  cr_max=10